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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.9.13",
3
+ "version": "0.9.14",
4
4
  "description": "Jira CLI for developers — fetch ticket context, triage your queue, and stop tab-switching. Zero dependencies, all local.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- // Default redirect:'follow' — Jira Cloud attachment URLs may redirect to CDN/S3
87
- // presigned URLs. The SSRF guard above already validates the initial URL is on
88
- // the configured Jira origin, which is the relevant security boundary here.
89
- const response = await fetcher(a.content, { headers });
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));