@bacnh85/pi-web 0.10.2 → 0.10.3

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/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.10.3 (2026-09-13)
4
+
5
+ ### Fixed
6
+
7
+ - **Redirect-aware SSRF guard**: image downloads now use `redirect: "manual"`
8
+ and re-validate every hop with the private/loopback check — a gateway URL
9
+ that 302s to an internal host (e.g. cloud metadata) can no longer bypass
10
+ the guard via fetch's default redirect following. Public redirects still
11
+ work (relative locations resolved per hop, max 3 hops).
12
+
3
13
  ## 0.10.2 (2026-09-13)
4
14
 
5
15
  ### Fixed
@@ -159,8 +159,15 @@ export function imageRateSnapshot(): Record<string, { count: number; day: string
159
159
  export interface FetchLike {
160
160
  (
161
161
  url: string,
162
- init?: { method?: string; headers?: Record<string, string>; body?: string; signal?: AbortSignal },
163
- ): Promise<{ ok: boolean; status: number; statusText?: string; json(): Promise<unknown>; arrayBuffer(): Promise<ArrayBuffer> }>;
162
+ init?: { method?: string; headers?: Record<string, string>; body?: string; signal?: AbortSignal; redirect?: string },
163
+ ): Promise<{
164
+ ok: boolean;
165
+ status: number;
166
+ statusText?: string;
167
+ headers?: { get(name: string): string | null };
168
+ json(): Promise<unknown>;
169
+ arrayBuffer(): Promise<ArrayBuffer>;
170
+ }>;
164
171
  }
165
172
 
166
173
  export interface ApiImageResult {
@@ -263,17 +270,30 @@ async function downloadImage(
263
270
  signal?: AbortSignal,
264
271
  timeoutMs?: number,
265
272
  ): Promise<string> {
266
- const res = await raceGuard(fetchImpl(url, { method: "GET", signal }), {
267
- signal,
268
- timeoutMs: timeoutMs ?? 120_000,
269
- label: "web_image download",
270
- });
271
- if (!res.ok) throw new ImageApiError(res.status, `image download failed (HTTP ${res.status})`);
273
+ // fetch follows redirects by default follow manually and re-validate each
274
+ // hop, or a gateway URL that 302s to an internal host bypasses the guard.
275
+ let current = url;
276
+ for (let hop = 0; ; hop++) {
277
+ if (hop > 3) throw new Error("too many image redirects");
278
+ if (isLocalUrl(current)) throw new Error(`image host is private/loopback (SSRF-guarded): ${current}`);
279
+ const res = await raceGuard(fetchImpl(current, { method: "GET", redirect: "manual", signal }), {
280
+ signal,
281
+ timeoutMs: timeoutMs ?? 120_000,
282
+ label: "web_image download",
283
+ });
284
+ if ([301, 302, 303, 307, 308].includes(res.status)) {
285
+ const loc = res.headers?.get?.("location") ?? null;
286
+ if (!loc) throw new ImageApiError(res.status, `image redirect ${res.status} without a location header`);
287
+ current = new URL(loc, current).toString();
288
+ continue;
289
+ }
290
+ if (!res.ok) throw new ImageApiError(res.status, `image download failed (HTTP ${res.status})`);
272
291
  const buf = Buffer.from(await res.arrayBuffer());
273
292
  if (buf.length > MAX_DOWNLOAD_BYTES) throw new Error(`image exceeds the ${MAX_DOWNLOAD_BYTES}-byte download cap`);
274
293
  const file = path.join(outDir, `pi-web-image-${randomUUID().slice(0, 8)}-${i}${extFor(url)}`);
275
294
  fs.writeFileSync(file, buf);
276
295
  return file;
296
+ }
277
297
  }
278
298
 
279
299
  function extFor(url: string): string {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bacnh85/pi-web",
3
- "version": "0.10.2",
3
+ "version": "0.10.3",
4
4
  "description": "Pi extension for web search, page extraction, Firecrawl scraping/crawling, Crawl4AI headless browser crawling, Gemini web-tier research, free upstream image generation, and one-off gateway chat.",
5
5
  "type": "module",
6
6
  "license": "MIT",