summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-10-21 01:37:19 +0900
committerAdam Malczewski <[email protected]>2026-10-21 01:37:19 +0900
commitde421a9720bc00334c2cac2913067079a410f8b4 (patch)
tree8ddd92ccf5aa482bb3a33d03287f6bd49786f9c9
parent273b824d2784959bf89ee77cd8f5d0703b1211f7 (diff)
downloaddispatch-web-de421a9720bc00334c2cac2913067079a410f8b4.tar.gz
dispatch-web-de421a9720bc00334c2cac2913067079a410f8b4.zip
fix(review-2.6): restrict Vite access to loopback and Tailscale
-rw-r--r--bun.lock5
-rw-r--r--package.json1
-rw-r--r--scripts/network-policy.test.ts135
-rw-r--r--scripts/network-policy.ts165
-rw-r--r--vite.config.ts136
5 files changed, 432 insertions, 10 deletions
diff --git a/bun.lock b/bun.lock
index 856bb73..f07addb 100644
--- a/bun.lock
+++ b/bun.lock
@@ -25,6 +25,7 @@
"@testing-library/svelte": "^5.2.0",
"@testing-library/user-event": "^14.6.1",
"@tsconfig/svelte": "^5.0.0",
+ "@types/node": "^26.6.5",
"daisyui": "^5.5.20",
"fake-indexeddb": "^6.0.0",
"jsdom": "^25.0.0",
@@ -262,6 +263,8 @@
"@types/estree": ["@types/[email protected]", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
+ "@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg=="],
+
"@types/trusted-types": ["@types/[email protected]", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
"@vitest/expect": ["@vitest/[email protected]", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.6", "@vitest/utils": "3.2.6", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ=="],
@@ -548,6 +551,8 @@
"typescript": ["[email protected]", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+ "undici-types": ["[email protected]", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
+
"vite": ["[email protected]", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="],
"vite-node": ["[email protected]", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="],
diff --git a/package.json b/package.json
index de9c93d..28e239c 100644
--- a/package.json
+++ b/package.json
@@ -38,6 +38,7 @@
"@testing-library/svelte": "^5.2.0",
"@testing-library/user-event": "^14.6.1",
"@tsconfig/svelte": "^5.0.0",
+ "@types/node": "^26.6.5",
"daisyui": "^5.5.20",
"fake-indexeddb": "^6.0.0",
"jsdom": "^25.0.0",
diff --git a/scripts/network-policy.test.ts b/scripts/network-policy.test.ts
new file mode 100644
index 0000000..af7d870
--- /dev/null
+++ b/scripts/network-policy.test.ts
@@ -0,0 +1,135 @@
+import { describe, expect, it } from "vitest";
+import {
+ buildAllowedHosts,
+ checkDevRequest,
+ checkHost,
+ isAllowedLocalAddress,
+ normalizeHost,
+ normalizeHostHeader,
+ tailscaleSelf,
+} from "./network-policy";
+
+const tailscaleStatusJson = `{"Self":{"HostName":"laptop",
+ "DNSName":"PII_REDACTED_TAILSCALE_HOST_01.",
+ "TailscaleIPs":["PII_REDACTED_DEV_IPV4_01","PII_REDACTED_DEV_IPV6_01"]}}`;
+const allowedHosts = buildAllowedHosts({ tailscaleStatusJson, env: {} });
+const allowedAddresses = new Set(["127.0.0.1", "::1", ...tailscaleSelf(tailscaleStatusJson).ips]);
+const policy = { allowedHosts, allowedAddresses };
+const request = (localAddress = "::1", host: string | undefined = "localhost", origin?: string) =>
+ checkDevRequest({ localAddress, host, origin }, policy);
+
+describe("backend-compatible hosts", () => {
+ it.each([
+ ["LOCALHOST.:34204", "localhost"],
+ ["127.0.0.1:34204", "127.0.0.1"],
+ ["[0:0:0:0:0:0:0:1]:34204", "::1"],
+ ["PII_REDACTED_DEVICE_01", "PII_REDACTED_DEVICE_01"],
+ ["PII_REDACTED_TAILSCALE_HOST_01.:34204", "PII_REDACTED_TAILSCALE_HOST_01"],
+ ["PII_REDACTED_DEV_IPV4_01:34204", "PII_REDACTED_DEV_IPV4_01"],
+ ["[PII_REDACTED_DEV_IPV6_01]:34204", "PII_REDACTED_DEV_IPV6_01"],
+ ])("normalizes and allows %s", (raw, expected) => {
+ expect(normalizeHost(raw)).toBe(expected);
+ expect(checkHost(raw, allowedHosts)).toBe("ok");
+ });
+
+ it.each(
+ (
+ "evil.com|PII_REDACTED_DEVICE_01.evil.com|PII_REDACTED_DEV_IPV4_01.nip.io|laptop|localhost.evil.com|" +
+ "sub.localhost|127.0.0.2|100.1.2.3|172.17.0.1|[email protected]|localhost/path|" +
+ "localhost:bad|localhost:65536|[::1]suffix|localhost\n|localhost..|%6cocalhost|"
+ ).split("|"),
+ )("rejects %j even where Vite would allow it", (host) => {
+ expect(checkHost(host, allowedHosts)).toMatchObject({ status: 421 });
+ });
+
+ const missingStatuses = [undefined, "invalid", "null", "[]", "{}", '{"Self":null}'];
+ it.each(missingStatuses)("fails closed for status %j, retains env overrides", (json) => {
+ const input = {
+ ...(json !== undefined ? { tailscaleStatusJson: json } : {}),
+ env: {
+ DISPATCH_ALLOWED_HOSTS: " EXTRA.example.:8080, https://evil.com",
+ DISPATCH_TAILSCALE_NAME: "PII_REDACTED_DEVICE_01",
+ },
+ };
+ const expected = "localhost,127.0.0.1,::1,extra.example,PII_REDACTED_DEVICE_01".split(",");
+ expect([...buildAllowedHosts(input)]).toEqual(expected);
+ expect(tailscaleSelf(json)).toEqual({ ips: [] });
+ });
+
+ it("keeps auto-detection when env hosts are supplied, without trusting OS hostname", () => {
+ const hosts = buildAllowedHosts({
+ tailscaleStatusJson,
+ env: { DISPATCH_ALLOWED_HOSTS: "extra.example" },
+ });
+ expect(hosts.has("PII_REDACTED_TAILSCALE_HOST_01")).toBe(true);
+ expect(hosts.has("extra.example")).toBe(true);
+ expect(hosts.has("laptop")).toBe(false);
+ expect(tailscaleSelf(tailscaleStatusJson).hostName).toBe("laptop");
+ expect(
+ tailscaleSelf('{"Self":{"TailscaleIPs":[null,"evil.com","PII_REDACTED_DEV_IPV4_01"]}}').ips,
+ ).toEqual(["PII_REDACTED_DEV_IPV4_01"]);
+ });
+
+ it.each([
+ ["LOCALHOST.:34204", "localhost:34204"],
+ ["[0:0:0:0:0:0:0:1]:34204", "[::1]:34204"],
+ ["[::1]", "[::1]"],
+ ["PII_REDACTED_DEVICE_01.", "PII_REDACTED_DEVICE_01"],
+ ])("preserves valid authorities for Vite: %s", (raw, expected) => {
+ expect(normalizeHostHeader(raw)).toBe(expected);
+ });
+});
+
+describe("destination interfaces (not remote peer addresses)", () => {
+ it.each(
+ (
+ "127.0.0.1|::1|0:0:0:0:0:0:0:1|::ffff:127.0.0.1|::ffff:7f00:1|" +
+ "PII_REDACTED_DEV_IPV4_01|::ffff:PII_REDACTED_DEV_IPV4_01|PII_REDACTED_DEV_IPV6_01"
+ ).split("|"),
+ )("allows local address %s", (address) => {
+ expect(isAllowedLocalAddress(address, allowedAddresses)).toBe(true);
+ });
+
+ it.each([
+ undefined,
+ ..."|172.17.0.1|::ffff:172.17.0.1|192.168.1.2|100.1.2.3|0.0.0.0|::|127.0.0.2".split("|"),
+ ])("rejects non-selected interface %j", (address) => {
+ expect(isAllowedLocalAddress(address, allowedAddresses)).toBe(false);
+ // undefined must not use the helper's default loopback address.
+ expect(
+ checkDevRequest({ localAddress: address, host: "localhost", origin: undefined }, policy),
+ ).toMatchObject({ status: 403 });
+ });
+
+ it("does not treat an env host extra as an allowed interface", () => {
+ const hosts = buildAllowedHosts({ env: { DISPATCH_ALLOWED_HOSTS: "172.17.0.1" } });
+ expect(
+ checkDevRequest(
+ { localAddress: "172.17.0.1", host: "172.17.0.1", origin: undefined },
+ { allowedHosts: hosts, allowedAddresses },
+ ),
+ ).toMatchObject({ status: 403 });
+ });
+});
+
+describe("shared HTTP / HMR request decision", () => {
+ it.each([
+ undefined,
+ "http://PII_REDACTED_DEVICE_01.:34204",
+ "https://[PII_REDACTED_DEV_IPV6_01]:443",
+ ])("allows trusted or absent Origin %j", (origin) => {
+ expect(request("::ffff:PII_REDACTED_DEV_IPV4_01", "PII_REDACTED_DEVICE_01", origin)).toBe("ok");
+ });
+ const badOrigins =
+ "null|file://localhost|https://evil.com|http://localhost/|http://user@localhost";
+ it.each(badOrigins.split("|"))("rejects Origin %j", (origin) => {
+ expect(request("::1", "localhost", origin)).toMatchObject({ status: 403 });
+ });
+ it("requires Host and checks it before Origin", () => {
+ for (const host of [undefined, "evil.com"]) {
+ expect(
+ checkDevRequest({ localAddress: "127.0.0.1", host, origin: "null" }, policy),
+ ).toMatchObject({ status: 421 });
+ }
+ });
+});
diff --git a/scripts/network-policy.ts b/scripts/network-policy.ts
new file mode 100644
index 0000000..3bb122c
--- /dev/null
+++ b/scripts/network-policy.ts
@@ -0,0 +1,165 @@
+/** Host/Origin semantics mirrored from backend packages/network-policy (review step 2.4).
+ * Keep in sync there; no I/O or ambient environment reads in this module.
+ */
+export interface NetworkPolicyEnv {
+ readonly DISPATCH_ALLOWED_HOSTS?: string;
+ readonly DISPATCH_TAILSCALE_NAME?: string;
+}
+
+export type PolicyDecision<Status extends number> = "ok" | { status: Status; reason: string };
+type HeaderValue = string | null | undefined;
+
+function ipAddress(raw: string): string {
+ if (/^(?:\d{1,3}\.){3}\d{1,3}$/.test(raw)) {
+ return raw.split(".").every((part) => Number(part) <= 255 && String(Number(part)) === part)
+ ? raw
+ : "";
+ }
+ if (!raw.includes(":") || /[^\da-f:.]/i.test(raw)) return "";
+ try {
+ return new URL(`http://[${raw}]`).hostname.slice(1, -1);
+ } catch {
+ return "";
+ }
+}
+
+/** Invalid authorities normalize to "" (fail closed). Bare IPv6 is also accepted. */
+export function normalizeHost(raw: string): string {
+ const host = raw.toLowerCase();
+ if (host.includes(":") && !host.startsWith("[") && host.split(":").length > 2) {
+ return ipAddress(host);
+ }
+ const match = /^(?:\[([\da-f:.]+)\]|([^\s:/\\@?#[\],%]+))(?::(\d+))?$/.exec(host);
+ if (!match || match[0] !== host || (match[3] !== undefined && Number(match[3]) > 65535)) {
+ return "";
+ }
+ if (match[1]) return match[1].includes(":") ? ipAddress(match[1]) : "";
+ const name = (match[2] ?? "").replace(/\.$/, "");
+ return name.length <= 253 &&
+ name.split(".").every((label) => /^[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?$/.test(label))
+ ? name
+ : "";
+}
+
+/** OS HostName is deliberately not trusted; the shell may add it after verifying DNS. */
+export function buildAllowedHosts(input: {
+ tailscaleStatusJson?: string;
+ env: NetworkPolicyEnv;
+}): ReadonlySet<string> {
+ const allowed = new Set(["localhost", "127.0.0.1", "::1"]);
+ const add = (raw: string) => {
+ const normalized = normalizeHost(raw);
+ if (normalized) allowed.add(normalized);
+ };
+ try {
+ const status: unknown = JSON.parse(input.tailscaleStatusJson ?? "null");
+ if (status && typeof status === "object" && "Self" in status) {
+ const self = status.Self;
+ if (self && typeof self === "object") {
+ if ("DNSName" in self && typeof self.DNSName === "string") {
+ const name = normalizeHost(self.DNSName);
+ if (name) {
+ add(name);
+ add(name.split(".")[0] ?? "");
+ }
+ }
+ if ("TailscaleIPs" in self && Array.isArray(self.TailscaleIPs)) {
+ for (const ip of self.TailscaleIPs) {
+ if (typeof ip === "string" && ipAddress(ip)) add(ip);
+ }
+ }
+ }
+ }
+ } catch {
+ // Missing/malformed status must leave the loopback and explicit env policy intact.
+ }
+ for (const host of (input.env.DISPATCH_ALLOWED_HOSTS ?? "").split(",")) add(host.trim());
+ if (input.env.DISPATCH_TAILSCALE_NAME) add(input.env.DISPATCH_TAILSCALE_NAME.trim());
+ return allowed;
+}
+
+export function checkHost(
+ hostHeader: HeaderValue,
+ allowed: ReadonlySet<string>,
+): PolicyDecision<421> {
+ const host = hostHeader == null ? "" : normalizeHost(hostHeader);
+ return host && allowed.has(host) ? "ok" : { status: 421, reason: "Host is not allowed" };
+}
+
+export function checkOrigin(
+ originHeader: HeaderValue,
+ allowed: ReadonlySet<string>,
+): PolicyDecision<403> {
+ if (originHeader == null) return "ok";
+ const match = /^https?:\/\/([^/\\?#\s]+)$/i.exec(originHeader);
+ if (match?.[1] && match[0] === originHeader && checkHost(match[1], allowed) === "ok") {
+ try {
+ new URL(originHeader);
+ return "ok";
+ } catch {
+ // Reject malformed URL authorities even if they resemble an allowed hostname.
+ }
+ }
+ return { status: 403, reason: "Origin is not allowed" };
+}
+
+/** Canonical authority for Vite's narrower parser; preserve ports and IPv6 brackets. */
+export function normalizeHostHeader(raw: string): string {
+ const host = normalizeHost(raw);
+ const port = /^(?:\[[^\]]+\]|[^:]+)(:\d+)$/.exec(raw)?.[1] ?? "";
+ return `${host.includes(":") ? `[${host}]` : host}${port}`;
+}
+
+/** Only the destination interface matters, NOT the remote peer's IP. */
+export function isAllowedLocalAddress(
+ address: string | undefined,
+ allowedAddresses: ReadonlySet<string>,
+): boolean {
+ if (!address) return false;
+ // Dual-stack Node listeners report IPv4 destinations as ::ffff:a.b.c.d (or hex).
+ let normalized = normalizeHost(address);
+ if (normalized.startsWith("::ffff:")) {
+ const words = normalized.slice(7).split(":");
+ if (words.length === 2) {
+ const value =
+ Number.parseInt(words[0] ?? "", 16) * 65536 + Number.parseInt(words[1] ?? "", 16);
+ normalized = [value >>> 24, (value >>> 16) & 255, (value >>> 8) & 255, value & 255].join(".");
+ }
+ }
+ return allowedAddresses.has(normalized);
+}
+
+/** Extract only actual IPs; neither env host extras nor a whole Tailscale subnet is trusted. */
+export function tailscaleSelf(json: string | undefined): { ips: string[]; hostName?: string } {
+ try {
+ const self = JSON.parse(json ?? "null")?.Self;
+ const ips = Array.isArray(self?.TailscaleIPs)
+ ? self.TailscaleIPs.filter(
+ (ip: unknown): ip is string => typeof ip === "string" && !!ipAddress(ip),
+ )
+ : [];
+ return {
+ ips: ips.map((ip: string) => ipAddress(ip)),
+ ...(typeof self?.HostName === "string" ? { hostName: self.HostName } : {}),
+ };
+ } catch {
+ return { ips: [] };
+ }
+}
+
+export interface DevNetworkPolicy {
+ readonly allowedHosts: ReadonlySet<string>;
+ readonly allowedAddresses: ReadonlySet<string>;
+}
+
+/** Shared HTTP and HMR guard. Host precedes Origin, matching the backend. */
+export function checkDevRequest(
+ input: { localAddress: string | undefined; host: string | undefined; origin: string | undefined },
+ policy: DevNetworkPolicy,
+): PolicyDecision<403 | 421> {
+ if (!isAllowedLocalAddress(input.localAddress, policy.allowedAddresses)) {
+ return { status: 403, reason: "Local interface is not allowed" };
+ }
+ const host = checkHost(input.host, policy.allowedHosts);
+ return host === "ok" ? checkOrigin(input.origin, policy.allowedHosts) : host;
+}
diff --git a/vite.config.ts b/vite.config.ts
index 8ea9c54..4a74b9b 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,4 +1,5 @@
-import { execSync } from "node:child_process";
+import { execFileSync, execSync } from "node:child_process";
+import { lookup } from "node:dns/promises";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
@@ -7,7 +8,119 @@ console.log("[spike] config module evaluated");
import { svelte } from "@sveltejs/vite-plugin-svelte";
import tailwindcss from "@tailwindcss/vite";
import { svelteTesting } from "@testing-library/svelte/vite";
-import { defineConfig, type Plugin } from "vitest/config";
+import { defineConfig, type Plugin, type UserConfig } from "vitest/config";
+import {
+ buildAllowedHosts,
+ checkDevRequest,
+ type DevNetworkPolicy,
+ normalizeHost,
+ normalizeHostHeader,
+ tailscaleSelf,
+} from "./scripts/network-policy";
+
+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 },
+ },
+ };
+}
+
+async function detectDevNetworkPolicy(): Promise<DevNetworkPolicy> {
+ let tailscaleStatusJson: string | undefined;
+ try {
+ tailscaleStatusJson = execFileSync("tailscale", ["status", "--self", "--json"], {
+ encoding: "utf8",
+ timeout: 2000,
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+ } catch {
+ console.warn(
+ "[network] Tailscale detection unavailable; using loopback and explicit env hosts",
+ );
+ }
+ const allowedHosts = new Set(
+ buildAllowedHosts({
+ ...(tailscaleStatusJson !== undefined ? { tailscaleStatusJson } : {}),
+ env: {
+ ...(process.env.DISPATCH_ALLOWED_HOSTS !== undefined
+ ? { DISPATCH_ALLOWED_HOSTS: process.env.DISPATCH_ALLOWED_HOSTS }
+ : {}),
+ ...(process.env.DISPATCH_TAILSCALE_NAME !== undefined
+ ? { DISPATCH_TAILSCALE_NAME: process.env.DISPATCH_TAILSCALE_NAME }
+ : {}),
+ },
+ }),
+ );
+ const self = tailscaleSelf(tailscaleStatusJson);
+ if (self.hostName) {
+ try {
+ const answers = await lookup(self.hostName, { all: true });
+ if (answers.some(({ address }) => self.ips.includes(normalizeHost(address)))) {
+ const name = normalizeHost(self.hostName);
+ if (name) allowedHosts.add(name);
+ }
+ } catch {
+ // The OS hostname is not necessarily the MagicDNS name. Fail closed.
+ }
+ }
+ 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.
@@ -105,16 +218,15 @@ function readBrowserHash(): string | null {
// Dev server on the reserved FRONTEND_PORT (24204). Vitest config lives here too
// (jsdom + globals) so component tests run without extra config.
-export default defineConfig({
+const config: UserConfig = {
// svelteTesting() forces Svelte's `browser` resolve condition under vitest so
// component render()/mount() works in jsdom (a plain test.resolve.conditions
// does not propagate to Vite's SSR resolution — sveltejs/svelte#11394).
plugins: [tailwindcss(), svelte(), svelteTesting(), svelteRuntimeImportMap()],
- // Bind all interfaces + accept any Host header so the dev server is reachable over a LAN /
- // Tailscale. Safe for LOCAL-NETWORK-ONLY use (NOT internet-exposed): `allowedHosts: true`
- // disables Vite's DNS-rebinding host check. (The WS URL still runs in the browser — set
- // VITE_WS_URL to the backend's reachable host when browsing from another device.)
- server: { port: 24204, host: true, allowedHosts: true },
+ // Vite has one bind: destination-interface guards restrict HTTP + HMR to
+ // loopback / detected Tailscale IPs despite the dual-stack wildcard listener.
+ // Env host extras are additive and DISPATCH_BIND cannot widen this guard.
+ server: { port: 24204, host: true, allowedHosts: ["localhost", "127.0.0.1", "::1"] },
// SPIKE (plan 03): resolve the bare Svelte specifiers to svelte's SOURCE
// files — stable dev URLs the import map points the plugin's bare imports
// at (one shared runtime instance; no prebundle copy of svelte).
@@ -171,7 +283,7 @@ export default defineConfig({
// rollup tree-shake entry exports no other chunk imports (leaving only
// the bindings the app happens to use). Their exports must be the FULL
// svelte API: a plugin may import bindings the app never uses.
- preserveEntrySignatures: true,
+ preserveEntrySignatures: "strict",
input: {
main: fileURLToPath(new URL("./index.html", import.meta.url)),
"svelte-api": svelteApiEntry,
@@ -189,7 +301,11 @@ export default defineConfig({
globals: true,
setupFiles: ["./vitest-setup.ts"],
},
-});
+};
+
+export default defineConfig(async ({ command }) =>
+ secureDevConfig(config, command === "serve" && !process.env.VITEST),
+);
function getGitShortHash(): string {
try {