From aa63d6b9f4cb14535fd0191ab358c27f0ee62ce8 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Mon, 19 Oct 2026 23:48:50 +0900 Subject: feat(markdown): render local-path images inline via backend image route --- src/features/markdown/logic/markdown.test.ts | 93 +++++++++++++++++- src/features/markdown/logic/markdown.ts | 110 ++++++++++++++++++++-- src/features/markdown/logic/streaming-markdown.ts | 61 ++++++++++-- src/features/markdown/ui/Markdown.svelte | 19 +++- 4 files changed, 263 insertions(+), 20 deletions(-) (limited to 'src/features/markdown') diff --git a/src/features/markdown/logic/markdown.test.ts b/src/features/markdown/logic/markdown.test.ts index 90f1938..61d0d0c 100644 --- a/src/features/markdown/logic/markdown.test.ts +++ b/src/features/markdown/logic/markdown.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from "vitest"; -import { renderMarkdown } from "./markdown"; +import { renderMarkdown, renderMarkdownTail } from "./markdown"; +import { advance, createStreamingMarkdown } from "./streaming-markdown"; + +/** The single rendered `` src, or null. */ +function imageSrc(html: string): string | null { + const doc = new DOMParser().parseFromString(html, "text/html"); + return doc.querySelector("img")?.getAttribute("src") ?? null; +} describe("renderMarkdown", () => { it("renders GFM markdown (headings, emphasis)", () => { @@ -95,4 +102,88 @@ describe("renderMarkdown", () => { it("returns an empty string for empty input", () => { expect(renderMarkdown("")).toBe(""); }); + + describe("local-path images", () => { + const BASE = "http://localhost:24203"; + const image = { apiBase: BASE, cwd: "/w/foo" }; + + it("rewrites a file:// URL to the backend image route", () => { + expect(imageSrc(renderMarkdown("![pic](file:///home/u/pic.png)", { image }))).toBe( + `${BASE}/local-image?path=%2Fhome%2Fu%2Fpic.png`, + ); + }); + + it("rewrites an absolute path; an empty apiBase yields a root-relative src", () => { + expect(imageSrc(renderMarkdown("![pic](/home/u/pic.png)", { image: { apiBase: "" } }))).toBe( + "/local-image?path=%2Fhome%2Fu%2Fpic.png", + ); + expect(imageSrc(renderMarkdown("![pic](/home/u/pic.png)", { image }))).toBe( + `${BASE}/local-image?path=%2Fhome%2Fu%2Fpic.png`, + ); + }); + + it("resolves a relative path against the conversation cwd", () => { + expect(imageSrc(renderMarkdown("![shot](shot.png)", { image }))).toBe( + `${BASE}/local-image?path=%2Fw%2Ffoo%2Fshot.png`, + ); + expect( + imageSrc(renderMarkdown("![shot](shot.png)", { image: { apiBase: BASE, cwd: "/w/foo/" } })), + ).toBe(`${BASE}/local-image?path=%2Fw%2Ffoo%2Fshot.png`); + }); + + it("leaves a relative path untouched when no cwd is available", () => { + expect(imageSrc(renderMarkdown("![shot](shot.png)", { image: { apiBase: BASE } }))).toBe( + "shot.png", + ); + expect(imageSrc(renderMarkdown("![shot](shot.png)", { image }))).toBe( + `${BASE}/local-image?path=%2Fw%2Ffoo%2Fshot.png`, + ); + expect(imageSrc(renderMarkdown("![shot](shot.png)"))).toBe("shot.png"); + }); + + it("leaves remote http(s) and data: images untouched", () => { + expect(imageSrc(renderMarkdown("![remote](https://example.com/a.png)", { image }))).toBe( + "https://example.com/a.png", + ); + expect(imageSrc(renderMarkdown("![remote](http://example.com/a.png)", { image }))).toBe( + "http://example.com/a.png", + ); + const data = "data:image/png;base64,iVBORw0KGgo="; + expect(imageSrc(renderMarkdown(`![inline](${data})`, { image }))).toBe(data); + }); + + it("does not rewrite image syntax inside fenced code", () => { + const html = renderMarkdown("```md\n![pic](/home/u/pic.png)\n```", { image }); + expect(html).toContain("/home/u/pic.png"); + expect(html).not.toContain("/local-image"); + }); + + it("rewrites the streaming tail, the settled prefix, and a seeded tail", () => { + const state = createStreamingMarkdown(); + const tail = advance(state, "![pic](/home/u/pic.png)", { image }); + expect(imageSrc(tail.tailHtml)).toBe(`${BASE}/local-image?path=%2Fhome%2Fu%2Fpic.png`); + + // The settled prefix must render with the same options too. + const settled = advance(state, "![pic](/home/u/pic.png)\n\nnext\n", { image }); + expect(imageSrc(settled.prefixHtml)).toBe(`${BASE}/local-image?path=%2Fhome%2Fu%2Fpic.png`); + + const seeded = renderMarkdownTail( + "![shot](shot.png)", + { fences: 0, bold: 0, code: 0 }, + { + image, + }, + ); + expect(imageSrc(seeded)).toBe(`${BASE}/local-image?path=%2Fw%2Ffoo%2Fshot.png`); + }); + + it("invalidates the streaming cache when the image options change (async cwd)", () => { + const state = createStreamingMarkdown(); + const text = "![shot](shot.png)\n\nnext\n"; + advance(state, text); + expect(imageSrc(state.prefixHtml)).toBe("shot.png"); // no cwd yet + const updated = advance(state, text, { image }); + expect(imageSrc(updated.prefixHtml)).toBe(`${BASE}/local-image?path=%2Fw%2Ffoo%2Fshot.png`); + }); + }); }); diff --git a/src/features/markdown/logic/markdown.ts b/src/features/markdown/logic/markdown.ts index fd6caf9..06e6a78 100644 --- a/src/features/markdown/logic/markdown.ts +++ b/src/features/markdown/logic/markdown.ts @@ -36,6 +36,7 @@ import xml from "highlight.js/lib/languages/xml"; import yaml from "highlight.js/lib/languages/yaml"; import { Marked } from "marked"; import { markedHighlight } from "marked-highlight"; +import { resolveImageUrl } from "../../../core/chunks"; // Hot set: registered eagerly so common code blocks highlight on first paint. const HOT_LANGUAGES: Record = { @@ -205,12 +206,106 @@ function sanitize(html: string): string { return purify.sanitize(html); } +/** + * Per-conversation context for rendering local-path images inline. `apiBase` + * is the backend HTTP base (may be `""` → a root-relative `/local-image?…`); + * `cwd` resolves RELATIVE Markdown image srcs against the conversation's + * working directory (the same machine the backend serves files from). + */ +export interface MarkdownImageOptions { + readonly apiBase: string; + readonly cwd?: string | null; +} + +/** Render options shared by all three render paths. */ +export interface MarkdownRenderOptions { + /** Balance dangling delimiters while the message is still generating. */ + readonly streaming?: boolean; + /** Rewrite local-path image srcs through the backend's `/local-image` route. */ + readonly image?: MarkdownImageOptions; +} + +/** Decode a URL-ish path, tolerating malformed `%` escapes. */ +function safeDecode(path: string): string { + try { + return decodeURIComponent(path); + } catch { + return path; + } +} + +/** + * Rewrite one image `src` for the local-image feature. Pure: `input → output`. + * + * - `file:///path` → local absolute path (scheme stripped). + * - `/absolute/path` → local absolute path. + * - a relative path (no scheme, no leading `/`) → joined to the conversation + * `cwd` (no `cwd` → left untouched). + * - `http(s)://…`, `data:`, and any other scheme → left untouched. + * + * Local paths become `${apiBase}/local-image?path=`, reusing + * `resolveImageUrl`'s join-without-double-slash semantics (an empty `apiBase` + * yields a root-relative path). + */ +function rewriteImageSrc(src: string, image: MarkdownImageOptions): string { + const trimmed = src.trim(); + if (trimmed.length === 0) return src; + // Remote images and inline data URLs already work — never touch them. + if (/^(?:data:|https?:\/\/)/i.test(trimmed)) return src; + + let path: string; + + if (/^file:\/\//i.test(trimmed)) { + // file:///abs/path (and file://localhost/abs/path) → /abs/path. + const match = /^file:\/\/([^/]*)(\/.*)?$/i.exec(trimmed); + if (match === null) return src; + const host = match[1] ?? ""; + const filePath = match[2] ?? "/"; + path = host === "" || host.toLowerCase() === "localhost" ? filePath : `/${host}${filePath}`; + path = safeDecode(path); + } else if (trimmed.startsWith("/")) { + path = safeDecode(trimmed); + } else if (trimmed.startsWith("~/")) { + // The backend expands a leading `~`; pass it through un-joined. + path = trimmed; + } else if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(trimmed)) { + // Some other scheme (mailto:, ftp:, …) — leave it alone. + return src; + } else { + const cwd = image.cwd ?? null; + if (cwd === null || cwd.length === 0) return src; + const base = cwd.replace(/\/+$/, ""); + path = `${base}/${safeDecode(trimmed.replace(/^\.\//, ""))}`; + } + + return resolveImageUrl(`/local-image?path=${encodeURIComponent(path)}`, image.apiBase); +} + +/** + * Parse with marked, optionally rewriting image token `href`s just before + * rendering. Doing it on the token stream (not the Markdown source) means + * fenced/inline code is never touched, and reference-style images resolve + * through marked's normal link resolution. When `image` is absent the parse is + * byte-identical to today's. + */ +function parseMarkdown(src: string, image?: MarkdownImageOptions): string { + if (image === undefined) return md.parse(src) as string; + + const renderer = new md.Renderer(); + const imageRenderer = renderer.image; + renderer.image = (token) => + imageRenderer.call(renderer, { + ...token, + href: rewriteImageSrc(token.href, image), + }); + return md.parse(src, { renderer }) as string; +} + /** Render Markdown to sanitized HTML. Returns `""` if parsing ever throws. */ -export function renderMarkdown(text: string, opts?: { streaming?: boolean }): string { +export function renderMarkdown(text: string, opts?: MarkdownRenderOptions): string { const src = opts?.streaming === true ? closeOpenDelimiters(text) : text; try { - const raw = md.parse(src) as string; - return sanitize(addCopyButtons(raw)); + return sanitize(addCopyButtons(parseMarkdown(src, opts?.image))); } catch { return ""; } @@ -225,11 +320,14 @@ export function renderMarkdown(text: string, opts?: { streaming?: boolean }): st * degenerate case (nothing settled yet) is exactly today's behavior. * Returns `""` if parsing ever throws. */ -export function renderMarkdownTail(text: string, seed: DelimiterCounts = ZERO_COUNTS): string { +export function renderMarkdownTail( + text: string, + seed: DelimiterCounts = ZERO_COUNTS, + opts?: MarkdownRenderOptions, +): string { const src = closeOpenDelimiters(text, seed); try { - const raw = md.parse(src) as string; - return sanitize(addCopyButtons(raw)); + return sanitize(addCopyButtons(parseMarkdown(src, opts?.image))); } catch { return ""; } diff --git a/src/features/markdown/logic/streaming-markdown.ts b/src/features/markdown/logic/streaming-markdown.ts index e273c66..b530cd4 100644 --- a/src/features/markdown/logic/streaming-markdown.ts +++ b/src/features/markdown/logic/streaming-markdown.ts @@ -31,6 +31,8 @@ import { countDelimiters, type DelimiterCounts, + type MarkdownImageOptions, + type MarkdownRenderOptions, renderMarkdown, renderMarkdownTail, renderOpenFence, @@ -90,6 +92,13 @@ export interface StreamingMarkdownState { blankRunStart: number | null; /** INTERNAL — the best settle candidate found by the current scan pass. */ pendingSettle: number; + /** + * INTERNAL — identity of the image-render options (apiBase + cwd) the cache + * was rendered with. The options can arrive asynchronously (the cwd loads + * after mount), so a change invalidates the settled cache: image srcs in the + * prefix would otherwise keep the stale resolution. + */ + imageKey: string; } /** Fresh state for one streaming message. */ @@ -109,9 +118,22 @@ export function createStreamingMarkdown(): StreamingMarkdownState { linkRefSeen: false, blankRunStart: null, pendingSettle: -1, + imageKey: "", }; } +/** Identity of the image options, so an async cwd appearing invalidates the cache. */ +function imageKeyOf(image: MarkdownImageOptions | undefined): string { + return image === undefined ? "" : JSON.stringify([image.apiBase, image.cwd ?? null]); +} + +/** Build per-render options without setting `image: undefined` (exactOptionalPropertyTypes). */ +function renderOptionsOf( + image: MarkdownImageOptions | undefined, +): MarkdownRenderOptions | undefined { + return image === undefined ? undefined : { image }; +} + // ── Scanner helpers (CommonMark block starts, mirroring what marked does) ── /** A fenced-code-block opening line: ≤3 spaces indent, ≥3 backticks/tildes + info string. */ @@ -382,9 +404,13 @@ function scan(s: StreamingMarkdownState): void { } /** Render the newly-settled span once and fold it into the prefix cache. */ -function commitSettle(s: StreamingMarkdownState, end: number): void { +function commitSettle( + s: StreamingMarkdownState, + end: number, + image: MarkdownImageOptions | undefined, +): void { const chunk = s.source.slice(s.settledOffset, end); - s.prefixHtml += renderMarkdown(chunk); + s.prefixHtml += renderMarkdown(chunk, renderOptionsOf(image)); s.seed = addCounts(s.seed, countDelimiters(chunk)); s.settledOffset = end; } @@ -395,13 +421,24 @@ function commitSettle(s: StreamingMarkdownState, end: number): void { * string identity is what lets Svelte skip settled DOM). Resets itself when * the text shrinks or is otherwise not an append-extension. */ -export function advance(state: StreamingMarkdownState, textSoFar: string): StreamingMarkdownResult { - if (textSoFar === state.source && state.last !== null) return state.last; - if (!textSoFar.startsWith(state.source)) { +export function advance( + state: StreamingMarkdownState, + textSoFar: string, + opts?: MarkdownRenderOptions, +): StreamingMarkdownResult { + const image = opts?.image; + const imageKey = imageKeyOf(image); + if (textSoFar === state.source && state.last !== null && state.imageKey === imageKey) { + return state.last; + } + // A changed render option (e.g. the cwd loading after mount) invalidates the + // settled cache — its image srcs were resolved with the old options. + if (state.imageKey !== imageKey || !textSoFar.startsWith(state.source)) { resetTo(state, textSoFar); } else { state.source = textSoFar; } + state.imageKey = imageKey; // A PARTIAL trailing line that starts a `[…]:` definition already changes // how the whole-text parse resolves shortrefs (marked parses the truncated @@ -418,10 +455,10 @@ export function advance(state: StreamingMarkdownState, textSoFar: string): Strea scan(state); if (!state.linkRefSeen && state.pendingSettle > state.settledOffset) { - commitSettle(state, state.pendingSettle); + commitSettle(state, state.pendingSettle, image); } - const result = renderTail(state); + const result = renderTail(state, image); state.last = result; return result; } @@ -434,7 +471,10 @@ export function advance(state: StreamingMarkdownState, textSoFar: string): Strea * between the marker's arrival and the line's completion); * 3. otherwise prose → today's pipeline, seeded with the settled parity. */ -function renderTail(s: StreamingMarkdownState): StreamingMarkdownResult { +function renderTail( + s: StreamingMarkdownState, + image: MarkdownImageOptions | undefined, +): StreamingMarkdownResult { // 1. Open top-level fence: everything from its opening line is code content. if (s.fence !== null && s.fence.start >= s.settledOffset) { let code = s.source.slice(s.fence.contentStart); @@ -467,7 +507,8 @@ function renderTail(s: StreamingMarkdownState): StreamingMarkdownResult { const prose = s.source.slice(s.settledOffset, s.scanOffset); return { prefixHtml: s.prefixHtml, - tailHtml: renderMarkdownTail(prose, s.seed) + renderOpenFence(info, ""), + tailHtml: + renderMarkdownTail(prose, s.seed, renderOptionsOf(image)) + renderOpenFence(info, ""), }; } } @@ -475,6 +516,6 @@ function renderTail(s: StreamingMarkdownState): StreamingMarkdownResult { // 3. Prose tail. return { prefixHtml: s.prefixHtml, - tailHtml: renderMarkdownTail(s.source.slice(s.settledOffset), s.seed), + tailHtml: renderMarkdownTail(s.source.slice(s.settledOffset), s.seed, renderOptionsOf(image)), }; } diff --git a/src/features/markdown/ui/Markdown.svelte b/src/features/markdown/ui/Markdown.svelte index da2c9ba..0adbbea 100644 --- a/src/features/markdown/ui/Markdown.svelte +++ b/src/features/markdown/ui/Markdown.svelte @@ -1,16 +1,29 @@ `, "script"], + ["anchor ping", `link`, "[ping]"], + ["legacy command", ``, "command"], + ["legacy applet", ``, "applet"], + ["non-image src", `
text
`, "[src],[href]"], + ] as const; + + // Inspect the returned DOM, not jsdom's lack of resource fetching. Both + // authoritative renders and streaming tails must remove each active sink. + describe.each([ + ["full", renderMarkdown], + ["tail", renderMarkdownTail], + ] as const)("%s render", (_name, render) => { + it.each(vectors)("neutralizes %s", (_vector, text, forbidden) => { + const doc = new DOMParser().parseFromString(render(text), "text/html"); + expect(doc.querySelector(forbidden)).toBeNull(); + for (const element of doc.body.querySelectorAll("*")) { + for (const attr of element.attributes) { + if (attr.name === "href" && element.tagName === "A") continue; + expect(attr.value).not.toContain("evil.example"); + } + } + }); + }); + + it("also blocks all vectors in settled streaming prefixes", () => { + for (const [_name, text, forbidden] of vectors) { + const state = createStreamingMarkdown(); + advance(state, `${text}\n\ntail`); + const result = advance(state, `${text}\n\ntail grows`); + const doc = new DOMParser().parseFromString( + result.prefixHtml + result.tailHtml, + "text/html", + ); + expect(doc.querySelector(forbidden)).toBeNull(); + } + }); + + it("retains passive Markdown structure, highlighting and disabled task checkboxes", () => { + const html = renderMarkdown( + "- [x] done\n- [ ] pending\n\n| A | B |\n| - | - |\n| x | y |\n\n```js\nconst x = 1;\n```", + ); + const doc = new DOMParser().parseFromString(html, "text/html"); + expect(doc.querySelectorAll('input[type="checkbox"][disabled]')).toHaveLength(2); + expect(doc.querySelector("input[checked]")).not.toBeNull(); + expect(doc.querySelectorAll("td")).toHaveLength(2); + expect(doc.querySelector(".hljs-keyword")).not.toBeNull(); + expect(doc.querySelector('button[data-copy][type="button"]')).not.toBeNull(); + }); + + it("makes raw checkboxes and buttons inert, even inside an outer form", () => { + const doc = new DOMParser().parseFromString( + renderMarkdown(''), + "text/html", + ); + expect(doc.querySelector("input")?.hasAttribute("disabled")).toBe(true); + expect(doc.querySelector("button")?.getAttribute("type")).toBe("button"); + }); + + it("does not permit non-HTTP network schemes on images", () => { + expect(imageSrc(renderMarkdown(''))).toBeNull(); + }); + }); + describe("local-path images", () => { const BASE = "http://localhost:24203"; const image = { apiBase: BASE, cwd: "/w/foo" }; diff --git a/src/features/markdown/logic/markdown.ts b/src/features/markdown/logic/markdown.ts index a65997e..ed287d2 100644 --- a/src/features/markdown/logic/markdown.ts +++ b/src/features/markdown/logic/markdown.ts @@ -185,6 +185,87 @@ function addCopyButtons(html: string): string { .replace(/<\/pre>/g, ""); } +// Positive policy: chat HTML is passive Markdown structure, not arbitrary web +// content. New browser elements/attributes must not silently add fetch sinks. +// In particular there is no CSS, SVG/MathML, media, form, embed or metadata. +const MARKDOWN_TAGS = [ + "a", + "abbr", + "b", + "blockquote", + "br", + "caption", + "code", + "col", + "colgroup", + "dd", + "del", + "details", + "div", + "dl", + "dt", + "em", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "hr", + "i", + "img", + "kbd", + "li", + "mark", + "ol", + "p", + "pre", + "q", + "rp", + "rt", + "ruby", + "s", + "samp", + "small", + "span", + "strong", + "sub", + "summary", + "sup", + "table", + "tbody", + "td", + "tfoot", + "th", + "thead", + "tr", + "u", + "ul", + "var", + "wbr", + "button", + "input", +]; +const MARKDOWN_ATTRIBUTES = [ + "alt", + "align", + "checked", + "class", + "colspan", + "disabled", + "height", + "href", + "open", + "reversed", + "rowspan", + "scope", + "src", + "start", + "title", + "type", + "width", +]; + /** * A dedicated, render-local DOMPurify instance: hooks never affect another * consumer or retain a conversation's backend allowlist. The document base is @@ -195,7 +276,22 @@ function sanitize(html: string, image?: MarkdownImageOptions): string { const base = document.baseURI; const origin = new URL(base).origin; const backendOrigin = image === undefined ? origin : new URL(image.apiBase || base, base).origin; + purify.addHook("uponSanitizeElement", (node) => { + // Keep GFM task lists, but no image/submit/other form controls. Removing + // only type/src would allow browser defaulting or later attribute changes. + if ( + node.nodeName === "INPUT" && + (node as Element).getAttribute("type")?.toLowerCase() !== "checkbox" + ) { + node.parentNode?.removeChild(node); + } + }); purify.addHook("afterSanitizeAttributes", (node) => { + // URL-bearing attributes are supported ONLY on these two HTML elements. + if (node.tagName !== "A") node.removeAttribute("href"); + if (node.tagName !== "IMG") node.removeAttribute("src"); + if (node.tagName === "INPUT") node.setAttribute("disabled", ""); + if (node.tagName === "BUTTON") node.setAttribute("type", "button"); if (node.tagName === "A" && node.hasAttribute("href")) { node.setAttribute("target", "_blank"); node.setAttribute("rel", "noopener noreferrer"); @@ -210,7 +306,11 @@ function sanitize(html: string, image?: MarkdownImageOptions): string { node.removeAttribute("src"); return; } - if (url.protocol !== "http:" && url.protocol !== "https:") return; + if (url.protocol === "data:") return; // Image context cannot load subresources. + if (url.protocol !== "http:" && url.protocol !== "https:") { + node.removeAttribute("src"); + return; + } if (url.origin === origin || url.origin === backendOrigin) return; // Only safe, inert attributes are carried over. In particular, never copy @@ -225,8 +325,14 @@ function sanitize(html: string, image?: MarkdownImageOptions): string { button.textContent = `Load image from ${url.host}`; node.parentNode?.replaceChild(button, node); }); - // Responsive candidates on / must not bypass the src hook. - return purify.sanitize(html, { FORBID_ATTR: ["srcset"] }); + return purify.sanitize(html, { + ALLOWED_TAGS: MARKDOWN_TAGS, + ALLOWED_ATTR: MARKDOWN_ATTRIBUTES, + // Keep consent/copy data and accessibility labels, never active URL sinks + // such as srcset, style, background, ping, action or namespaced href. + ALLOW_DATA_ATTR: true, + ALLOW_ARIA_ATTR: true, + }); } /** diff --git a/src/features/markdown/ui/markdown.test.ts b/src/features/markdown/ui/markdown.test.ts index 149bff5..b86cc0a 100644 --- a/src/features/markdown/ui/markdown.test.ts +++ b/src/features/markdown/ui/markdown.test.ts @@ -62,6 +62,25 @@ describe("Markdown", () => { ).toBeInTheDocument(); }); + it.each([ + false, + true, + ])("keeps non-image HTML inert before and after image consent (streaming=%s)", async (streaming) => { + const { container } = render(Markdown, { + props: { + text: '
', + streaming, + }, + }); + expect(container.querySelector("video,svg,input,[style],img")).toBeNull(); + await fireEvent.click(screen.getByRole("button", { name: "Load image from evil.example" })); + expect(container.querySelectorAll("img")).toHaveLength(1); + expect(container.querySelector("img")?.getAttribute("src")).toBe( + "https://evil.example/allowed-after-click", + ); + expect(container.querySelector("video,svg,input,[style]")).toBeNull(); + }); + it("rejects an unsafe URL in a forged placeholder", async () => { const { container } = render(Markdown, { props: { text: '' }, diff --git a/vite.config.ts b/vite.config.ts index 4a74b9b..4a45c51 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -11,27 +11,17 @@ import { svelteTesting } from "@testing-library/svelte/vite"; import { defineConfig, type Plugin, type UserConfig } from "vitest/config"; import { buildAllowedHosts, - checkDevRequest, type DevNetworkPolicy, normalizeHost, - normalizeHostHeader, tailscaleSelf, } from "./scripts/network-policy"; +import { applyViteNetworkPolicy } from "./scripts/vite-network-guard"; async function secureDevConfig(config: UserConfig, serving: boolean): Promise { // Build/test have no listener and need no subprocess or DNS effects. if (!serving) return config; const network = await detectDevNetworkPolicy(); - return { - ...config, - plugins: [devNetworkGuard(network), ...(config.plugins ?? [])], - server: { - ...config.server, - allowedHosts: [...network.allowedHosts], - // Safe reflection: our guard rejects disallowed Origins before Vite's CORS. - cors: { origin: true }, - }, - }; + return applyViteNetworkPolicy(config, network); } async function detectDevNetworkPolicy(): Promise { @@ -75,53 +65,6 @@ async function detectDevNetworkPolicy(): Promise { return { allowedHosts, allowedAddresses: new Set(["127.0.0.1", "::1", ...self.ips]) }; } -function devNetworkGuard(policy: DevNetworkPolicy): Plugin { - return { - name: "dispatch-dev-network-guard", - enforce: "pre", - configureServer(server) { - const check = (req: import("node:http").IncomingMessage) => - checkDevRequest( - { - localAddress: req.socket.localAddress, - host: req.headers.host, - origin: req.headers.origin, - }, - policy, - ); - server.middlewares.use((req, res, next) => { - const decision = check(req); - if (decision !== "ok") { - res.writeHead(decision.status, { "Content-Type": "text/plain" }); - res.end(decision.reason); - return; - } - // Vite's own check is case-sensitive and doesn't strip trailing dots. - req.headers.host = normalizeHostHeader(req.headers.host ?? ""); - next(); - }); - // Vite registers HMR before configureServer. Wrap (rather than prepend to) - // its listeners: EventEmitter otherwise continues into HMR after a denial. - const http = server.httpServer; - if (http) { - const upgrades = http.listeners("upgrade"); - http.removeAllListeners("upgrade"); - http.on("upgrade", (req, socket, head) => { - const decision = check(req); - if (decision !== "ok") { - socket.end( - `HTTP/1.1 ${decision.status} ${decision.status === 421 ? "Misdirected Request" : "Forbidden"}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`, - ); - return; - } - req.headers.host = normalizeHostHeader(req.headers.host ?? ""); - for (const upgrade of upgrades) upgrade.call(http, req, socket, head); - }); - } - }, - }; -} - /** * SPIKE (plan 03) — the Svelte-runtime import map. * -- cgit v1.2.3