@bacnh85/pi-web 0.10.2 → 0.10.4

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,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.10.4 (2026-09-13)
4
+
5
+ ### Fixed
6
+
7
+ - **Truthful image extensions**: saved files are typed by magic bytes, not
8
+ URL/file extension — Z.ai GLM-Image serves JPEG behind a `.png` URL, which
9
+ previously produced mislabeled files and inline blocks.
10
+
11
+ ## 0.10.3 (2026-09-13)
12
+
13
+ ### Fixed
14
+
15
+ - **Redirect-aware SSRF guard**: image downloads now use `redirect: "manual"`
16
+ and re-validate every hop with the private/loopback check — a gateway URL
17
+ that 302s to an internal host (e.g. cloud metadata) can no longer bypass
18
+ the guard via fetch's default redirect following. Public redirects still
19
+ work (relative locations resolved per hop, max 3 hops).
20
+
3
21
  ## 0.10.2 (2026-09-13)
4
22
 
5
23
  ### 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 {
@@ -231,7 +238,7 @@ export async function apiGenerateImage(opts: {
231
238
  for (let i = 0; i < items.length; i++) {
232
239
  const item = items[i];
233
240
  if (typeof item?.b64_json === "string" && item.b64_json) {
234
- paths.push(writeB64(opts.outDir, item.b64_json, i));
241
+ paths.push(writeB64(opts.outDir, Buffer.from(item.b64_json, "base64"), i));
235
242
  } else if (typeof item?.url === "string" && item.url) {
236
243
  // A failed download must not waste the generation: surface the URL.
237
244
  try {
@@ -249,9 +256,19 @@ export async function apiGenerateImage(opts: {
249
256
  return { paths, urls, model: typeof payload?.model === "string" ? payload.model : opts.model };
250
257
  }
251
258
 
252
- function writeB64(outDir: string, b64: string, i: number): string {
253
- const file = path.join(outDir, `pi-web-image-${randomUUID().slice(0, 8)}-${i}.png`);
254
- fs.writeFileSync(file, Buffer.from(b64, "base64"));
259
+ // Some gateways serve JPEG/WebP bytes behind a .png URL (Z.ai GLM-Image does)
260
+ // trust the magic bytes, not the URL/file extension.
261
+ function extFromBytes(buf: Buffer, fallback: string): string {
262
+ if (buf.length >= 4 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) return ".png";
263
+ if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return ".jpg";
264
+ if (buf.length >= 6 && buf.toString("ascii", 0, 3) === "GIF") return ".gif";
265
+ if (buf.length >= 12 && buf.toString("ascii", 0, 4) === "RIFF" && buf.toString("ascii", 8, 12) === "WEBP") return ".webp";
266
+ return fallback;
267
+ }
268
+
269
+ function writeB64(outDir: string, buf: Buffer, i: number): string {
270
+ const file = path.join(outDir, `pi-web-image-${randomUUID().slice(0, 8)}-${i}${extFromBytes(buf, ".png")}`);
271
+ fs.writeFileSync(file, buf);
255
272
  return file;
256
273
  }
257
274
 
@@ -263,17 +280,30 @@ async function downloadImage(
263
280
  signal?: AbortSignal,
264
281
  timeoutMs?: number,
265
282
  ): 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})`);
283
+ // fetch follows redirects by default follow manually and re-validate each
284
+ // hop, or a gateway URL that 302s to an internal host bypasses the guard.
285
+ let current = url;
286
+ for (let hop = 0; ; hop++) {
287
+ if (hop > 3) throw new Error("too many image redirects");
288
+ if (isLocalUrl(current)) throw new Error(`image host is private/loopback (SSRF-guarded): ${current}`);
289
+ const res = await raceGuard(fetchImpl(current, { method: "GET", redirect: "manual", signal }), {
290
+ signal,
291
+ timeoutMs: timeoutMs ?? 120_000,
292
+ label: "web_image download",
293
+ });
294
+ if ([301, 302, 303, 307, 308].includes(res.status)) {
295
+ const loc = res.headers?.get?.("location") ?? null;
296
+ if (!loc) throw new ImageApiError(res.status, `image redirect ${res.status} without a location header`);
297
+ current = new URL(loc, current).toString();
298
+ continue;
299
+ }
300
+ if (!res.ok) throw new ImageApiError(res.status, `image download failed (HTTP ${res.status})`);
272
301
  const buf = Buffer.from(await res.arrayBuffer());
273
302
  if (buf.length > MAX_DOWNLOAD_BYTES) throw new Error(`image exceeds the ${MAX_DOWNLOAD_BYTES}-byte download cap`);
274
- const file = path.join(outDir, `pi-web-image-${randomUUID().slice(0, 8)}-${i}${extFor(url)}`);
303
+ const file = path.join(outDir, `pi-web-image-${randomUUID().slice(0, 8)}-${i}${extFromBytes(buf, extFor(url))}`);
275
304
  fs.writeFileSync(file, buf);
276
305
  return file;
306
+ }
277
307
  }
278
308
 
279
309
  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.4",
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",