fix(server): harden public gateway auth
This commit is contained in:
parent
364df77368
commit
d69963f89c
@ -15,3 +15,11 @@ TrapOS public production services use the `4242-4244` TCP range. Keep local deve
|
||||
- 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.
|
||||
|
||||
## 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.
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user