@bacnh85/pi-web 0.10.1 → 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,27 @@
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
+
13
+ ## 0.10.2 (2026-09-13)
14
+
15
+ ### Fixed
16
+
17
+ - `.gif` downloads inline as `image/gif` (was mislabeled `image/png`).
18
+ - **SSRF guard on gateway-supplied image URLs**: downloads to
19
+ loopback/private/link-local hosts (e.g. cloud metadata 169.254.169.254)
20
+ are refused before any request is made — the URL is surfaced instead.
21
+ - **25 MB download cap**: oversized image downloads are not written to disk;
22
+ the URL is surfaced instead.
23
+ - pi-hub catalog/README description updated to match 0.10.x features.
24
+
3
25
  ## 0.10.1 (2026-09-13)
4
26
 
5
27
  ### Added
@@ -87,13 +87,15 @@ const engineSchema = {
87
87
 
88
88
  // Saved image file → inline image block (the 0.6.2 vision-loop lesson: the
89
89
  // generating model should see its own output).
90
- async function toImageBlock(file: string): Promise<{ type: "image"; data: string; mimeType: string }> {
90
+ export async function toImageBlock(file: string): Promise<{ type: "image"; data: string; mimeType: string }> {
91
91
  const data = (await fs.promises.readFile(file)).toString("base64");
92
92
  const lower = file.toLowerCase();
93
93
  const mimeType = lower.endsWith(".jpg") || lower.endsWith(".jpeg")
94
94
  ? "image/jpeg"
95
95
  : lower.endsWith(".webp")
96
96
  ? "image/webp"
97
+ : lower.endsWith(".gif")
98
+ ? "image/gif"
97
99
  : "image/png";
98
100
  return { type: "image" as const, data, mimeType };
99
101
  }
@@ -6,6 +6,7 @@ import fs from "node:fs";
6
6
  import path from "node:path";
7
7
  import { randomUUID } from "node:crypto";
8
8
  import { findEnvValue } from "./config";
9
+ import { isLocalUrl } from "./chrome";
9
10
  import {
10
11
  describeGeminiError,
11
12
  geminiGenerateImage,
@@ -158,8 +159,15 @@ export function imageRateSnapshot(): Record<string, { count: number; day: string
158
159
  export interface FetchLike {
159
160
  (
160
161
  url: string,
161
- init?: { method?: string; headers?: Record<string, string>; body?: string; signal?: AbortSignal },
162
- ): 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
+ }>;
163
171
  }
164
172
 
165
173
  export interface ApiImageResult {
@@ -169,6 +177,9 @@ export interface ApiImageResult {
169
177
  model?: string;
170
178
  }
171
179
 
180
+ /** Hard cap on downloaded image size — gateways can point at arbitrary URLs. */
181
+ export const MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024;
182
+
172
183
  export class ImageApiError extends Error {
173
184
  constructor(
174
185
  public status: number,
@@ -231,6 +242,7 @@ export async function apiGenerateImage(opts: {
231
242
  } else if (typeof item?.url === "string" && item.url) {
232
243
  // A failed download must not waste the generation: surface the URL.
233
244
  try {
245
+ if (isLocalUrl(item.url)) throw new Error("image host is private/loopback (SSRF-guarded)");
234
246
  paths.push(await downloadImage(fetchImpl, item.url, opts.outDir, i, opts.signal, opts.timeoutMs));
235
247
  } catch (err) {
236
248
  urls.push(item.url);
@@ -258,16 +270,30 @@ async function downloadImage(
258
270
  signal?: AbortSignal,
259
271
  timeoutMs?: number,
260
272
  ): Promise<string> {
261
- const res = await raceGuard(fetchImpl(url, { method: "GET", signal }), {
262
- signal,
263
- timeoutMs: timeoutMs ?? 120_000,
264
- label: "web_image download",
265
- });
266
- 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})`);
267
291
  const buf = Buffer.from(await res.arrayBuffer());
292
+ if (buf.length > MAX_DOWNLOAD_BYTES) throw new Error(`image exceeds the ${MAX_DOWNLOAD_BYTES}-byte download cap`);
268
293
  const file = path.join(outDir, `pi-web-image-${randomUUID().slice(0, 8)}-${i}${extFor(url)}`);
269
294
  fs.writeFileSync(file, buf);
270
295
  return file;
296
+ }
271
297
  }
272
298
 
273
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.1",
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",