Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/INSTALL_LOCAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 9 additions & 4 deletions docs/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 3 additions & 2 deletions server/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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") {
Expand Down
74 changes: 71 additions & 3 deletions server/security/access-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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" };
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}
Expand Down
111 changes: 102 additions & 9 deletions tests/server/security/access-control.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@ import express from "express";
import http from "node:http";
import {
authorizeHeaders,
createLocalHostGuard,
createUserAuthorizationMiddleware,
createWebSocketAuthorizationVerifier,
isLocalClientAllowed,
isLoopbackHostHeader,
isWebSocketOriginAllowed,
parseTrustConfig,
type TrustConfig,
Expand Down Expand Up @@ -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" },
Expand Down Expand Up @@ -316,23 +325,20 @@ 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" },
localTrust,
["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" },
Expand All @@ -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<http.Server>((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<number>((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);
});
});
5 changes: 5 additions & 0 deletions tests/server/vite-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
3 changes: 2 additions & 1 deletion vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Loading