@bivy/bivy 0.5.1-staging.74 → 0.5.1-staging.76
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/dist/auth.js +6 -1
- package/dist/runtime/claude-code.js +13 -4
- package/dist/server.js +79 -0
- package/dist/session/event-log.js +45 -1
- package/dist/session/inline-image-fetch.js +243 -0
- package/package.json +1 -1
package/dist/auth.js
CHANGED
|
@@ -164,8 +164,13 @@ export function isAuthorized(ctx) {
|
|
|
164
164
|
* local/private hostnames and reject a public Host (rebinding) or public Origin
|
|
165
165
|
* (cross-site). Escape hatches: BIVY_ALLOWED_HOSTS (comma-separated extra
|
|
166
166
|
* hostnames, e.g. a reverse-proxy domain) and BIVY_ALLOW_ANY_ORIGIN=1.
|
|
167
|
+
*
|
|
168
|
+
* Exported: also reused as the private/local-address check for the inline
|
|
169
|
+
* markdown-image SSRF guard (src/session/inline-image-fetch.ts) — same
|
|
170
|
+
* "must not be a private/loopback/link-local address" question, just asked
|
|
171
|
+
* about an *outbound* fetch target instead of an *inbound* request's Host.
|
|
167
172
|
*/
|
|
168
|
-
function hostnameIsLocal(hostname) {
|
|
173
|
+
export function hostnameIsLocal(hostname) {
|
|
169
174
|
const h = hostname.toLowerCase().replace(/^\[/, "").replace(/\]$/, "").replace(/^::ffff:/, "");
|
|
170
175
|
if (h === "localhost" || h === "127.0.0.1" || h === "::1" || h === "0.0.0.0")
|
|
171
176
|
return true;
|
|
@@ -66,14 +66,23 @@ const FALLBACK_MODELS = [
|
|
|
66
66
|
* it can't (it looks for a tool, finds none). BIVY_SESSION_ID is injected into the
|
|
67
67
|
* subprocess env (see spawnQuery), so the bare command resolves the session. Keep
|
|
68
68
|
* this short: it rides on every turn's system prompt.
|
|
69
|
+
*
|
|
70
|
+
* The chat still has no route to a LOCAL/workspace file path, so that half of the
|
|
71
|
+
* guidance (use `bivy attach`, not markdown, for those) stands. A REMOTE
|
|
72
|
+
* `https://` image URL is different: the node now fetches it server-side and
|
|
73
|
+
* serves it back to the chat (see src/session/inline-image-fetch.ts, issue #293),
|
|
74
|
+
* so plain markdown is the right tool there — `bivy attach` only works on files
|
|
75
|
+
* already inside the workspace, which a URL by definition isn't.
|
|
69
76
|
*/
|
|
70
77
|
export const BIVY_ATTACH_SYSTEM_PROMPT = "Sending files and images to the user: the person you're talking to is in a chat UI. They cannot see files you only " +
|
|
71
|
-
"write to disk, and the chat
|
|
72
|
-
"To show them a file or image — a report, screenshot, chart, or a file they asked for — run " +
|
|
78
|
+
"write to disk, and the chat has no route to a workspace file path. " +
|
|
79
|
+
"To show them a LOCAL file or image — a report, screenshot, chart, or a file they asked for — run " +
|
|
73
80
|
'`bivy attach <path> [--caption "short note"]` in your shell. ' +
|
|
74
81
|
"An image renders inline in the chat; any other file shows as a downloadable chip. The path must be inside the session " +
|
|
75
|
-
"workspace. Do NOT use markdown image syntax like 
|
|
76
|
-
"use `bivy attach
|
|
82
|
+
"workspace. Do NOT use markdown image syntax like  for a local file or workspace path — it will not render; " +
|
|
83
|
+
"always use `bivy attach` for those. A REMOTE image you already have a URL for is different: plain markdown " +
|
|
84
|
+
"`` renders it inline, no attach needed. Prefer `bivy attach` / markdown links over pasting large " +
|
|
85
|
+
"file contents or describing where a file lives on disk.";
|
|
77
86
|
/** Name of the in-process MCP server the native attach tool is registered
|
|
78
87
|
* under (see buildAttachMcpServer) — the SDK namespaces the tool the agent
|
|
79
88
|
* sees as `mcp__<server>__<tool>`. */
|
package/dist/server.js
CHANGED
|
@@ -74,6 +74,7 @@ import { buildNativeImportSeedPrompt } from "./session/native-import.js";
|
|
|
74
74
|
import { EventLog } from "./session/event-log.js";
|
|
75
75
|
import { AttachmentStore, isValidAttachmentHash } from "./session/attachment-store.js";
|
|
76
76
|
import { planAttachment, isAttachPlanError, MAX_AGENT_ATTACHMENT_BYTES } from "./session/attach-to-chat.js";
|
|
77
|
+
import { extractInlineImageUrls, assistantTextForImageScan, fetchInlineImage, isFetchImageError, inlineImageDisplayName, } from "./session/inline-image-fetch.js";
|
|
77
78
|
import { ReplicationService } from "./session/replication-service.js";
|
|
78
79
|
import { createSessionNewDedupe } from "./session/session-new-dedupe.js";
|
|
79
80
|
import { evaluateForkPrereqs, blockingForkPrereqs, missingForkPrereqs } from "./session/fork-prereqs.js";
|
|
@@ -2322,6 +2323,70 @@ function persistTranscriptSnapshot(record) {
|
|
|
2322
2323
|
return;
|
|
2323
2324
|
eventLog.appendBaseSnapshot(record.id, base);
|
|
2324
2325
|
}
|
|
2326
|
+
// In-flight dedupe so two sessions (or two turns) referencing the same remote
|
|
2327
|
+
// image URL only ever trigger one outbound fetch. Process-lifetime only — a
|
|
2328
|
+
// restart just means the first re-encounter fetches again, which is fine.
|
|
2329
|
+
const inlineImageFetchInFlight = new Map();
|
|
2330
|
+
// A URL that failed (bad host, timeout, not-an-image, …) is not retried for a
|
|
2331
|
+
// cooldown window, so a persistently broken URL in a long-lived session can't
|
|
2332
|
+
// turn every subsequent message_end into a wasted fetch attempt.
|
|
2333
|
+
const inlineImageFailedAt = new Map();
|
|
2334
|
+
const INLINE_IMAGE_RETRY_COOLDOWN_MS = 10 * 60 * 1000;
|
|
2335
|
+
/**
|
|
2336
|
+
* Scan a session's just-finalized assistant messages for remote markdown images
|
|
2337
|
+
* (``) and, for any URL not already resolved or in flight,
|
|
2338
|
+
* fetch it (SSRF-guarded, size-capped — see inline-image-fetch.ts), store the
|
|
2339
|
+
* bytes in the content-addressed AttachmentStore, persist the durable url→ref
|
|
2340
|
+
* mapping, and broadcast it live so an already-open chat hydrates the image
|
|
2341
|
+
* without waiting for a reload. Fire-and-forget: called from the session event
|
|
2342
|
+
* listener, which must not block on a network fetch.
|
|
2343
|
+
*/
|
|
2344
|
+
function resolveInlineImages(record) {
|
|
2345
|
+
const messages = record.session.getMessages();
|
|
2346
|
+
const urls = new Set();
|
|
2347
|
+
for (const m of messages) {
|
|
2348
|
+
if (m.role !== "assistant")
|
|
2349
|
+
continue;
|
|
2350
|
+
for (const url of extractInlineImageUrls(assistantTextForImageScan(m.content)))
|
|
2351
|
+
urls.add(url);
|
|
2352
|
+
}
|
|
2353
|
+
if (!urls.size)
|
|
2354
|
+
return;
|
|
2355
|
+
const alreadyResolved = new Set(eventLog.readInlineImages(record.id).map(([url]) => url));
|
|
2356
|
+
for (const url of urls) {
|
|
2357
|
+
if (alreadyResolved.has(url) || inlineImageFetchInFlight.has(url))
|
|
2358
|
+
continue;
|
|
2359
|
+
const failedAt = inlineImageFailedAt.get(url);
|
|
2360
|
+
if (failedAt !== undefined && Date.now() - failedAt < INLINE_IMAGE_RETRY_COOLDOWN_MS)
|
|
2361
|
+
continue;
|
|
2362
|
+
const task = (async () => {
|
|
2363
|
+
try {
|
|
2364
|
+
const result = await fetchInlineImage(url);
|
|
2365
|
+
if (isFetchImageError(result)) {
|
|
2366
|
+
console.warn(`[inline-image] ${url}: ${result.error}`);
|
|
2367
|
+
inlineImageFailedAt.set(url, Date.now());
|
|
2368
|
+
return;
|
|
2369
|
+
}
|
|
2370
|
+
const ref = attachmentStore.put(result.bytes, {
|
|
2371
|
+
name: inlineImageDisplayName(url, result.mimeType),
|
|
2372
|
+
mimeType: result.mimeType,
|
|
2373
|
+
kind: "image",
|
|
2374
|
+
});
|
|
2375
|
+
eventLog.appendInlineImage(record.id, { url, ref });
|
|
2376
|
+
eventLog.flush(record.id);
|
|
2377
|
+
broadcast({ type: "session.event", sessionId: record.id, event: { type: "inlineImage", url, ref } });
|
|
2378
|
+
}
|
|
2379
|
+
catch (error) {
|
|
2380
|
+
console.warn(`[inline-image] ${url}:`, error instanceof Error ? error.message : String(error));
|
|
2381
|
+
inlineImageFailedAt.set(url, Date.now());
|
|
2382
|
+
}
|
|
2383
|
+
finally {
|
|
2384
|
+
inlineImageFetchInFlight.delete(url);
|
|
2385
|
+
}
|
|
2386
|
+
})();
|
|
2387
|
+
inlineImageFetchInFlight.set(url, task);
|
|
2388
|
+
}
|
|
2389
|
+
}
|
|
2325
2390
|
// Append the tool-activity entry to the log. The id-merge and last-500 cap the old
|
|
2326
2391
|
// store applied are now applied by `foldTool` on replay.
|
|
2327
2392
|
function upsertToolActivityMessage(sessionId, entry) {
|
|
@@ -2445,6 +2510,11 @@ function buildHistoryEvent(opts) {
|
|
|
2445
2510
|
// Durable attachment references (text→refs), so a client that never sent the
|
|
2446
2511
|
// attachment (a reload, or a different device) rehydrates thumbnails by hash.
|
|
2447
2512
|
attachmentRefs: opts.sessionId ? eventLog.readAttachments(opts.sessionId) : [],
|
|
2513
|
+
// Durable url→ref map for remote markdown images the node has already
|
|
2514
|
+
// fetched (see resolveInlineImages) — lets a reload resolve a
|
|
2515
|
+
// `data-remote-src` placeholder straight to its attachment hash instead of
|
|
2516
|
+
// waiting on a fresh (redundant) fetch.
|
|
2517
|
+
inlineImageRefs: opts.sessionId ? eventLog.readInlineImages(opts.sessionId) : [],
|
|
2448
2518
|
};
|
|
2449
2519
|
}
|
|
2450
2520
|
// Idempotency for `session.new` keyed by requestId: a client's post-reconnect
|
|
@@ -6786,6 +6856,15 @@ function attachSessionListeners(record) {
|
|
|
6786
6856
|
if (event.type === "turn_start" || event.type === "message_end" || event.type === "turn_end") {
|
|
6787
6857
|
persistTranscriptSnapshot(record);
|
|
6788
6858
|
}
|
|
6859
|
+
// A finalized assistant message may reference a remote image via markdown
|
|
6860
|
+
// (``) — fetch and store it now so the chat can render it
|
|
6861
|
+
// (see resolveInlineImages). Checked on both events: message_end is the
|
|
6862
|
+
// precise "this assistant message is done" signal most runtimes emit, but
|
|
6863
|
+
// turn_end is a safety net for one that only surfaces the final text there.
|
|
6864
|
+
// Fire-and-forget and internally deduped, so checking on both costs nothing.
|
|
6865
|
+
if (event.type === "message_end" || event.type === "turn_end") {
|
|
6866
|
+
resolveInlineImages(record);
|
|
6867
|
+
}
|
|
6789
6868
|
// Durably persist the throttled sidecars at the turn boundary so a crash
|
|
6790
6869
|
// loses at most the in-flight turn's UI detail, not the whole turn.
|
|
6791
6870
|
if (event.type === "turn_end")
|
|
@@ -61,8 +61,18 @@ function isOutboundAttachment(value) {
|
|
|
61
61
|
typeof record.ref === "object" &&
|
|
62
62
|
typeof record.ref.hash === "string");
|
|
63
63
|
}
|
|
64
|
+
function isInlineImage(value) {
|
|
65
|
+
if (!value || typeof value !== "object")
|
|
66
|
+
return false;
|
|
67
|
+
const record = value;
|
|
68
|
+
return (record.bivyKind === "inline-image" &&
|
|
69
|
+
typeof record.url === "string" &&
|
|
70
|
+
!!record.ref &&
|
|
71
|
+
typeof record.ref === "object" &&
|
|
72
|
+
typeof record.ref.hash === "string");
|
|
73
|
+
}
|
|
64
74
|
function isRecord(value) {
|
|
65
|
-
return isOverlay(value) || isBase(value) || isAttachment(value) || isOutboundAttachment(value);
|
|
75
|
+
return isOverlay(value) || isBase(value) || isAttachment(value) || isOutboundAttachment(value) || isInlineImage(value);
|
|
66
76
|
}
|
|
67
77
|
/**
|
|
68
78
|
* Fold attachment records into a text→refs list: last write wins per text (a
|
|
@@ -85,6 +95,24 @@ export function replayAttachments(entries) {
|
|
|
85
95
|
}
|
|
86
96
|
return [...byText.entries()];
|
|
87
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* Fold inline-image records into a url→ref list: last write wins per URL
|
|
100
|
+
* (a re-resolved URL — e.g. after a retry — re-keys onto the newest ref),
|
|
101
|
+
* preserving first-seen order. Mirrors replayAttachments' shape exactly, one
|
|
102
|
+
* level simpler (a single ref instead of an array) since one URL is one image.
|
|
103
|
+
*/
|
|
104
|
+
export function replayInlineImages(entries) {
|
|
105
|
+
const byUrl = new Map();
|
|
106
|
+
for (const entry of entries) {
|
|
107
|
+
if (entry.bivyKind !== "inline-image")
|
|
108
|
+
continue;
|
|
109
|
+
if (!entry.url)
|
|
110
|
+
continue;
|
|
111
|
+
byUrl.delete(entry.url);
|
|
112
|
+
byUrl.set(entry.url, entry.ref);
|
|
113
|
+
}
|
|
114
|
+
return [...byUrl.entries()];
|
|
115
|
+
}
|
|
88
116
|
/**
|
|
89
117
|
* Fold the intermediate-reasoning entries exactly as the legacy incremental
|
|
90
118
|
* upsert did (`upsertIntermediateMessage` in server.ts): last write wins per id;
|
|
@@ -343,6 +371,22 @@ export class EventLog {
|
|
|
343
371
|
};
|
|
344
372
|
this.enqueue(id, `oa:${entry.id}`, record);
|
|
345
373
|
}
|
|
374
|
+
/**
|
|
375
|
+
* Record the durable ref for a fetched inline (remote markdown) image,
|
|
376
|
+
* keyed by its source URL. Coalesces on the URL so a re-resolve (retry after
|
|
377
|
+
* a transient failure) updates in place rather than appending a duplicate line.
|
|
378
|
+
*/
|
|
379
|
+
appendInlineImage(id, entry) {
|
|
380
|
+
if (!entry.url)
|
|
381
|
+
return;
|
|
382
|
+
this.load(id);
|
|
383
|
+
const record = { bivyKind: "inline-image", createdAt: Date.now(), url: entry.url, ref: { ...entry.ref } };
|
|
384
|
+
this.enqueue(id, `ii:${entry.url}`, record);
|
|
385
|
+
}
|
|
386
|
+
/** Replay the inline-image records (disk + pending) into a url→ref list. */
|
|
387
|
+
readInlineImages(id) {
|
|
388
|
+
return replayInlineImages(this.entries(id));
|
|
389
|
+
}
|
|
346
390
|
/** Replay the overlay entries (disk + pending) into the flat `extras` list. */
|
|
347
391
|
read(id) {
|
|
348
392
|
return replayExtras(this.entries(id));
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
//
|
|
4
|
+
// Fetch + validate a remote image an agent referenced with markdown image syntax
|
|
5
|
+
// (``), so the node — not the viewer's browser — makes the
|
|
6
|
+
// request. See docs/issue #293: the deployed web app's CSP (`img-src 'self'
|
|
7
|
+
// data: blob:`) blocks a literal `<img src="https://…">` outright, so without
|
|
8
|
+
// this the syntax rendered nothing. Fetching server-side also closes the
|
|
9
|
+
// SSRF/privacy hole a client-side fetch would otherwise open (an agent could
|
|
10
|
+
// otherwise get the *viewer's* browser/IP to hit an arbitrary URL by embedding
|
|
11
|
+
// it in a reply).
|
|
12
|
+
//
|
|
13
|
+
// This is the PURE-ish, testable half — URL extraction, host/SSRF validation,
|
|
14
|
+
// and the guarded fetch itself — all dependency-injectable so tests never hit
|
|
15
|
+
// the real network or DNS. The server half (src/server.ts) owns the AttachmentStore
|
|
16
|
+
// write, the durable event-log ref, and the live broadcast; see resolveInlineImages.
|
|
17
|
+
import dns from "node:dns/promises";
|
|
18
|
+
import { hostnameIsLocal } from "../auth.js";
|
|
19
|
+
import { sanitizeAttachmentName, sniffMime } from "./attach-to-chat.js";
|
|
20
|
+
/**
|
|
21
|
+
* The image-markdown pattern, `` — MUST match the image regex
|
|
22
|
+
* in `inline()` in packages/core/src/markdown.ts exactly. Not a shared import: the
|
|
23
|
+
* node (src/) intentionally does not depend on @bivy/core (a browser/client
|
|
24
|
+
* package — see packages/core's own description). Kept in lock-step by comment
|
|
25
|
+
* instead, the same convention EPHEMERAL_ALLOWED_HOSTS uses in
|
|
26
|
+
* src/ephemeral-exec.ts for its cross-copy host allowlist. If you change one,
|
|
27
|
+
* change the other.
|
|
28
|
+
*/
|
|
29
|
+
const INLINE_IMAGE_MD_RE = /!\[[^\]]*\]\((https:\/\/[^)\s]+)\)/g;
|
|
30
|
+
/** Bound how many distinct remote images a single message can trigger a fetch
|
|
31
|
+
* for — a pathological/malicious message can't fan out into an unbounded
|
|
32
|
+
* number of outbound requests. */
|
|
33
|
+
export const MAX_INLINE_IMAGES_PER_MESSAGE = 6;
|
|
34
|
+
/** Ceiling for a single fetched inline image. Smaller than
|
|
35
|
+
* MAX_AGENT_ATTACHMENT_BYTES (attach-to-chat.ts) because these bytes come from
|
|
36
|
+
* an arbitrary, untrusted remote origin rather than the local workspace —
|
|
37
|
+
* still comfortably under the relay's 32 MiB reassembly limit (see
|
|
38
|
+
* packages/core/src/wire-format.ts). */
|
|
39
|
+
export const MAX_INLINE_IMAGE_BYTES = 8 * 1024 * 1024;
|
|
40
|
+
const FETCH_TIMEOUT_MS = 10_000;
|
|
41
|
+
/** Hard cap on redirect hops, mirroring execEphemeralRequest's guard. */
|
|
42
|
+
const MAX_REDIRECTS = 5;
|
|
43
|
+
/** Extract the distinct `https://` URLs a message's raw markdown references via
|
|
44
|
+
* ``, in first-seen order, capped at MAX_INLINE_IMAGES_PER_MESSAGE. */
|
|
45
|
+
export function extractInlineImageUrls(text) {
|
|
46
|
+
if (!text)
|
|
47
|
+
return [];
|
|
48
|
+
const out = [];
|
|
49
|
+
const seen = new Set();
|
|
50
|
+
for (const match of text.matchAll(INLINE_IMAGE_MD_RE)) {
|
|
51
|
+
const url = match[1];
|
|
52
|
+
if (!url || seen.has(url))
|
|
53
|
+
continue;
|
|
54
|
+
seen.add(url);
|
|
55
|
+
out.push(url);
|
|
56
|
+
if (out.length >= MAX_INLINE_IMAGES_PER_MESSAGE)
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
/** Best-effort plain text for an assistant RuntimeMessage's `content`, which is
|
|
62
|
+
* either a plain string or an array of typed blocks (`{type:"text", text}`
|
|
63
|
+
* among others, e.g. tool_use/thinking). Only the text parts matter for
|
|
64
|
+
* finding markdown image references. */
|
|
65
|
+
export function assistantTextForImageScan(content) {
|
|
66
|
+
if (typeof content === "string")
|
|
67
|
+
return content;
|
|
68
|
+
if (!Array.isArray(content))
|
|
69
|
+
return "";
|
|
70
|
+
return content
|
|
71
|
+
.filter((part) => !!part && typeof part === "object" && String(part.type || "").toLowerCase() === "text")
|
|
72
|
+
.map((part) => String(part.text ?? ""))
|
|
73
|
+
.join("\n");
|
|
74
|
+
}
|
|
75
|
+
/** Env-configurable extra allowlist, comma-separated hostnames — same shape/
|
|
76
|
+
* naming convention as BIVY_ALLOWED_HOSTS (src/auth.ts). When set and
|
|
77
|
+
* non-empty, ONLY these hosts may be fetched (a stricter opt-in for locked-down
|
|
78
|
+
* deployments); unset means "any public host is fine" (the private/local-address
|
|
79
|
+
* block below still applies either way). */
|
|
80
|
+
function explicitAllowlist() {
|
|
81
|
+
return new Set((process.env.BIVY_INLINE_IMAGE_ALLOWED_HOSTS ?? "")
|
|
82
|
+
.split(",")
|
|
83
|
+
.map((h) => h.trim().toLowerCase())
|
|
84
|
+
.filter(Boolean));
|
|
85
|
+
}
|
|
86
|
+
export function isFetchImageError(value) {
|
|
87
|
+
return typeof value.error === "string";
|
|
88
|
+
}
|
|
89
|
+
async function defaultResolveHost(hostname) {
|
|
90
|
+
const results = await dns.lookup(hostname, { all: true });
|
|
91
|
+
return results.map((r) => r.address);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Reject anything but a public https host before it's ever requested: the
|
|
95
|
+
* literal hostname (an IP-literal `https://169.254.169.254/…` cloud-metadata
|
|
96
|
+
* URL, or an internal domain), AND every address it resolves to (a public-
|
|
97
|
+
* looking hostname an attacker points at an internal IP — classic DNS-rebinding
|
|
98
|
+
* SSRF). This runs again on every redirect hop in fetchInlineImage, exactly like
|
|
99
|
+
* execEphemeralRequest's per-hop host re-check.
|
|
100
|
+
*
|
|
101
|
+
* Known limitation: `fetch()` below does its own DNS resolution, which could in
|
|
102
|
+
* principle differ from what we just checked (a narrow TOCTOU window) — pinning
|
|
103
|
+
* the connection to the resolved address would need a custom dispatcher/Agent,
|
|
104
|
+
* which isn't worth the complexity here; this closes the overwhelming majority
|
|
105
|
+
* of real SSRF attempts (metadata endpoints, LAN scanning, loopback) the same
|
|
106
|
+
* way the rest of this codebase's SSRF guards do.
|
|
107
|
+
*/
|
|
108
|
+
async function assertHostAllowed(url, resolveHost) {
|
|
109
|
+
if (url.protocol !== "https:")
|
|
110
|
+
throw new Error(`Refusing to fetch a non-https image URL (${url.protocol})`);
|
|
111
|
+
const hostname = url.hostname;
|
|
112
|
+
if (hostnameIsLocal(hostname))
|
|
113
|
+
throw new Error(`Refusing to fetch an image from a local/private host: ${hostname}`);
|
|
114
|
+
const allowlist = explicitAllowlist();
|
|
115
|
+
if (allowlist.size > 0 && !allowlist.has(hostname.toLowerCase())) {
|
|
116
|
+
throw new Error(`Host not in BIVY_INLINE_IMAGE_ALLOWED_HOSTS: ${hostname}`);
|
|
117
|
+
}
|
|
118
|
+
let addresses;
|
|
119
|
+
try {
|
|
120
|
+
addresses = await resolveHost(hostname);
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
throw new Error(`Could not resolve host ${hostname}: ${error instanceof Error ? error.message : String(error)}`);
|
|
124
|
+
}
|
|
125
|
+
if (!addresses.length)
|
|
126
|
+
throw new Error(`Host ${hostname} did not resolve to any address`);
|
|
127
|
+
for (const address of addresses) {
|
|
128
|
+
if (hostnameIsLocal(address))
|
|
129
|
+
throw new Error(`Refusing to fetch an image — ${hostname} resolves to a private/local address`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Fetch a single remote image, guarded against SSRF (private/local hosts,
|
|
134
|
+
* DNS-rebinding, unvalidated redirects), unbounded size, and non-image
|
|
135
|
+
* responses. Returns the bytes + a validated mime type, or a human-readable
|
|
136
|
+
* error — never throws (a bad/malicious URL must not crash the caller).
|
|
137
|
+
*/
|
|
138
|
+
export async function fetchInlineImage(rawUrl, opts = {}) {
|
|
139
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
140
|
+
const resolveHost = opts.resolveHost ?? defaultResolveHost;
|
|
141
|
+
const maxBytes = opts.maxBytes ?? MAX_INLINE_IMAGE_BYTES;
|
|
142
|
+
const timeoutMs = opts.timeoutMs ?? FETCH_TIMEOUT_MS;
|
|
143
|
+
let url;
|
|
144
|
+
try {
|
|
145
|
+
url = new URL(rawUrl);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return { error: `Invalid image URL: ${rawUrl}` };
|
|
149
|
+
}
|
|
150
|
+
const controller = new AbortController();
|
|
151
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
152
|
+
try {
|
|
153
|
+
for (let hop = 0;; hop++) {
|
|
154
|
+
try {
|
|
155
|
+
await assertHostAllowed(url, resolveHost);
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
159
|
+
}
|
|
160
|
+
let res;
|
|
161
|
+
try {
|
|
162
|
+
res = await fetchImpl(url.toString(), { signal: controller.signal, redirect: "manual", headers: { accept: "image/*" } });
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
return { error: `Fetching image failed: ${error instanceof Error ? error.message : String(error)}` };
|
|
166
|
+
}
|
|
167
|
+
if (res.status >= 300 && res.status < 400 && res.status !== 304) {
|
|
168
|
+
if (hop >= MAX_REDIRECTS)
|
|
169
|
+
return { error: `Too many redirects fetching image: ${rawUrl}` };
|
|
170
|
+
const location = res.headers.get("location");
|
|
171
|
+
if (!location)
|
|
172
|
+
return { error: `Redirect (${res.status}) had no Location header` };
|
|
173
|
+
try {
|
|
174
|
+
url = new URL(location, url);
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return { error: `Redirect target was not a valid URL: ${location}` };
|
|
178
|
+
}
|
|
179
|
+
continue; // next hop re-validates the new host before requesting it
|
|
180
|
+
}
|
|
181
|
+
if (!res.ok)
|
|
182
|
+
return { error: `Image fetch failed: HTTP ${res.status}` };
|
|
183
|
+
const contentLength = Number(res.headers.get("content-length") || "0");
|
|
184
|
+
if (contentLength > 0 && contentLength > maxBytes) {
|
|
185
|
+
return { error: `Image too large (${contentLength} bytes; limit ${maxBytes})` };
|
|
186
|
+
}
|
|
187
|
+
if (!res.body)
|
|
188
|
+
return { error: "Image response had no body" };
|
|
189
|
+
const contentType = (res.headers.get("content-type") || "").split(";")[0].trim().toLowerCase();
|
|
190
|
+
const reader = res.body.getReader();
|
|
191
|
+
const chunks = [];
|
|
192
|
+
let total = 0;
|
|
193
|
+
for (;;) {
|
|
194
|
+
const { done, value } = await reader.read();
|
|
195
|
+
if (done)
|
|
196
|
+
break;
|
|
197
|
+
if (!value)
|
|
198
|
+
continue;
|
|
199
|
+
total += value.byteLength;
|
|
200
|
+
if (total > maxBytes) {
|
|
201
|
+
try {
|
|
202
|
+
await reader.cancel();
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
// best-effort — we're already erroring out
|
|
206
|
+
}
|
|
207
|
+
return { error: `Image exceeded the ${maxBytes}-byte limit` };
|
|
208
|
+
}
|
|
209
|
+
chunks.push(value);
|
|
210
|
+
}
|
|
211
|
+
const bytes = Buffer.concat(chunks.map((c) => Buffer.from(c)));
|
|
212
|
+
if (!bytes.length)
|
|
213
|
+
return { error: "Image response was empty" };
|
|
214
|
+
// Trust magic bytes over the (spoofable) Content-Type header when they
|
|
215
|
+
// disagree; fall back to the header only when sniffing is inconclusive
|
|
216
|
+
// (e.g. an SVG, which sniffMime doesn't recognize).
|
|
217
|
+
const sniffed = sniffMime(bytes);
|
|
218
|
+
const mimeType = sniffed || (contentType.startsWith("image/") ? contentType : "");
|
|
219
|
+
if (!mimeType)
|
|
220
|
+
return { error: "Response does not look like an image" };
|
|
221
|
+
return { bytes, mimeType };
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
finally {
|
|
225
|
+
clearTimeout(timeout);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
/** A display name for a fetched inline image's AttachmentStore entry: the URL's
|
|
229
|
+
* last path segment when it looks like a filename, else a generic name derived
|
|
230
|
+
* from the resolved mime type. */
|
|
231
|
+
export function inlineImageDisplayName(url, mimeType) {
|
|
232
|
+
try {
|
|
233
|
+
const pathname = new URL(url).pathname;
|
|
234
|
+
const base = pathname.split("/").filter(Boolean).pop();
|
|
235
|
+
if (base)
|
|
236
|
+
return sanitizeAttachmentName(decodeURIComponent(base));
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
// fall through to the generic name below
|
|
240
|
+
}
|
|
241
|
+
const ext = mimeType.split("/")[1]?.split("+")[0] || "png";
|
|
242
|
+
return `inline-image.${ext}`;
|
|
243
|
+
}
|
package/package.json
CHANGED