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(),