mirror of
https://github.com/grp06/openclaw-studio.git
synced 2026-08-14 00:47:51 +00:00
Enforce Studio access token for public binds
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user