From 0cfe804d6c6b6d02a6fba15d00dd45eeb49b3070 Mon Sep 17 00:00:00 2001 From: ttbombadil Date: Fri, 9 Oct 2026 13:57:54 +0200 Subject: [PATCH] fix(security): keep the local dev profile reachable from this machine only The Vite dev server of `npm run dev:full` listened on all interfaces (`server.host: true`) and proxied /api and /ws to the loopback-only backend, which runs sketches as native processes in local mode. Vite admits every IP Host header, and the local WebSocket verifier accepted an Origin equal to Host and any upgrade without Origin, so a LAN client could start native sketch execution on the developer's machine. The backend checked no Host header at all, so a DNS rebinding page whose host name resolves to 127.0.0.1 could do the same from any website. - Vite listens on 127.0.0.1 only. - In local mode the backend accepts HTTP requests and WebSocket upgrades only for Host `localhost` or a numeric loopback address. - A WebSocket upgrade without Origin is accepted only from a loopback peer. - UNOSIM_UNSAFE_ALLOW_EXTERNAL_LOCAL_BIND keeps its explicit meaning and lifts these checks; gateway mode and the Docker profile are unchanged. Co-Authored-By: Claude Opus 5.5 --- docs/INSTALL_LOCAL.md | 6 + docs/SECURITY.md | 13 ++- server/config.ts | 5 +- server/index.ts | 4 +- server/security/access-control.ts | 74 ++++++++++++- tests/server/security/access-control.test.ts | 111 +++++++++++++++++-- tests/server/vite-config.test.ts | 5 + vite.config.ts | 3 +- 8 files changed, 201 insertions(+), 20 deletions(-) diff --git a/docs/INSTALL_LOCAL.md b/docs/INSTALL_LOCAL.md index dc30afe45..157607698 100644 --- a/docs/INSTALL_LOCAL.md +++ b/docs/INSTALL_LOCAL.md @@ -43,6 +43,12 @@ Die Projektskripte setzen `NODE_ENV=development` und Sketches als lokale Prozesse aus. Docker wird weder geprüft noch als Fallback verwendet. +Weil Sketches nativ laufen, ist der Local-Modus nur vom eigenen Rechner aus +erreichbar: Vite lauscht auf `127.0.0.1:3001`, das Backend auf `127.0.0.1:3000`, +und das Backend nimmt nur Anfragen an `localhost` oder eine Loopback-Adresse an. +Aufruf über `http://localhost:3001`; LAN-Adressen und fremde Hostnamen werden +abgewiesen (siehe [SECURITY.md](SECURITY.md)). + ## Konfiguration `UNOSIM_SERVER_MODE` ist der einzige Topologie-Schalter. Für Entwicklung ist diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 93b01fb86..276df10eb 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -17,10 +17,15 @@ gateway is optional. Local development is not approved for shared, LAN or public access. `UNOSIM_LISTEN_HOST` in local mode accepts only numeric loopback addresses -(`127.0.0.0/8` or `::1`). Binding elsewhere requires the explicit dangerous -opt-in `UNOSIM_UNSAFE_ALLOW_EXTERNAL_LOCAL_BIND=true`; it adds no authentication -or sandboxing and exposes local process execution to clients that can reach the -listener. +(`127.0.0.0/8` or `::1`). The Vite dev server of `npm run dev:full` listens on +`127.0.0.1:3001` only. In local mode the backend accepts only HTTP requests and +WebSocket upgrades whose `Host` is `localhost` or a numeric loopback address +(any port), so neither a LAN client nor a DNS rebinding page whose host name +resolves to `127.0.0.1` reaches it; WebSocket clients without an `Origin` +header must connect from a loopback address. Binding elsewhere requires the +explicit dangerous opt-in `UNOSIM_UNSAFE_ALLOW_EXTERNAL_LOCAL_BIND=true`, which +also lifts these host checks; it adds no authentication or sandboxing and +exposes local process execution to clients that can reach the listener. ### Docker deployment diff --git a/server/config.ts b/server/config.ts index eb7afd7c2..ac03bfd9a 100644 --- a/server/config.ts +++ b/server/config.ts @@ -277,7 +277,8 @@ const cwd = process.cwd(); const cpuCount = os.cpus().length; const defaultWorkers = Math.min(8, Math.max(2, Math.floor(cpuCount * 0.5))); const defaultCompileMaxConcurrent = Math.max(1, cpuCount - 1); -const trust = parseTrustConfig(process.env, runtimeProfile); +const allowUnsafeExternalLocalBind = envBool("UNOSIM_UNSAFE_ALLOW_EXTERNAL_LOCAL_BIND", false); +const trust = parseTrustConfig(process.env, runtimeProfile, allowUnsafeExternalLocalBind); const localWebSocketOrigins = [ "http://localhost:3000", "http://127.0.0.1:3000", @@ -450,7 +451,7 @@ export const config = { listenHost: parseListenHost( trust.mode, process.env.UNOSIM_LISTEN_HOST, - envBool("UNOSIM_UNSAFE_ALLOW_EXTERNAL_LOCAL_BIND", false), + allowUnsafeExternalLocalBind, ), /** * Register destructive endpoints used for test isolation. diff --git a/server/index.ts b/server/index.ts index 6ba1769ee..275d6f8e8 100644 --- a/server/index.ts +++ b/server/index.ts @@ -10,7 +10,7 @@ import type { Server } from "node:http"; import { getCompilationPool } from "./services/compilation-worker-pool"; import { config } from "./config"; import { INPUT_LIMITS } from "@shared/input-limits"; -import { createLocalSessionMiddleware } from "./security/access-control"; +import { createLocalHostGuard, createLocalSessionMiddleware } from "./security/access-control"; import { guardApiMutations } from "./security/api-request-guard"; import { formatStartupLine, @@ -45,6 +45,8 @@ function startCleanupService(): NodeJS.Timeout { const app = express(); +// Local mode executes sketches natively: refuse LAN and DNS-rebinding hosts first. +app.use(createLocalHostGuard(config.trust)); app.use(createLocalSessionMiddleware(config.trust)); if (config.trust.mode === "gateway") { diff --git a/server/security/access-control.ts b/server/security/access-control.ts index 7f410bf7b..448962d91 100644 --- a/server/security/access-control.ts +++ b/server/security/access-control.ts @@ -10,6 +10,11 @@ export interface TrustConfig { mode: TrustMode; gatewaySecret?: string; trustedProxy?: string; + /** + * Local mode only: the explicit UNOSIM_UNSAFE_ALLOW_EXTERNAL_LOCAL_BIND + * opt-in. Without it, local mode serves loopback clients only. + */ + allowExternalLocalClients?: true; } export interface RequestIdentity { @@ -109,8 +114,14 @@ export function parseTrustConfig( serverMode: "local" | "docker"; dockerTestBypassGateway: boolean; }, + allowUnsafeExternalLocalBind = false, ): TrustConfig { - if (profile.serverMode === "local" || profile.dockerTestBypassGateway) { + if (profile.serverMode === "local") { + return allowUnsafeExternalLocalBind + ? { mode: "local", allowExternalLocalClients: true } + : { mode: "local" }; + } + if (profile.dockerTestBypassGateway) { return { mode: "local" }; } @@ -139,13 +150,66 @@ function singleHeader( return Array.isArray(value) ? undefined : value; } +function isLoopbackAddress(address: string | undefined): boolean { + if (!address) return false; + const normalized = address.startsWith("::ffff:") ? address.slice("::ffff:".length) : address; + const version = isIP(normalized); + if (version === 6) return normalized === "::1"; + return version === 4 && normalized.split(".", 1)[0] === "127"; +} + +/** + * Whether a Host header names this machine: `localhost` or a numeric loopback + * address, with any port. A DNS rebinding page reaches a loopback listener + * under its own host name, and a LAN client under the machine's LAN address; + * both fail here. + */ +export function isLoopbackHostHeader(host: string | undefined): boolean { + // A Host header is host[:port]; anything URL syntax would reinterpret is refused. + if (!host || /[\s/\\@?#]/.test(host)) return false; + let hostname: string; + try { + hostname = new URL(`http://${host}`).hostname; + } catch { + return false; + } + if (hostname === "localhost") return true; + if (hostname === "[::1]") return true; + return isIP(hostname) === 4 && isLoopbackAddress(hostname); +} + +/** + * Local mode runs sketches as native processes, so only this machine may use it. + * Gateway mode and the explicit unsafe external opt-in are not restricted here. + */ +export function isLocalClientAllowed(req: IncomingMessage, trust: TrustConfig): boolean { + if (trust.mode !== "local" || trust.allowExternalLocalClients) return true; + return isLoopbackHostHeader(singleHeader(req.headers, "host")); +} + +/** Rejects HTTP requests for a non-loopback host in local mode (LAN, DNS rebinding). */ +export function createLocalHostGuard(trust: TrustConfig): RequestHandler { + return (req: Request, res: Response, next: NextFunction): void => { + if (!isLocalClientAllowed(req, trust)) { + res.status(403).json({ error: "Local mode accepts only localhost requests" }); + return; + } + next(); + }; +} + export function isWebSocketOriginAllowed( headers: IncomingHttpHeaders, trust: TrustConfig, allowedOrigins: readonly string[], + remoteAddress?: string, ): boolean { const origin = singleHeader(headers, "origin"); - if (!origin) return trust.mode === "local"; + if (!origin) { + // Non-browser clients send no Origin; locally only this machine may connect without one. + return trust.mode === "local" + && (trust.allowExternalLocalClients === true || isLoopbackAddress(remoteAddress)); + } let parsed: URL; try { @@ -269,7 +333,11 @@ export function createWebSocketAuthorizationVerifier( ); return; } - if (!isWebSocketOriginAllowed(req.headers, trust, allowedOrigins)) { + if (!isLocalClientAllowed(req, trust)) { + done(false, 403, "Forbidden host"); + return; + } + if (!isWebSocketOriginAllowed(req.headers, trust, allowedOrigins, req.socket?.remoteAddress)) { done(false, 403, "Forbidden origin"); return; } diff --git a/tests/server/security/access-control.test.ts b/tests/server/security/access-control.test.ts index c48c3fd47..92748e18e 100644 --- a/tests/server/security/access-control.test.ts +++ b/tests/server/security/access-control.test.ts @@ -3,8 +3,11 @@ import express from "express"; import http from "node:http"; import { authorizeHeaders, + createLocalHostGuard, createUserAuthorizationMiddleware, createWebSocketAuthorizationVerifier, + isLocalClientAllowed, + isLoopbackHostHeader, isWebSocketOriginAllowed, parseTrustConfig, type TrustConfig, @@ -59,6 +62,12 @@ describe("parseTrustConfig", () => { }); }); + it("allows external local clients only under the explicit unsafe opt-in", () => { + const localProfile = { serverMode: "local", dockerTestBypassGateway: false } as const; + expect(parseTrustConfig({}, localProfile, true)).toEqual({ mode: "local", allowExternalLocalClients: true }); + expect(parseTrustConfig({}, { serverMode: "docker", dockerTestBypassGateway: true }, true)).toEqual({ mode: "local" }); + }); + it("uses local authentication only for the explicit Docker test bypass", () => { expect(parseTrustConfig( { NODE_ENV: "test" }, @@ -316,9 +325,13 @@ describe("WebSocket authorization verifier", () => { ).toBe(false); }); - it("allows originless local clients but checks origins when supplied", () => { + it("allows originless local clients only from a loopback peer and checks supplied origins", () => { const localTrust: TrustConfig = { mode: "local" }; - expect(isWebSocketOriginAllowed({}, localTrust, [])).toBe(true); + expect(isWebSocketOriginAllowed({}, localTrust, [], "127.0.0.1")).toBe(true); + expect(isWebSocketOriginAllowed({}, localTrust, [], "::ffff:127.0.0.1")).toBe(true); + expect(isWebSocketOriginAllowed({}, localTrust, [], "::1")).toBe(true); + expect(isWebSocketOriginAllowed({}, localTrust, [], "192.168.1.30")).toBe(false); + expect(isWebSocketOriginAllowed({}, localTrust, [])).toBe(false); expect( isWebSocketOriginAllowed( { origin: "http://localhost:5173" }, @@ -326,13 +339,6 @@ describe("WebSocket authorization verifier", () => { ["http://localhost:5173"], ), ).toBe(true); - expect( - isWebSocketOriginAllowed( - { origin: "http://192.168.1.20:3000", host: "192.168.1.20:3000" }, - localTrust, - [], - ), - ).toBe(true); expect( isWebSocketOriginAllowed( { origin: "https://attacker.example", host: "192.168.1.20:3000" }, @@ -341,4 +347,91 @@ describe("WebSocket authorization verifier", () => { ), ).toBe(false); }); + + it("keeps originless external local clients only under the explicit unsafe opt-in", () => { + const unsafeTrust: TrustConfig = { mode: "local", allowExternalLocalClients: true }; + expect(isWebSocketOriginAllowed({}, unsafeTrust, [], "192.168.1.30")).toBe(true); + }); +}); + +describe("local loopback boundary", () => { + const localTrust: TrustConfig = { mode: "local" }; + const upgrade = (headers: http.IncomingHttpHeaders, remoteAddress: string, trust = localTrust) => { + const done = vi.fn(); + createWebSocketAuthorizationVerifier(trust, ["http://localhost:3001"])( + { + origin: headers.origin ?? "", + secure: false, + req: { headers, socket: { remoteAddress } } as unknown as http.IncomingMessage, + }, + done, + ); + return done; + }; + + it("accepts localhost and numeric loopback Host headers with any port", () => { + for (const host of ["localhost", "localhost:3000", "LOCALHOST:3001", "127.0.0.1:3000", "127.1.2.3", "[::1]:3000", "localhost:80"]) { + expect(isLoopbackHostHeader(host), host).toBe(true); + } + }); + + it("rejects LAN, rebinding and malformed Host headers", () => { + for (const host of [undefined, "", "192.168.1.20:3001", "10.0.0.12", "evil.example:3000", "localhost.evil.example", "127.0.0.1.nip.io:3000", "[::ffff:192.168.1.20]:3000", "localhost@evil.example", "localhost/x", "localhost:3000 x"]) { + expect(isLoopbackHostHeader(host), String(host)).toBe(false); + } + }); + + it("rejects a LAN client that reaches the dev proxy by the machine's LAN address", () => { + // Before the fix: Origin equal to Host was accepted in local mode, so a LAN + // browser at http://192.168.1.20:3001 could start native sketch execution. + const done = upgrade({ host: "192.168.1.20:3001", origin: "http://192.168.1.20:3001" }, "127.0.0.1"); + expect(done).toHaveBeenCalledWith(false, 403, "Forbidden host"); + }); + + it("rejects an originless LAN client", () => { + const done = upgrade({ host: "localhost:3000" }, "192.168.1.30"); + expect(done).toHaveBeenCalledWith(false, 403, "Forbidden origin"); + }); + + it("rejects a DNS rebinding page whose own host name now resolves to loopback", () => { + const done = upgrade({ host: "evil.example:3000", origin: "http://evil.example:3000" }, "127.0.0.1"); + expect(done).toHaveBeenCalledWith(false, 403, "Forbidden host"); + }); + + it("accepts the local browser and originless local tools", () => { + expect(upgrade({ host: "localhost:3001", origin: "http://localhost:3001" }, "127.0.0.1")) + .toHaveBeenCalledWith(true, undefined, undefined, expect.anything()); + expect(upgrade({ host: "127.0.0.1:3000" }, "127.0.0.1")) + .toHaveBeenCalledWith(true, undefined, undefined, expect.anything()); + }); + + it("does not restrict gateway mode or the explicit unsafe local opt-in", () => { + const lanRequest = { headers: { host: "192.168.1.20:3000" } } as http.IncomingMessage; + expect(isLocalClientAllowed(lanRequest, gatewayTrust)).toBe(true); + expect(isLocalClientAllowed(lanRequest, { mode: "local", allowExternalLocalClients: true })).toBe(true); + expect(isLocalClientAllowed(lanRequest, localTrust)).toBe(false); + }); + + it("refuses HTTP requests for LAN and rebinding hosts before any route", async () => { + const app = express(); + app.use(createLocalHostGuard(localTrust)); + app.get("/api/health", (_req, res) => res.json({ status: "ok" })); + const server = await new Promise((resolve) => { + const listeningServer = app.listen(0, "127.0.0.1", () => resolve(listeningServer)); + }); + servers.push(server); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing port"); + const statusFor = (host: string) => new Promise((resolve, reject) => { + http.get({ host: "127.0.0.1", port: address.port, path: "/api/health", headers: { Host: host } }, (res) => { + res.resume(); + resolve(res.statusCode ?? 0); + }).on("error", reject); + }); + + expect(await statusFor(`localhost:${address.port}`)).toBe(200); + expect(await statusFor(`127.0.0.1:${address.port}`)).toBe(200); + expect(await statusFor(`192.168.1.20:${address.port}`)).toBe(403); + expect(await statusFor(`evil.example:${address.port}`)).toBe(403); + }); }); diff --git a/tests/server/vite-config.test.ts b/tests/server/vite-config.test.ts index 0e278ee4b..dd709707e 100644 --- a/tests/server/vite-config.test.ts +++ b/tests/server/vite-config.test.ts @@ -9,4 +9,9 @@ describe("Vite development server configuration", () => { expect(config.server?.port).toBe(3001); expect(config.server).not.toHaveProperty("hmr"); }); + + it("listens on loopback only, because it proxies the native local backend", () => { + // `host: true` bound every interface and let LAN clients reach /api and /ws. + expect((viteConfig as UserConfig).server?.host).toBe("127.0.0.1"); + }); }); diff --git a/vite.config.ts b/vite.config.ts index 1ab8c7690..9ce96d713 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -65,7 +65,8 @@ export default defineConfig({ }, }, server: { - host: true, + // Loopback only: the proxied local backend runs sketches as native processes. + host: "127.0.0.1", port: 3001, // Vite devserver Port headers: { "Content-Security-Policy": getAllowedFrameAncestors(),