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`.
|
- 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.
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user