From d69963f89cc771af00b1e9f376a1a9085d9eaed1 Mon Sep 17 00:00:00 2001 From: Guillaume ARM Date: Thu, 18 Jun 2026 21:02:32 +0200 Subject: [PATCH] fix(server): harden public gateway auth --- docs/public-ports.md | 8 ++++++++ just/install.just | 5 +++-- just/server.just | 1 + tools/trapos-server/src/auth.ts | 19 ++++++++++++++++++- tools/trapos-server/src/index.ts | 23 +++++++++++++++++++++++ tools/trapos-server/test/auth.test.ts | 6 ++++++ 6 files changed, 59 insertions(+), 3 deletions(-) diff --git a/docs/public-ports.md b/docs/public-ports.md index a0f5949..943419f 100644 --- a/docs/public-ports.md +++ b/docs/public-ports.md @@ -15,3 +15,11 @@ TrapOS public production services use the `4242-4244` TCP range. Keep local deve - Public/production opencode: `http://:4242`. Production services exposed on public ports should use the normal deployment controls for the host: authentication where supported, firewall rules, and TLS or a reverse proxy when crossing untrusted networks. + +## Securing the gateway (port 4444) + +The gateway authenticates each connection with a shared secret (`TRAPOS_SERVER_PASSWORD`) sent in the WebSocket `hello` frame. The server itself speaks **plain `ws://`** — it does not terminate TLS. Therefore: + +- **Always front port 4444 with TLS (`wss://`)** — a TLS-terminating reverse proxy (nginx/Caddy/Traefik) or a tunnel/VPN. Exposing `ws://` directly to the internet sends the secret in cleartext and lets an MITM defeat authentication entirely. +- Keep `TRAPOS_SERVER_PASSWORD` a high-entropy random value. `just install` generates `openssl rand -hex 32` (256-bit); do not replace it with a weak/guessable string (the gateway has no brute-force rate limiting of its own — rely on the secret's entropy and the fronting proxy). +- The MCP endpoint (`exec-lua` / `write-file`, default `127.0.0.1:4445`) is **unauthenticated** and must stay loopback-bound. The server refuses to start if `MCP_HOST` is non-loopback unless `MCP_ALLOW_PUBLIC=1` is set — only do that behind your own authenticated proxy. diff --git a/just/install.just b/just/install.just index 892bfb5..e0bf017 100644 --- a/just/install.just +++ b/just/install.just @@ -29,11 +29,12 @@ generate-env: if [ -f .env ]; then exit 0 fi - password="$(openssl rand -hex 32)" while IFS= read -r line || [ -n "$line" ]; do case "$line" in OPENCODE_SERVER_PASSWORD=*|TRAPOS_SERVER_PASSWORD=*) - printf '%s\n' "${line%%=*}=$password" + # A fresh secret per service: reusing one across both trust domains + # would widen the blast radius if either leaks. + printf '%s\n' "${line%%=*}=$(openssl rand -hex 32)" ;; *) printf '%s\n' "$line" diff --git a/just/server.just b/just/server.just index 0e91f18..8a3f361 100644 --- a/just/server.just +++ b/just/server.just @@ -2,6 +2,7 @@ # repo-local .env secrets. Mirrors `opencode-serve`. Both TRAPOS_SERVER_PASSWORD and # OPENCODE_SERVER_PASSWORD are required: an empty one would run the gateway # unauthenticated, so a missing secret is a fatal error (see also the guard in index.ts). +# This serves plain ws:// — front it with TLS/wss in production (see docs/public-ports.md). [positional-arguments] serve *args: #!/usr/bin/env bash diff --git a/tools/trapos-server/src/auth.ts b/tools/trapos-server/src/auth.ts index d229cc9..0844091 100644 --- a/tools/trapos-server/src/auth.ts +++ b/tools/trapos-server/src/auth.ts @@ -1,3 +1,5 @@ +import { timingSafeEqual } from "node:crypto"; + export type NetworkId = "global"; // The single network today. Centralized so the gateway and the MCP server agree on @@ -15,5 +17,20 @@ export function resolveNetwork( if (password === undefined || password === "") { return GLOBAL_NETWORK_ID; } - return secret === password ? GLOBAL_NETWORK_ID : null; + if (secret === undefined) { + return null; + } + return constantTimeEqual(secret, password) ? GLOBAL_NETWORK_ID : null; +} + +// Length-guarded constant-time compare. timingSafeEqual throws on unequal-length +// buffers, so the length check is unavoidable and itself leaks only the secret's +// byte length (not its content) — acceptable for a 256-bit random shared secret. +function constantTimeEqual(a: string, b: string): boolean { + const bufA = Buffer.from(a, "utf8"); + const bufB = Buffer.from(b, "utf8"); + if (bufA.length !== bufB.length) { + return false; + } + return timingSafeEqual(bufA, bufB); } diff --git a/tools/trapos-server/src/index.ts b/tools/trapos-server/src/index.ts index 5da43bb..ca758bf 100644 --- a/tools/trapos-server/src/index.ts +++ b/tools/trapos-server/src/index.ts @@ -22,6 +22,8 @@ const config: Config = { logger: buildLogger(), }; +guardMcpBind(config.mcpEnabled, config.mcpHost); + const { app, mcpApp } = await createApp(config); try { @@ -72,6 +74,27 @@ function requireSecret(name: string, value: string | undefined): string { return secret; } +// The MCP endpoint (exec-lua / write-file) has no auth of its own — its only trust +// boundary is the loopback binding. A non-loopback MCP_HOST exposes those tools to the +// network unauthenticated, so refuse to start unless the operator explicitly opts in +// with MCP_ALLOW_PUBLIC=1 (e.g. when fronting it with their own authenticated proxy). +function guardMcpBind(enabled: boolean, host: string): void { + if (!enabled || isLoopbackHost(host) || process.env.MCP_ALLOW_PUBLIC === "1") { + return; + } + process.stderr.write( + `\n[trapos-server] MCP_HOST is "${host}", not a loopback address. The MCP endpoint ` + + `(exec-lua / write-file) is UNAUTHENTICATED and would be exposed to the network. ` + + `Set MCP_HOST=127.0.0.1, disable it with MCP_ENABLED=0, or — if it sits behind your ` + + `own authenticated proxy — set MCP_ALLOW_PUBLIC=1. Refusing to start.\n\n`, + ); + process.exit(1); +} + +function isLoopbackHost(host: string): boolean { + return host === "127.0.0.1" || host === "::1" || host === "localhost" || host.startsWith("127."); +} + function readPort(value: string | undefined, fallback: number): number { return readPositiveInt(value, fallback); } diff --git a/tools/trapos-server/test/auth.test.ts b/tools/trapos-server/test/auth.test.ts index a58322e..84fe9f5 100644 --- a/tools/trapos-server/test/auth.test.ts +++ b/tools/trapos-server/test/auth.test.ts @@ -20,3 +20,9 @@ test("password set: mismatch -> null", () => { test("password set: missing secret -> null", () => { assert.equal(resolveNetwork(undefined, "s3cr3t"), null); }); + +test("password set: length-mismatched secret -> null (constant-time compare)", () => { + assert.equal(resolveNetwork("s3cr3", "s3cr3t"), null); + assert.equal(resolveNetwork("s3cr3tt", "s3cr3t"), null); + assert.equal(resolveNetwork("", "s3cr3t"), null); +});