@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 +22 -0
- package/extensions/index.ts +3 -1
- package/extensions/lib/imageapi.ts +34 -8
- package/package.json +1 -1
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
|
package/extensions/index.ts
CHANGED
|
@@ -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<{
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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.
|
|
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",
|