Merge branch 'codex/public-bind-access-token-guard'

This commit is contained in:
George Pickett
2026-02-25 10:18:56 -08:00
5 changed files with 141 additions and 11 deletions
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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.
+6 -7
View File
@@ -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,
+78
View File
@@ -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,
};
+53
View File
@@ -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();
});
});