diff --git a/README.md b/README.md index 4546859..a86feb3 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ This is the “always-on” setup. The easiest secure version is to keep the Gat Notes: - Avoid serving Studio behind `/studio` unless you configure `basePath` and rebuild. -- If Studio is reachable beyond a tailnet, consider setting `STUDIO_ACCESS_TOKEN` (see Configuration below). +- If Studio is reachable beyond loopback, `STUDIO_ACCESS_TOKEN` is required. ## How It Connects (Mental Model) @@ -100,7 +100,7 @@ Paths and key settings: - OpenClaw config: `~/.openclaw/openclaw.json` (or `OPENCLAW_CONFIG_PATH` / `OPENCLAW_STATE_DIR`) - Studio settings: `~/.openclaw/openclaw-studio/settings.json` - Default gateway URL: `ws://localhost:18789` (override via Studio Settings or `NEXT_PUBLIC_GATEWAY_URL`) -- Optional Studio access gate: set `STUDIO_ACCESS_TOKEN` on the Studio server +- `STUDIO_ACCESS_TOKEN`: required when binding Studio to a public host (`HOST=0.0.0.0`, `HOST=::`, or non-loopback hostnames/IPs); optional for loopback-only binds (`127.0.0.1`, `::1`, `localhost`) ## UI guide diff --git a/docs/pi-chat-streaming.md b/docs/pi-chat-streaming.md index aac9329..b2a7ec2 100644 --- a/docs/pi-chat-streaming.md +++ b/docs/pi-chat-streaming.md @@ -175,9 +175,9 @@ There are two layers of retry behavior: - Transport reconnect (after a successful hello): the vendored browser client reconnects the browser->Studio WebSocket with backoff when it closes, and continues emitting events after reconnect. See `src/lib/gateway/openclaw/GatewayBrowserClient.ts`. - Initial connect failure retry: when the initial `connect` handshake fails (for example bad token), `GatewayClient.connect()` tears down the vendored client and returns a rejected promise; `useGatewayConnection()` may schedule a limited re-attempt unless the error code is known non-retryable. See `resolveGatewayAutoRetryDelayMs` in `src/lib/gateway/GatewayClient.ts`. -## Optional Studio Access Gate +## Studio Access Gate -If `STUDIO_ACCESS_TOKEN` is set on the Studio server, Studio enforces a simple access gate: +When Studio is bound to a public host, `STUDIO_ACCESS_TOKEN` is required. For loopback-only binds, it remains optional. When enabled, Studio enforces a simple access gate: - HTTP: blocks `/api/*` routes unless the correct cookie is present; you can set it once via `/?access_token=...`. - WebSocket: blocks `/api/gateway/ws` upgrades unless the cookie is present. diff --git a/server/index.js b/server/index.js index 745fa4c..e830a14 100644 --- a/server/index.js +++ b/server/index.js @@ -3,14 +3,9 @@ const next = require("next"); const { createAccessGate } = require("./access-gate"); const { createGatewayProxy } = require("./gateway-proxy"); +const { assertPublicHostAllowed, resolveHost } = require("./network-policy"); const { loadUpstreamGatewaySettings } = require("./studio-settings"); -const resolveHost = () => { - const fromEnv = process.env.HOST?.trim() || process.env.HOSTNAME?.trim(); - if (fromEnv) return fromEnv; - return "::"; -}; - const resolvePort = () => { const raw = process.env.PORT?.trim() || "3000"; const port = Number(raw); @@ -26,8 +21,12 @@ const resolvePathname = (url) => { async function main() { const dev = process.argv.includes("--dev"); - const hostname = resolveHost(); + const hostname = resolveHost(process.env); const port = resolvePort(); + assertPublicHostAllowed({ + host: hostname, + studioAccessToken: process.env.STUDIO_ACCESS_TOKEN, + }); const app = next({ dev, diff --git a/server/network-policy.js b/server/network-policy.js new file mode 100644 index 0000000..c486c54 --- /dev/null +++ b/server/network-policy.js @@ -0,0 +1,78 @@ +const net = require("node:net"); + +const normalizeHost = (host) => { + let raw = String(host ?? "").trim().toLowerCase(); + if (!raw) return ""; + + if (raw.startsWith("[")) { + const end = raw.indexOf("]"); + if (end !== -1) { + return raw.slice(1, end).trim(); + } + } + + const colonCount = (raw.match(/:/g) || []).length; + if (colonCount === 1) { + const idx = raw.lastIndexOf(":"); + const maybePort = raw.slice(idx + 1); + if (/^\d+$/.test(maybePort)) { + raw = raw.slice(0, idx); + } + } + + return raw; +}; + +const resolveHost = (env = process.env) => { + const host = String(env.HOST ?? "").trim(); + if (host) return host; + return "127.0.0.1"; +}; + +const isIpv4Loopback = (value) => value.startsWith("127."); + +const isIpv6Loopback = (value) => { + if (value === "::1" || value === "0:0:0:0:0:0:0:1") return true; + if (!value.startsWith("::ffff:")) return false; + const mapped = value.slice("::ffff:".length); + return net.isIP(mapped) === 4 && isIpv4Loopback(mapped); +}; + +const isPublicHost = (host) => { + const normalized = normalizeHost(host); + if (!normalized) return false; + + if (normalized === "localhost") return false; + if (normalized === "0.0.0.0" || normalized === "::") { + return true; + } + + const ipVersion = net.isIP(normalized); + if (ipVersion === 4) { + return !isIpv4Loopback(normalized); + } + if (ipVersion === 6) { + return !isIpv6Loopback(normalized); + } + + return true; +}; + +const assertPublicHostAllowed = ({ host, studioAccessToken }) => { + if (!isPublicHost(host)) return; + + const token = String(studioAccessToken ?? "").trim(); + if (token) return; + + const normalized = normalizeHost(host) || String(host ?? "").trim() || "(unknown)"; + throw new Error( + `Refusing to bind Studio to public host "${normalized}" without STUDIO_ACCESS_TOKEN. ` + + "Set STUDIO_ACCESS_TOKEN or bind HOST to 127.0.0.1/::1/localhost." + ); +}; + +module.exports = { + resolveHost, + isPublicHost, + assertPublicHostAllowed, +}; diff --git a/tests/unit/serverNetworkPolicy.test.ts b/tests/unit/serverNetworkPolicy.test.ts new file mode 100644 index 0000000..67a8a31 --- /dev/null +++ b/tests/unit/serverNetworkPolicy.test.ts @@ -0,0 +1,53 @@ +// @vitest-environment node + +import { describe, expect, it } from "vitest"; + +describe("server network policy", () => { + it("defaults to loopback host", async () => { + const { resolveHost } = await import("../../server/network-policy"); + expect(resolveHost({} as NodeJS.ProcessEnv)).toBe("127.0.0.1"); + }); + + it("ignores HOSTNAME and uses only HOST for bind resolution", async () => { + const { resolveHost } = await import("../../server/network-policy"); + expect(resolveHost({ HOSTNAME: "example-host" } as NodeJS.ProcessEnv)).toBe("127.0.0.1"); + expect(resolveHost({ HOST: "0.0.0.0", HOSTNAME: "example-host" } as NodeJS.ProcessEnv)).toBe( + "0.0.0.0" + ); + }); + + it("classifies wildcard and non-loopback hosts as public", async () => { + const { isPublicHost } = await import("../../server/network-policy"); + expect(isPublicHost("0.0.0.0")).toBe(true); + expect(isPublicHost("::")).toBe(true); + expect(isPublicHost("studio.example.com")).toBe(true); + }); + + it("classifies loopback hosts as non-public", async () => { + const { isPublicHost } = await import("../../server/network-policy"); + expect(isPublicHost("127.0.0.1")).toBe(false); + expect(isPublicHost("::1")).toBe(false); + expect(isPublicHost("0:0:0:0:0:0:0:1")).toBe(false); + expect(isPublicHost("::ffff:127.0.0.1")).toBe(false); + expect(isPublicHost("[::1]:3000")).toBe(false); + expect(isPublicHost("localhost")).toBe(false); + }); + + it("classifies non-loopback IPv6 addresses as public", async () => { + const { isPublicHost } = await import("../../server/network-policy"); + expect(isPublicHost("::ffff:192.168.1.10")).toBe(true); + }); + + it("rejects public bind without non-empty studio access token", async () => { + const { assertPublicHostAllowed } = await import("../../server/network-policy"); + expect(() => assertPublicHostAllowed({ host: "0.0.0.0", studioAccessToken: "" })).toThrow( + /Refusing to bind Studio to public host/ + ); + expect(() => assertPublicHostAllowed({ host: "0.0.0.0", studioAccessToken: " " })).toThrow( + /Refusing to bind Studio to public host/ + ); + expect(() => + assertPublicHostAllowed({ host: "0.0.0.0", studioAccessToken: "abc" }) + ).not.toThrow(); + }); +});