ticketlens 0.9.13 → 0.9.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -11,6 +11,21 @@ import { DEFAULT_CONFIG_DIR } from './config.mjs';
|
|
|
11
11
|
const MAX_ATTACHMENTS = 20;
|
|
12
12
|
const MAX_FILE_BYTES = 10 * 1024 * 1024; // 10 MB
|
|
13
13
|
|
|
14
|
+
// Blocks redirect targets to private/internal addresses (SSRF via redirect).
|
|
15
|
+
// Mirrors BLOCKED_PATTERNS in jira-client.mjs; kept local to avoid coupling.
|
|
16
|
+
const BLOCKED_REDIRECT_PATTERNS = [
|
|
17
|
+
/^127\./, /^10\./, /^172\.(1[6-9]|2\d|3[01])\./, /^192\.168\./,
|
|
18
|
+
/^169\.254\./, /^0\.0\.0\.0$/, /^::1$/, /^::ffff:/i, /^fc00:/i, /^fe80:/i, /^localhost$/i,
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
function isSafeRedirectUrl(url) {
|
|
22
|
+
let parsed;
|
|
23
|
+
try { parsed = new URL(url); } catch { return false; }
|
|
24
|
+
if (parsed.protocol !== 'https:') return false;
|
|
25
|
+
const hostname = parsed.hostname.replace(/^\[|\]$/g, '');
|
|
26
|
+
return !BLOCKED_REDIRECT_PATTERNS.some(re => re.test(hostname));
|
|
27
|
+
}
|
|
28
|
+
|
|
14
29
|
/**
|
|
15
30
|
* @param {object} ticket Normalized ticket (from normalizeTicket)
|
|
16
31
|
* @param {object} opts
|
|
@@ -83,10 +98,23 @@ export async function downloadAttachments(ticket, opts = {}) {
|
|
|
83
98
|
onProgress?.(` download ${a.filename}`);
|
|
84
99
|
try {
|
|
85
100
|
const headers = buildAuthHeader(env);
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
const
|
|
101
|
+
// Probe with redirect:'manual' to intercept any redirect before following it.
|
|
102
|
+
// Validates the redirect target against BLOCKED_REDIRECT_PATTERNS so a
|
|
103
|
+
// Jira-origin URL that redirects to a private IP (e.g. 169.254.x.x) is rejected.
|
|
104
|
+
const probe = await fetcher(a.content, { headers, redirect: 'manual' });
|
|
105
|
+
let response;
|
|
106
|
+
if (probe.status >= 300 && probe.status < 400) {
|
|
107
|
+
const location = probe.headers?.get?.('location');
|
|
108
|
+
if (!location) throw new Error('Redirect without Location header');
|
|
109
|
+
const redirectUrl = new URL(location, a.content).href;
|
|
110
|
+
if (!isSafeRedirectUrl(redirectUrl)) {
|
|
111
|
+
throw new Error(`Redirect to blocked host (${new URL(redirectUrl).hostname}) rejected`);
|
|
112
|
+
}
|
|
113
|
+
// Safe CDN/S3 redirect — follow without auth (presigned URLs are self-authorizing)
|
|
114
|
+
response = await fetcher(redirectUrl, { redirect: 'follow' });
|
|
115
|
+
} else {
|
|
116
|
+
response = probe;
|
|
117
|
+
}
|
|
90
118
|
if (!response.ok) throw new Error(`HTTP ${response.status} (${response.statusText})`);
|
|
91
119
|
const buffer = await response.arrayBuffer();
|
|
92
120
|
fs.writeFileSync(localPath, Buffer.from(buffer));
|