summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--scripts/vite-network-guard.test.ts105
-rw-r--r--scripts/vite-network-guard.ts74
-rw-r--r--src/features/markdown/logic/markdown.test.ts118
-rw-r--r--src/features/markdown/logic/markdown.ts112
-rw-r--r--src/features/markdown/ui/markdown.test.ts19
-rw-r--r--vite.config.ts61
6 files changed, 427 insertions, 62 deletions
diff --git a/scripts/vite-network-guard.test.ts b/scripts/vite-network-guard.test.ts
new file mode 100644
index 0000000..651b635
--- /dev/null
+++ b/scripts/vite-network-guard.test.ts
@@ -0,0 +1,105 @@
+import { EventEmitter } from "node:events";
+import type { IncomingMessage, ServerResponse } from "node:http";
+import type { Connect, Plugin, PreviewServer, ViteDevServer } from "vite";
+import { describe, expect, it, vi } from "vitest";
+import { applyViteNetworkPolicy, devNetworkGuard } from "./vite-network-guard";
+
+const policy = {
+ allowedHosts: new Set(["localhost", "127.0.0.1", "::1", "node.ts.net", "100.64.0.10"]),
+ allowedAddresses: new Set(["127.0.0.1", "::1", "100.64.0.10"]),
+};
+
+function install(mode: "dev" | "preview") {
+ let middleware: Connect.NextHandleFunction | undefined;
+ const http = new EventEmitter();
+ const upgrade = vi.fn();
+ http.on("upgrade", upgrade);
+ const server = {
+ middlewares: {
+ use: (handler: Connect.NextHandleFunction) => {
+ middleware = handler;
+ },
+ },
+ httpServer: http,
+ };
+ const plugin = devNetworkGuard(policy);
+ const hook = mode === "dev" ? plugin.configureServer : plugin.configurePreviewServer;
+ if (typeof hook !== "function") throw new Error(`Missing ${mode} guard hook`);
+ // Hooks only need the injected middleware stack and HTTP event emitter.
+ hook.call({} as never, server as unknown as ViteDevServer & PreviewServer);
+ if (!middleware) throw new Error(`Missing ${mode} middleware`);
+ return { middleware, http, upgrade };
+}
+
+function request(localAddress: string | undefined, host?: string, origin?: string) {
+ return { socket: { localAddress }, headers: { host, origin } } as IncomingMessage;
+}
+
+const cases = [
+ ["loopback", "127.0.0.1", "localhost", undefined, 200],
+ ["IPv6 loopback", "::1", "[::1]:34204", undefined, 200],
+ ["mapped address / canonical Host", "::ffff:127.0.0.1", "LOCALHOST.:34204", undefined, 200],
+ ["tailnet", "100.64.0.10", "node.ts.net", "https://node.ts.net:34204", 200],
+ ["unselected interface with trusted Host", "172.17.0.1", "localhost", undefined, 403],
+ ["missing interface", undefined, "localhost", undefined, 403],
+ ["arbitrary IP Host", "127.0.0.1", "127.0.0.2", undefined, 421],
+ ["rebinding Host", "127.0.0.1", "evil.example", undefined, 421],
+ ["missing Host", "127.0.0.1", undefined, undefined, 421],
+ ["disallowed Origin", "127.0.0.1", "localhost", "https://evil.example", 403],
+ ["opaque Origin", "127.0.0.1", "localhost", "null", 403],
+ ["Host before Origin", "127.0.0.1", "evil.example", "null", 421],
+] as const;
+
+describe.each(["dev", "preview"] as const)("%s HTTP guard", (mode) => {
+ it.each(cases)("%s", (_label, local, host, origin, status) => {
+ const { middleware } = install(mode);
+ const req = request(local, host, origin);
+ const writeHead = vi.fn();
+ const end = vi.fn();
+ const next = vi.fn();
+ middleware(req, { writeHead, end } as unknown as ServerResponse, next);
+ if (status === 200) {
+ expect(next).toHaveBeenCalledExactlyOnceWith();
+ expect(writeHead).not.toHaveBeenCalled();
+ if (host === "LOCALHOST.:34204") expect(req.headers.host).toBe("localhost:34204");
+ } else {
+ expect(writeHead).toHaveBeenCalledWith(status, { "Content-Type": "text/plain" });
+ expect(end).toHaveBeenCalledOnce();
+ expect(next).not.toHaveBeenCalled(); // In particular, cannot reach CORS/static serving.
+ }
+ });
+});
+
+describe("dev upgrades remain protected", () => {
+ it.each(cases)("%s", (_label, local, host, origin, status) => {
+ const { http, upgrade } = install("dev");
+ const end = vi.fn();
+ http.emit("upgrade", request(local, host, origin), { end }, Buffer.alloc(0));
+ if (status === 200) {
+ expect(upgrade).toHaveBeenCalledOnce();
+ expect(end).not.toHaveBeenCalled();
+ } else {
+ expect(upgrade).not.toHaveBeenCalled();
+ expect(end).toHaveBeenCalledWith(expect.stringContaining(`HTTP/1.1 ${status}`));
+ }
+ });
+});
+
+it("sets explicit allowlists and guarded CORS for preview as well as dev", () => {
+ const config = applyViteNetworkPolicy(
+ {
+ server: { host: true, port: 34204 },
+ preview: { host: true, port: 34204, cors: true, allowedHosts: true },
+ },
+ policy,
+ );
+ for (const serving of [config.server, config.preview]) {
+ expect(serving?.allowedHosts).toEqual([...policy.allowedHosts]);
+ expect(serving?.cors).toEqual({ origin: true });
+ expect(serving?.host).toBe(true);
+ expect(serving?.port).toBe(34204);
+ }
+ const plugin = config.plugins?.[0] as Plugin;
+ expect(plugin.configureServer).toBeTypeOf("function");
+ expect(plugin.configurePreviewServer).toBeTypeOf("function");
+});
diff --git a/scripts/vite-network-guard.ts b/scripts/vite-network-guard.ts
new file mode 100644
index 0000000..7fe9eca
--- /dev/null
+++ b/scripts/vite-network-guard.ts
@@ -0,0 +1,74 @@
+import type { IncomingMessage } from "node:http";
+import type { Plugin, UserConfig } from "vite";
+import { checkDevRequest, type DevNetworkPolicy, normalizeHostHeader } from "./network-policy";
+
+/** Explicitly protect BOTH serving modes; preview must not inherit permissive CORS alone. */
+export function applyViteNetworkPolicy(config: UserConfig, network: DevNetworkPolicy): UserConfig {
+ return {
+ ...config,
+ plugins: [devNetworkGuard(network), ...(config.plugins ?? [])],
+ server: {
+ ...config.server,
+ allowedHosts: [...network.allowedHosts],
+ // Safe reflection only after our pre-middleware validates the Origin.
+ cors: { origin: true },
+ },
+ preview: {
+ ...config.preview,
+ allowedHosts: [...network.allowedHosts],
+ cors: { origin: true },
+ },
+ };
+}
+
+export function devNetworkGuard(policy: DevNetworkPolicy): Plugin {
+ const check = (req: IncomingMessage) =>
+ checkDevRequest(
+ {
+ localAddress: req.socket.localAddress,
+ host: req.headers.host,
+ origin: req.headers.origin,
+ },
+ policy,
+ );
+ // Installed before Vite's CORS, host check and static-file middleware in
+ // either mode. No post-hook: even preflights and error responses are guarded.
+ const middleware: import("vite").Connect.NextHandleFunction = (req, res, next) => {
+ const decision = check(req);
+ if (decision !== "ok") {
+ res.writeHead(decision.status, { "Content-Type": "text/plain" });
+ res.end(decision.reason);
+ return;
+ }
+ req.headers.host = normalizeHostHeader(req.headers.host ?? "");
+ next();
+ };
+ return {
+ name: "dispatch-dev-network-guard",
+ enforce: "pre",
+ configurePreviewServer(server) {
+ server.middlewares.use(middleware);
+ },
+ configureServer(server) {
+ server.middlewares.use(middleware);
+ // 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);
+ });
+ }
+ },
+ };
+}
diff --git a/src/features/markdown/logic/markdown.test.ts b/src/features/markdown/logic/markdown.test.ts
index 320a5f3..f186da9 100644
--- a/src/features/markdown/logic/markdown.test.ts
+++ b/src/features/markdown/logic/markdown.test.ts
@@ -174,6 +174,124 @@ describe("renderMarkdown", () => {
});
});
+ describe("raw HTML cannot load remote content outside image consent", () => {
+ const remote = "https://evil.example/beacon";
+ const vectors = [
+ ["video poster", `<video poster="${remote}"></video>`, "video"],
+ ["video src", `<video src="${remote}" autoplay></video>`, "video"],
+ ["audio src", `<audio src="${remote}" preload="auto"></audio>`, "audio"],
+ ["source src", `<video><source src="${remote}"></video>`, "source"],
+ ["source srcset", `<picture><source srcset="${remote} 2x"></picture>`, "source"],
+ ["track src", `<video><track src="${remote}" default></video>`, "track"],
+ ["image input", `<input type="image" src="${remote}">`, "input"],
+ ["input src without type", `<input src="${remote}">`, "input"],
+ ["inline background", `<div style="background-image:url(${remote})">text</div>`, "[style]"],
+ ["inline CSS escape", `<p style="background: u\\72l(${remote})">text</p>`, "[style]"],
+ [
+ "inline image-set",
+ `<span style="background:image-set('${remote}' 1x)">text</span>`,
+ "[style]",
+ ],
+ ["style import", `<style>@import '${remote}';</style>`, "style"],
+ ["SVG image href", `<svg><image href="${remote}" /></svg>`, "svg,image"],
+ ["SVG image xlink", `<svg><image xlink:href="${remote}" /></svg>`, "svg,image"],
+ ["SVG use href", `<svg><use href="${remote}#x" /></svg>`, "svg,use"],
+ ["SVG use xlink", `<svg><use xlink:href="${remote}#x" /></svg>`, "svg,use"],
+ ["SVG filter", `<svg><filter><feImage href="${remote}" /></filter></svg>`, "svg,feImage"],
+ ["MathML", `<math href="${remote}"><mi>x</mi></math>`, "math"],
+ [
+ "table background",
+ `<table background="${remote}"><tr><td>x</td></tr></table>`,
+ "[background]",
+ ],
+ [
+ "td background",
+ `<table><tr><td background="${remote}">x</td></tr></table>`,
+ "[background]",
+ ],
+ ["body background", `<body background="${remote}">x</body>`, "[background]"],
+ ["object data", `<object data="${remote}"></object>`, "object"],
+ ["embed src", `<embed src="${remote}">`, "embed"],
+ ["embed data", `<embed data="${remote}">`, "embed"],
+ ["form action", `<form action="${remote}"><button>go</button></form>`, "form,[action]"],
+ ["button formaction", `<button formaction="${remote}">go</button>`, "[formaction]"],
+ ["input formaction", `<input type="submit" formaction="${remote}">`, "input,[formaction]"],
+ ["stylesheet link", `<link rel="stylesheet" href="${remote}">`, "link"],
+ ["preload link", `<link rel="preload" as="image" href="${remote}">`, "link"],
+ ["prefetch link", `<link rel="prefetch" href="${remote}">`, "link"],
+ ["DNS prefetch", `<link rel="dns-prefetch" href="//evil.example">`, "link"],
+ ["meta refresh", `<meta http-equiv="refresh" content="0;url=${remote}">`, "meta"],
+ ["iframe src", `<iframe src="${remote}"></iframe>`, "iframe"],
+ [
+ "iframe srcdoc",
+ `<iframe srcdoc="&lt;img src='${remote}'&gt;"></iframe>`,
+ "iframe,[srcdoc]",
+ ],
+ ["base URL", `<base href="${remote}/"><img src="/safe.png">`, "base"],
+ ["script src", `<script src="${remote}"></script>`, "script"],
+ ["anchor ping", `<a href="/safe" ping="${remote}">link</a>`, "[ping]"],
+ ["legacy command", `<command icon="${remote}">`, "command"],
+ ["legacy applet", `<applet codebase="${remote}" archive="x.jar"></applet>`, "applet"],
+ ["non-image src", `<div src="${remote}" href="${remote}">text</div>`, "[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('<input type="checkbox"><button type="submit">go</button>'),
+ "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('<img src="ftp://evil.example/beacon">'))).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, "</pre></div>");
}
+// 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 <img>/<picture> 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: '<div style="background:url(https://evil.example/bg)"><video poster="https://evil.example/poster" src="https://evil.example/video"></video><svg><image href="https://evil.example/svg" /></svg><input type="image" src="https://evil.example/input"><img src="https://evil.example/allowed-after-click"></div>',
+ 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: '<button class="remote-image" data-src="javascript:alert(1)">Load</button>' },
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<UserConfig> {
// 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<DevNetworkPolicy> {
@@ -75,53 +65,6 @@ async function detectDevNetworkPolicy(): Promise<DevNetworkPolicy> {
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.
*