fix(server): harden public gateway auth

This commit is contained in:
Guillaume ARM 2026-06-18 21:02:32 +02:00
parent 364df77368
commit d69963f89c
6 changed files with 59 additions and 3 deletions

View File

@ -15,3 +15,11 @@ TrapOS public production services use the `4242-4244` TCP range. Keep local deve
- Public/production opencode: `http://<public-host>:4242`. - Public/production opencode: `http://<public-host>: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. 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.

View File

@ -29,11 +29,12 @@ generate-env:
if [ -f .env ]; then if [ -f .env ]; then
exit 0 exit 0
fi fi
password="$(openssl rand -hex 32)"
while IFS= read -r line || [ -n "$line" ]; do while IFS= read -r line || [ -n "$line" ]; do
case "$line" in case "$line" in
OPENCODE_SERVER_PASSWORD=*|TRAPOS_SERVER_PASSWORD=*) 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" printf '%s\n' "$line"

View File

@ -2,6 +2,7 @@
# repo-local .env secrets. Mirrors `opencode-serve`. Both TRAPOS_SERVER_PASSWORD and # repo-local .env secrets. Mirrors `opencode-serve`. Both TRAPOS_SERVER_PASSWORD and
# OPENCODE_SERVER_PASSWORD are required: an empty one would run the gateway # 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). # 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] [positional-arguments]
serve *args: serve *args:
#!/usr/bin/env bash #!/usr/bin/env bash

View File

@ -1,3 +1,5 @@
import { timingSafeEqual } from "node:crypto";
export type NetworkId = "global"; export type NetworkId = "global";
// The single network today. Centralized so the gateway and the MCP server agree on // 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 === "") { if (password === undefined || password === "") {
return GLOBAL_NETWORK_ID; 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);
} }

View File

@ -22,6 +22,8 @@ const config: Config = {
logger: buildLogger(), logger: buildLogger(),
}; };
guardMcpBind(config.mcpEnabled, config.mcpHost);
const { app, mcpApp } = await createApp(config); const { app, mcpApp } = await createApp(config);
try { try {
@ -72,6 +74,27 @@ function requireSecret(name: string, value: string | undefined): string {
return secret; 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 { function readPort(value: string | undefined, fallback: number): number {
return readPositiveInt(value, fallback); return readPositiveInt(value, fallback);
} }

View File

@ -20,3 +20,9 @@ test("password set: mismatch -> null", () => {
test("password set: missing secret -> null", () => { test("password set: missing secret -> null", () => {
assert.equal(resolveNetwork(undefined, "s3cr3t"), 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);
});