mirror of
https://github.com/clawdbot/clawdbot.git
synced 2026-01-31 19:37:45 +01:00
fix: require gateway auth by default
This commit is contained in:
@@ -37,6 +37,7 @@ Status: unreleased.
|
|||||||
### Fixes
|
### Fixes
|
||||||
- Security: harden Tailscale Serve auth by validating identity via local tailscaled before trusting headers.
|
- Security: harden Tailscale Serve auth by validating identity via local tailscaled before trusting headers.
|
||||||
- Web UI: improve WebChat image paste previews and allow image-only sends. (#1925) Thanks @smartprogrammer93.
|
- Web UI: improve WebChat image paste previews and allow image-only sends. (#1925) Thanks @smartprogrammer93.
|
||||||
|
- Gateway: default auth now fail-closed (token/password required; Tailscale Serve identity remains allowed).
|
||||||
|
|
||||||
## 2026.1.24-3
|
## 2026.1.24-3
|
||||||
|
|
||||||
|
|||||||
@@ -2867,12 +2867,12 @@ Notes:
|
|||||||
- `gateway.port` controls the single multiplexed port used for WebSocket + HTTP (control UI, hooks, A2UI).
|
- `gateway.port` controls the single multiplexed port used for WebSocket + HTTP (control UI, hooks, A2UI).
|
||||||
- OpenAI Chat Completions endpoint: **disabled by default**; enable with `gateway.http.endpoints.chatCompletions.enabled: true`.
|
- OpenAI Chat Completions endpoint: **disabled by default**; enable with `gateway.http.endpoints.chatCompletions.enabled: true`.
|
||||||
- Precedence: `--port` > `CLAWDBOT_GATEWAY_PORT` > `gateway.port` > default `18789`.
|
- Precedence: `--port` > `CLAWDBOT_GATEWAY_PORT` > `gateway.port` > default `18789`.
|
||||||
- Non-loopback binds (`lan`/`tailnet`/`auto`) require auth. Use `gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`).
|
- Gateway auth is required by default (token/password or Tailscale Serve identity). Non-loopback binds require a shared token/password.
|
||||||
- The onboarding wizard generates a gateway token by default (even on loopback).
|
- The onboarding wizard generates a gateway token by default (even on loopback).
|
||||||
- `gateway.remote.token` is **only** for remote CLI calls; it does not enable local gateway auth. `gateway.token` is ignored.
|
- `gateway.remote.token` is **only** for remote CLI calls; it does not enable local gateway auth. `gateway.token` is ignored.
|
||||||
|
|
||||||
Auth and Tailscale:
|
Auth and Tailscale:
|
||||||
- `gateway.auth.mode` sets the handshake requirements (`token` or `password`).
|
- `gateway.auth.mode` sets the handshake requirements (`token` or `password`). When unset, token auth is assumed.
|
||||||
- `gateway.auth.token` stores the shared token for token auth (used by the CLI on the same machine).
|
- `gateway.auth.token` stores the shared token for token auth (used by the CLI on the same machine).
|
||||||
- When `gateway.auth.mode` is set, only that method is accepted (plus optional Tailscale headers).
|
- When `gateway.auth.mode` is set, only that method is accepted (plus optional Tailscale headers).
|
||||||
- `gateway.auth.password` can be set here, or via `CLAWDBOT_GATEWAY_PASSWORD` (recommended).
|
- `gateway.auth.password` can be set here, or via `CLAWDBOT_GATEWAY_PASSWORD` (recommended).
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ pnpm gateway:watch
|
|||||||
- `--force` uses `lsof` to find listeners on the chosen port, sends SIGTERM, logs what it killed, then starts the gateway (fails fast if `lsof` is missing).
|
- `--force` uses `lsof` to find listeners on the chosen port, sends SIGTERM, logs what it killed, then starts the gateway (fails fast if `lsof` is missing).
|
||||||
- If you run under a supervisor (launchd/systemd/mac app child-process mode), a stop/restart typically sends **SIGTERM**; older builds may surface this as `pnpm` `ELIFECYCLE` exit code **143** (SIGTERM), which is a normal shutdown, not a crash.
|
- If you run under a supervisor (launchd/systemd/mac app child-process mode), a stop/restart typically sends **SIGTERM**; older builds may surface this as `pnpm` `ELIFECYCLE` exit code **143** (SIGTERM), which is a normal shutdown, not a crash.
|
||||||
- **SIGUSR1** triggers an in-process restart when authorized (gateway tool/config apply/update, or enable `commands.restart` for manual restarts).
|
- **SIGUSR1** triggers an in-process restart when authorized (gateway tool/config apply/update, or enable `commands.restart` for manual restarts).
|
||||||
- Gateway auth: set `gateway.auth.mode=token` + `gateway.auth.token` (or pass `--token <value>` / `CLAWDBOT_GATEWAY_TOKEN`) to require clients to send `connect.params.auth.token`.
|
- Gateway auth is required by default: set `gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`) or `gateway.auth.password`. Clients must send `connect.params.auth.token/password` unless using Tailscale Serve identity.
|
||||||
- The wizard now generates a token by default, even on loopback.
|
- The wizard now generates a token by default, even on loopback.
|
||||||
- Port precedence: `--port` > `CLAWDBOT_GATEWAY_PORT` > `gateway.port` > default `18789`.
|
- Port precedence: `--port` > `CLAWDBOT_GATEWAY_PORT` > `gateway.port` > default `18789`.
|
||||||
|
|
||||||
|
|||||||
@@ -280,7 +280,7 @@ The Gateway multiplexes **WebSocket + HTTP** on a single port:
|
|||||||
|
|
||||||
Bind mode controls where the Gateway listens:
|
Bind mode controls where the Gateway listens:
|
||||||
- `gateway.bind: "loopback"` (default): only local clients can connect.
|
- `gateway.bind: "loopback"` (default): only local clients can connect.
|
||||||
- Non-loopback binds (`"lan"`, `"tailnet"`, `"custom"`) expand the attack surface. Only use them with `gateway.auth` enabled and a real firewall.
|
- Non-loopback binds (`"lan"`, `"tailnet"`, `"custom"`) expand the attack surface. Only use them with a shared token/password and a real firewall.
|
||||||
|
|
||||||
Rules of thumb:
|
Rules of thumb:
|
||||||
- Prefer Tailscale Serve over LAN binds (Serve keeps the Gateway on loopback, and Tailscale handles access).
|
- Prefer Tailscale Serve over LAN binds (Serve keeps the Gateway on loopback, and Tailscale handles access).
|
||||||
@@ -289,13 +289,11 @@ Rules of thumb:
|
|||||||
|
|
||||||
### 0.5) Lock down the Gateway WebSocket (local auth)
|
### 0.5) Lock down the Gateway WebSocket (local auth)
|
||||||
|
|
||||||
Gateway auth is **only** enforced when you set `gateway.auth`. If it’s unset,
|
Gateway auth is **required by default**. If no token/password is configured,
|
||||||
loopback WS clients are unauthenticated — any local process can connect and call
|
the Gateway refuses WebSocket connections (fail‑closed).
|
||||||
`config.apply`.
|
|
||||||
|
|
||||||
The onboarding wizard now generates a token by default (even for loopback) so
|
The onboarding wizard generates a token by default (even for loopback) so
|
||||||
local clients must authenticate. If you skip the wizard or remove auth, you’re
|
local clients must authenticate.
|
||||||
back to open loopback.
|
|
||||||
|
|
||||||
Set a token so **all** WS clients must authenticate:
|
Set a token so **all** WS clients must authenticate:
|
||||||
|
|
||||||
|
|||||||
@@ -91,7 +91,8 @@ Open:
|
|||||||
|
|
||||||
## Security notes
|
## Security notes
|
||||||
|
|
||||||
- Binding the Gateway to a non-loopback address **requires** auth (`gateway.auth` or `CLAWDBOT_GATEWAY_TOKEN`).
|
- Gateway auth is required by default (token/password or Tailscale identity headers).
|
||||||
|
- Non-loopback binds still **require** a shared token/password (`gateway.auth` or env).
|
||||||
- The wizard generates a gateway token by default (even on loopback).
|
- The wizard generates a gateway token by default (even on loopback).
|
||||||
- The UI sends `connect.params.auth.token` or `connect.params.auth.password`.
|
- The UI sends `connect.params.auth.token` or `connect.params.auth.password`.
|
||||||
- With Serve, Tailscale identity headers can satisfy auth when
|
- With Serve, Tailscale identity headers can satisfy auth when
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Status: the macOS/iOS SwiftUI chat UI talks directly to the Gateway WebSocket.
|
|||||||
## Quick start
|
## Quick start
|
||||||
1) Start the gateway.
|
1) Start the gateway.
|
||||||
2) Open the WebChat UI (macOS/iOS app) or the Control UI chat tab.
|
2) Open the WebChat UI (macOS/iOS app) or the Control UI chat tab.
|
||||||
3) Ensure gateway auth is configured if you are not on loopback.
|
3) Ensure gateway auth is configured (required by default, even on loopback).
|
||||||
|
|
||||||
## How it works (behavior)
|
## How it works (behavior)
|
||||||
- The UI connects to the Gateway WebSocket and uses `chat.history`, `chat.send`, and `chat.inject`.
|
- The UI connects to the Gateway WebSocket and uses `chat.history`, `chat.send`, and `chat.inject`.
|
||||||
|
|||||||
@@ -249,7 +249,7 @@ describe("gateway-cli coverage", () => {
|
|||||||
programInvalidPort.exitOverride();
|
programInvalidPort.exitOverride();
|
||||||
registerGatewayCli(programInvalidPort);
|
registerGatewayCli(programInvalidPort);
|
||||||
await expect(
|
await expect(
|
||||||
programInvalidPort.parseAsync(["gateway", "--port", "0"], {
|
programInvalidPort.parseAsync(["gateway", "--port", "0", "--token", "test-token"], {
|
||||||
from: "user",
|
from: "user",
|
||||||
}),
|
}),
|
||||||
).rejects.toThrow("__exit__:1");
|
).rejects.toThrow("__exit__:1");
|
||||||
@@ -263,7 +263,7 @@ describe("gateway-cli coverage", () => {
|
|||||||
registerGatewayCli(programForceFail);
|
registerGatewayCli(programForceFail);
|
||||||
await expect(
|
await expect(
|
||||||
programForceFail.parseAsync(
|
programForceFail.parseAsync(
|
||||||
["gateway", "--port", "18789", "--force", "--allow-unconfigured"],
|
["gateway", "--port", "18789", "--token", "test-token", "--force", "--allow-unconfigured"],
|
||||||
{ from: "user" },
|
{ from: "user" },
|
||||||
),
|
),
|
||||||
).rejects.toThrow("__exit__:1");
|
).rejects.toThrow("__exit__:1");
|
||||||
@@ -276,9 +276,12 @@ describe("gateway-cli coverage", () => {
|
|||||||
const beforeSigterm = new Set(process.listeners("SIGTERM"));
|
const beforeSigterm = new Set(process.listeners("SIGTERM"));
|
||||||
const beforeSigint = new Set(process.listeners("SIGINT"));
|
const beforeSigint = new Set(process.listeners("SIGINT"));
|
||||||
await expect(
|
await expect(
|
||||||
programStartFail.parseAsync(["gateway", "--port", "18789", "--allow-unconfigured"], {
|
programStartFail.parseAsync(
|
||||||
from: "user",
|
["gateway", "--port", "18789", "--token", "test-token", "--allow-unconfigured"],
|
||||||
}),
|
{
|
||||||
|
from: "user",
|
||||||
|
},
|
||||||
|
),
|
||||||
).rejects.toThrow("__exit__:1");
|
).rejects.toThrow("__exit__:1");
|
||||||
for (const listener of process.listeners("SIGTERM")) {
|
for (const listener of process.listeners("SIGTERM")) {
|
||||||
if (!beforeSigterm.has(listener)) process.removeListener("SIGTERM", listener);
|
if (!beforeSigterm.has(listener)) process.removeListener("SIGTERM", listener);
|
||||||
@@ -304,7 +307,7 @@ describe("gateway-cli coverage", () => {
|
|||||||
registerGatewayCli(program);
|
registerGatewayCli(program);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
program.parseAsync(["gateway", "--allow-unconfigured"], {
|
program.parseAsync(["gateway", "--token", "test-token", "--allow-unconfigured"], {
|
||||||
from: "user",
|
from: "user",
|
||||||
}),
|
}),
|
||||||
).rejects.toThrow("__exit__:1");
|
).rejects.toThrow("__exit__:1");
|
||||||
@@ -327,7 +330,7 @@ describe("gateway-cli coverage", () => {
|
|||||||
|
|
||||||
startGatewayServer.mockRejectedValueOnce(new Error("nope"));
|
startGatewayServer.mockRejectedValueOnce(new Error("nope"));
|
||||||
await expect(
|
await expect(
|
||||||
program.parseAsync(["gateway", "--allow-unconfigured"], {
|
program.parseAsync(["gateway", "--token", "test-token", "--allow-unconfigured"], {
|
||||||
from: "user",
|
from: "user",
|
||||||
}),
|
}),
|
||||||
).rejects.toThrow("__exit__:1");
|
).rejects.toThrow("__exit__:1");
|
||||||
|
|||||||
@@ -203,6 +203,10 @@ async function runGatewayCommand(opts: GatewayRunOpts) {
|
|||||||
const resolvedAuthMode = resolvedAuth.mode;
|
const resolvedAuthMode = resolvedAuth.mode;
|
||||||
const tokenValue = resolvedAuth.token;
|
const tokenValue = resolvedAuth.token;
|
||||||
const passwordValue = resolvedAuth.password;
|
const passwordValue = resolvedAuth.password;
|
||||||
|
const hasToken = typeof tokenValue === "string" && tokenValue.trim().length > 0;
|
||||||
|
const hasPassword = typeof passwordValue === "string" && passwordValue.trim().length > 0;
|
||||||
|
const hasSharedSecret =
|
||||||
|
(resolvedAuthMode === "token" && hasToken) || (resolvedAuthMode === "password" && hasPassword);
|
||||||
const authHints: string[] = [];
|
const authHints: string[] = [];
|
||||||
if (miskeys.hasGatewayToken) {
|
if (miskeys.hasGatewayToken) {
|
||||||
authHints.push('Found "gateway.token" in config. Use "gateway.auth.token" instead.');
|
authHints.push('Found "gateway.token" in config. Use "gateway.auth.token" instead.');
|
||||||
@@ -212,7 +216,7 @@ async function runGatewayCommand(opts: GatewayRunOpts) {
|
|||||||
'"gateway.remote.token" is for remote CLI calls; it does not enable local gateway auth.',
|
'"gateway.remote.token" is for remote CLI calls; it does not enable local gateway auth.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (resolvedAuthMode === "token" && !tokenValue) {
|
if (resolvedAuthMode === "token" && !hasToken && !resolvedAuth.allowTailscale) {
|
||||||
defaultRuntime.error(
|
defaultRuntime.error(
|
||||||
[
|
[
|
||||||
"Gateway auth is set to token, but no token is configured.",
|
"Gateway auth is set to token, but no token is configured.",
|
||||||
@@ -225,7 +229,7 @@ async function runGatewayCommand(opts: GatewayRunOpts) {
|
|||||||
defaultRuntime.exit(1);
|
defaultRuntime.exit(1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (resolvedAuthMode === "password" && !passwordValue) {
|
if (resolvedAuthMode === "password" && !hasPassword) {
|
||||||
defaultRuntime.error(
|
defaultRuntime.error(
|
||||||
[
|
[
|
||||||
"Gateway auth is set to password, but no password is configured.",
|
"Gateway auth is set to password, but no password is configured.",
|
||||||
@@ -238,11 +242,11 @@ async function runGatewayCommand(opts: GatewayRunOpts) {
|
|||||||
defaultRuntime.exit(1);
|
defaultRuntime.exit(1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (bind !== "loopback" && resolvedAuthMode === "none") {
|
if (bind !== "loopback" && !hasSharedSecret) {
|
||||||
defaultRuntime.error(
|
defaultRuntime.error(
|
||||||
[
|
[
|
||||||
`Refusing to bind gateway to ${bind} without auth.`,
|
`Refusing to bind gateway to ${bind} without auth.`,
|
||||||
"Set gateway.auth.token (or CLAWDBOT_GATEWAY_TOKEN) or pass --token.",
|
"Set gateway.auth.token/password (or CLAWDBOT_GATEWAY_TOKEN/CLAWDBOT_GATEWAY_PASSWORD) or pass --token/--password.",
|
||||||
...authHints,
|
...authHints,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
|
|||||||
@@ -369,7 +369,8 @@ const FIELD_HELP: Record<string, string> = {
|
|||||||
"gateway.remote.sshIdentity": "Optional SSH identity file path (passed to ssh -i).",
|
"gateway.remote.sshIdentity": "Optional SSH identity file path (passed to ssh -i).",
|
||||||
"agents.list[].identity.avatar":
|
"agents.list[].identity.avatar":
|
||||||
"Avatar image path (relative to the agent workspace only) or a remote URL/data URL.",
|
"Avatar image path (relative to the agent workspace only) or a remote URL/data URL.",
|
||||||
"gateway.auth.token": "Recommended for all gateways; required for non-loopback binds.",
|
"gateway.auth.token":
|
||||||
|
"Required by default for gateway access (unless using Tailscale Serve identity); required for non-loopback binds.",
|
||||||
"gateway.auth.password": "Required for Tailscale funnel.",
|
"gateway.auth.password": "Required for Tailscale funnel.",
|
||||||
"gateway.controlUi.basePath":
|
"gateway.controlUi.basePath":
|
||||||
"Optional URL prefix where the Control UI is served (e.g. /clawdbot).",
|
"Optional URL prefix where the Control UI is served (e.g. /clawdbot).",
|
||||||
|
|||||||
@@ -173,8 +173,7 @@ export function resolveGatewayAuth(params: {
|
|||||||
const env = params.env ?? process.env;
|
const env = params.env ?? process.env;
|
||||||
const token = authConfig.token ?? env.CLAWDBOT_GATEWAY_TOKEN ?? undefined;
|
const token = authConfig.token ?? env.CLAWDBOT_GATEWAY_TOKEN ?? undefined;
|
||||||
const password = authConfig.password ?? env.CLAWDBOT_GATEWAY_PASSWORD ?? undefined;
|
const password = authConfig.password ?? env.CLAWDBOT_GATEWAY_PASSWORD ?? undefined;
|
||||||
const mode: ResolvedGatewayAuth["mode"] =
|
const mode: ResolvedGatewayAuth["mode"] = authConfig.mode ?? (password ? "password" : "token");
|
||||||
authConfig.mode ?? (password ? "password" : token ? "token" : "none");
|
|
||||||
const allowTailscale =
|
const allowTailscale =
|
||||||
authConfig.allowTailscale ?? (params.tailscaleMode === "serve" && mode !== "password");
|
authConfig.allowTailscale ?? (params.tailscaleMode === "serve" && mode !== "password");
|
||||||
return {
|
return {
|
||||||
@@ -187,6 +186,7 @@ export function resolveGatewayAuth(params: {
|
|||||||
|
|
||||||
export function assertGatewayAuthConfigured(auth: ResolvedGatewayAuth): void {
|
export function assertGatewayAuthConfigured(auth: ResolvedGatewayAuth): void {
|
||||||
if (auth.mode === "token" && !auth.token) {
|
if (auth.mode === "token" && !auth.token) {
|
||||||
|
if (auth.allowTailscale) return;
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"gateway auth mode is token, but no token was configured (set gateway.auth.token or CLAWDBOT_GATEWAY_TOKEN)",
|
"gateway auth mode is token, but no token was configured (set gateway.auth.token or CLAWDBOT_GATEWAY_TOKEN)",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -70,6 +70,11 @@ export async function resolveGatewayRuntimeConfig(params: {
|
|||||||
tailscaleMode,
|
tailscaleMode,
|
||||||
});
|
});
|
||||||
const authMode: ResolvedGatewayAuth["mode"] = resolvedAuth.mode;
|
const authMode: ResolvedGatewayAuth["mode"] = resolvedAuth.mode;
|
||||||
|
const hasToken = typeof resolvedAuth.token === "string" && resolvedAuth.token.trim().length > 0;
|
||||||
|
const hasPassword =
|
||||||
|
typeof resolvedAuth.password === "string" && resolvedAuth.password.trim().length > 0;
|
||||||
|
const hasSharedSecret =
|
||||||
|
(authMode === "token" && hasToken) || (authMode === "password" && hasPassword);
|
||||||
const hooksConfig = resolveHooksConfig(params.cfg);
|
const hooksConfig = resolveHooksConfig(params.cfg);
|
||||||
const canvasHostEnabled =
|
const canvasHostEnabled =
|
||||||
process.env.CLAWDBOT_SKIP_CANVAS_HOST !== "1" && params.cfg.canvasHost?.enabled !== false;
|
process.env.CLAWDBOT_SKIP_CANVAS_HOST !== "1" && params.cfg.canvasHost?.enabled !== false;
|
||||||
@@ -83,9 +88,9 @@ export async function resolveGatewayRuntimeConfig(params: {
|
|||||||
if (tailscaleMode !== "off" && !isLoopbackHost(bindHost)) {
|
if (tailscaleMode !== "off" && !isLoopbackHost(bindHost)) {
|
||||||
throw new Error("tailscale serve/funnel requires gateway bind=loopback (127.0.0.1)");
|
throw new Error("tailscale serve/funnel requires gateway bind=loopback (127.0.0.1)");
|
||||||
}
|
}
|
||||||
if (!isLoopbackHost(bindHost) && authMode === "none") {
|
if (!isLoopbackHost(bindHost) && !hasSharedSecret) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`refusing to bind gateway to ${bindHost}:${params.port} without auth (set gateway.auth.token or CLAWDBOT_GATEWAY_TOKEN, or pass --token)`,
|
`refusing to bind gateway to ${bindHost}:${params.port} without auth (set gateway.auth.token/password, or set CLAWDBOT_GATEWAY_TOKEN/CLAWDBOT_GATEWAY_PASSWORD)`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ const openWs = async (port: number) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe("gateway server auth/connect", () => {
|
describe("gateway server auth/connect", () => {
|
||||||
describe("default auth", () => {
|
describe("default auth (token)", () => {
|
||||||
let server: Awaited<ReturnType<typeof startGatewayServer>>;
|
let server: Awaited<ReturnType<typeof startGatewayServer>>;
|
||||||
let port: number;
|
let port: number;
|
||||||
|
|
||||||
@@ -234,6 +234,7 @@ describe("gateway server auth/connect", () => {
|
|||||||
test("returns control ui hint when token is missing", async () => {
|
test("returns control ui hint when token is missing", async () => {
|
||||||
const ws = await openWs(port);
|
const ws = await openWs(port);
|
||||||
const res = await connectReq(ws, {
|
const res = await connectReq(ws, {
|
||||||
|
skipDefaultAuth: true,
|
||||||
client: {
|
client: {
|
||||||
id: GATEWAY_CLIENT_NAMES.CONTROL_UI,
|
id: GATEWAY_CLIENT_NAMES.CONTROL_UI,
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
@@ -352,6 +353,7 @@ describe("gateway server auth/connect", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("rejects proxied connections without auth when proxy headers are untrusted", async () => {
|
test("rejects proxied connections without auth when proxy headers are untrusted", async () => {
|
||||||
|
testState.gatewayAuth = { mode: "none" };
|
||||||
const prevToken = process.env.CLAWDBOT_GATEWAY_TOKEN;
|
const prevToken = process.env.CLAWDBOT_GATEWAY_TOKEN;
|
||||||
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
|
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
|
||||||
const port = await getFreePort();
|
const port = await getFreePort();
|
||||||
@@ -360,7 +362,7 @@ describe("gateway server auth/connect", () => {
|
|||||||
headers: { "x-forwarded-for": "203.0.113.10" },
|
headers: { "x-forwarded-for": "203.0.113.10" },
|
||||||
});
|
});
|
||||||
await new Promise<void>((resolve) => ws.once("open", resolve));
|
await new Promise<void>((resolve) => ws.once("open", resolve));
|
||||||
const res = await connectReq(ws);
|
const res = await connectReq(ws, { skipDefaultAuth: true });
|
||||||
expect(res.ok).toBe(false);
|
expect(res.ok).toBe(false);
|
||||||
expect(res.error?.message ?? "").toContain("gateway auth required");
|
expect(res.error?.message ?? "").toContain("gateway auth required");
|
||||||
ws.close();
|
ws.close();
|
||||||
|
|||||||
@@ -28,11 +28,12 @@ let ws: WebSocket;
|
|||||||
let port: number;
|
let port: number;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const started = await startServerWithClient();
|
const token = "test-gateway-token-1234567890";
|
||||||
|
const started = await startServerWithClient(token);
|
||||||
server = started.server;
|
server = started.server;
|
||||||
ws = started.ws;
|
ws = started.ws;
|
||||||
port = started.port;
|
port = started.port;
|
||||||
await connectOk(ws);
|
await connectOk(ws, { token });
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -60,6 +61,7 @@ describe("late-arriving invoke results", () => {
|
|||||||
mode: GATEWAY_CLIENT_MODES.NODE,
|
mode: GATEWAY_CLIENT_MODES.NODE,
|
||||||
},
|
},
|
||||||
commands: ["canvas.snapshot"],
|
commands: ["canvas.snapshot"],
|
||||||
|
token: "test-gateway-token-1234567890",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Send an invoke result with an unknown ID (simulating late arrival after timeout)
|
// Send an invoke result with an unknown ID (simulating late arrival after timeout)
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ async function resetGatewayTestState(options: { uniqueConfigRoot: boolean }) {
|
|||||||
sessionStoreSaveDelayMs.value = 0;
|
sessionStoreSaveDelayMs.value = 0;
|
||||||
testTailnetIPv4.value = undefined;
|
testTailnetIPv4.value = undefined;
|
||||||
testState.gatewayBind = undefined;
|
testState.gatewayBind = undefined;
|
||||||
testState.gatewayAuth = undefined;
|
testState.gatewayAuth = { mode: "token", token: "test-gateway-token-1234567890" };
|
||||||
testState.gatewayControlUi = undefined;
|
testState.gatewayControlUi = undefined;
|
||||||
testState.hooksConfig = undefined;
|
testState.hooksConfig = undefined;
|
||||||
testState.canvasHostPort = undefined;
|
testState.canvasHostPort = undefined;
|
||||||
@@ -260,10 +260,15 @@ export async function startGatewayServer(port: number, opts?: GatewayServerOptio
|
|||||||
export async function startServerWithClient(token?: string, opts?: GatewayServerOptions) {
|
export async function startServerWithClient(token?: string, opts?: GatewayServerOptions) {
|
||||||
let port = await getFreePort();
|
let port = await getFreePort();
|
||||||
const prev = process.env.CLAWDBOT_GATEWAY_TOKEN;
|
const prev = process.env.CLAWDBOT_GATEWAY_TOKEN;
|
||||||
if (token === undefined) {
|
const fallbackToken =
|
||||||
|
token ??
|
||||||
|
(typeof (testState.gatewayAuth as { token?: unknown } | undefined)?.token === "string"
|
||||||
|
? (testState.gatewayAuth as { token?: string }).token
|
||||||
|
: undefined);
|
||||||
|
if (fallbackToken === undefined) {
|
||||||
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
|
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
|
||||||
} else {
|
} else {
|
||||||
process.env.CLAWDBOT_GATEWAY_TOKEN = token;
|
process.env.CLAWDBOT_GATEWAY_TOKEN = fallbackToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
let server: Awaited<ReturnType<typeof startGatewayServer>> | null = null;
|
let server: Awaited<ReturnType<typeof startGatewayServer>> | null = null;
|
||||||
@@ -299,6 +304,7 @@ export async function connectReq(
|
|||||||
opts?: {
|
opts?: {
|
||||||
token?: string;
|
token?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
|
skipDefaultAuth?: boolean;
|
||||||
minProtocol?: number;
|
minProtocol?: number;
|
||||||
maxProtocol?: number;
|
maxProtocol?: number;
|
||||||
client?: {
|
client?: {
|
||||||
@@ -334,6 +340,20 @@ export async function connectReq(
|
|||||||
mode: GATEWAY_CLIENT_MODES.TEST,
|
mode: GATEWAY_CLIENT_MODES.TEST,
|
||||||
};
|
};
|
||||||
const role = opts?.role ?? "operator";
|
const role = opts?.role ?? "operator";
|
||||||
|
const defaultToken =
|
||||||
|
opts?.skipDefaultAuth === true
|
||||||
|
? undefined
|
||||||
|
: typeof (testState.gatewayAuth as { token?: unknown } | undefined)?.token === "string"
|
||||||
|
? ((testState.gatewayAuth as { token?: string }).token ?? undefined)
|
||||||
|
: process.env.CLAWDBOT_GATEWAY_TOKEN;
|
||||||
|
const defaultPassword =
|
||||||
|
opts?.skipDefaultAuth === true
|
||||||
|
? undefined
|
||||||
|
: typeof (testState.gatewayAuth as { password?: unknown } | undefined)?.password === "string"
|
||||||
|
? ((testState.gatewayAuth as { password?: string }).password ?? undefined)
|
||||||
|
: process.env.CLAWDBOT_GATEWAY_PASSWORD;
|
||||||
|
const token = opts?.token ?? defaultToken;
|
||||||
|
const password = opts?.password ?? defaultPassword;
|
||||||
const requestedScopes = Array.isArray(opts?.scopes) ? opts?.scopes : [];
|
const requestedScopes = Array.isArray(opts?.scopes) ? opts?.scopes : [];
|
||||||
const device = (() => {
|
const device = (() => {
|
||||||
if (opts?.device === null) return undefined;
|
if (opts?.device === null) return undefined;
|
||||||
@@ -347,7 +367,7 @@ export async function connectReq(
|
|||||||
role,
|
role,
|
||||||
scopes: requestedScopes,
|
scopes: requestedScopes,
|
||||||
signedAtMs,
|
signedAtMs,
|
||||||
token: opts?.token ?? null,
|
token: token ?? null,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
id: identity.deviceId,
|
id: identity.deviceId,
|
||||||
@@ -372,10 +392,10 @@ export async function connectReq(
|
|||||||
role,
|
role,
|
||||||
scopes: opts?.scopes,
|
scopes: opts?.scopes,
|
||||||
auth:
|
auth:
|
||||||
opts?.token || opts?.password
|
token || password
|
||||||
? {
|
? {
|
||||||
token: opts?.token,
|
token,
|
||||||
password: opts?.password,
|
password,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
device,
|
device,
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ import { createTestRegistry } from "../test-utils/channel-plugins.js";
|
|||||||
|
|
||||||
installGatewayTestHooks({ scope: "suite" });
|
installGatewayTestHooks({ scope: "suite" });
|
||||||
|
|
||||||
|
const resolveGatewayToken = (): string => {
|
||||||
|
const token = (testState.gatewayAuth as { token?: string } | undefined)?.token;
|
||||||
|
if (!token) throw new Error("test gateway token missing");
|
||||||
|
return token;
|
||||||
|
};
|
||||||
|
|
||||||
describe("POST /tools/invoke", () => {
|
describe("POST /tools/invoke", () => {
|
||||||
it("invokes a tool and returns {ok:true,result}", async () => {
|
it("invokes a tool and returns {ok:true,result}", async () => {
|
||||||
// Allow the sessions_list tool for main agent.
|
// Allow the sessions_list tool for main agent.
|
||||||
@@ -25,10 +31,11 @@ describe("POST /tools/invoke", () => {
|
|||||||
const server = await startGatewayServer(port, {
|
const server = await startGatewayServer(port, {
|
||||||
bind: "loopback",
|
bind: "loopback",
|
||||||
});
|
});
|
||||||
|
const token = resolveGatewayToken();
|
||||||
|
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, {
|
const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
||||||
body: JSON.stringify({ tool: "sessions_list", action: "json", args: {}, sessionKey: "main" }),
|
body: JSON.stringify({ tool: "sessions_list", action: "json", args: {}, sessionKey: "main" }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -105,9 +112,10 @@ describe("POST /tools/invoke", () => {
|
|||||||
const port = await getFreePort();
|
const port = await getFreePort();
|
||||||
const server = await startGatewayServer(port, { bind: "loopback" });
|
const server = await startGatewayServer(port, { bind: "loopback" });
|
||||||
try {
|
try {
|
||||||
|
const token = resolveGatewayToken();
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, {
|
const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
tool: "sessions_list",
|
tool: "sessions_list",
|
||||||
action: "json",
|
action: "json",
|
||||||
@@ -167,10 +175,11 @@ describe("POST /tools/invoke", () => {
|
|||||||
|
|
||||||
const port = await getFreePort();
|
const port = await getFreePort();
|
||||||
const server = await startGatewayServer(port, { bind: "loopback" });
|
const server = await startGatewayServer(port, { bind: "loopback" });
|
||||||
|
const token = resolveGatewayToken();
|
||||||
|
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, {
|
const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
||||||
body: JSON.stringify({ tool: "sessions_list", action: "json", args: {}, sessionKey: "main" }),
|
body: JSON.stringify({ tool: "sessions_list", action: "json", args: {}, sessionKey: "main" }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -198,10 +207,11 @@ describe("POST /tools/invoke", () => {
|
|||||||
|
|
||||||
const port = await getFreePort();
|
const port = await getFreePort();
|
||||||
const server = await startGatewayServer(port, { bind: "loopback" });
|
const server = await startGatewayServer(port, { bind: "loopback" });
|
||||||
|
const token = resolveGatewayToken();
|
||||||
|
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, {
|
const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
||||||
body: JSON.stringify({ tool: "sessions_list", action: "json", args: {}, sessionKey: "main" }),
|
body: JSON.stringify({ tool: "sessions_list", action: "json", args: {}, sessionKey: "main" }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -234,17 +244,18 @@ describe("POST /tools/invoke", () => {
|
|||||||
const server = await startGatewayServer(port, { bind: "loopback" });
|
const server = await startGatewayServer(port, { bind: "loopback" });
|
||||||
|
|
||||||
const payload = { tool: "sessions_list", action: "json", args: {} };
|
const payload = { tool: "sessions_list", action: "json", args: {} };
|
||||||
|
const token = resolveGatewayToken();
|
||||||
|
|
||||||
const resDefault = await fetch(`http://127.0.0.1:${port}/tools/invoke`, {
|
const resDefault = await fetch(`http://127.0.0.1:${port}/tools/invoke`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
expect(resDefault.status).toBe(200);
|
expect(resDefault.status).toBe(200);
|
||||||
|
|
||||||
const resMain = await fetch(`http://127.0.0.1:${port}/tools/invoke`, {
|
const resMain = await fetch(`http://127.0.0.1:${port}/tools/invoke`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
||||||
body: JSON.stringify({ ...payload, sessionKey: "main" }),
|
body: JSON.stringify({ ...payload, sessionKey: "main" }),
|
||||||
});
|
});
|
||||||
expect(resMain.status).toBe(200);
|
expect(resMain.status).toBe(200);
|
||||||
|
|||||||
@@ -211,8 +211,14 @@ function collectGatewayConfigFindings(cfg: ClawdbotConfig): SecurityAuditFinding
|
|||||||
const trustedProxies = Array.isArray(cfg.gateway?.trustedProxies)
|
const trustedProxies = Array.isArray(cfg.gateway?.trustedProxies)
|
||||||
? cfg.gateway.trustedProxies
|
? cfg.gateway.trustedProxies
|
||||||
: [];
|
: [];
|
||||||
|
const hasToken = typeof auth.token === "string" && auth.token.trim().length > 0;
|
||||||
|
const hasPassword = typeof auth.password === "string" && auth.password.trim().length > 0;
|
||||||
|
const hasSharedSecret =
|
||||||
|
(auth.mode === "token" && hasToken) || (auth.mode === "password" && hasPassword);
|
||||||
|
const hasTailscaleAuth = auth.allowTailscale === true && tailscaleMode === "serve";
|
||||||
|
const hasGatewayAuth = hasSharedSecret || hasTailscaleAuth;
|
||||||
|
|
||||||
if (bind !== "loopback" && auth.mode === "none") {
|
if (bind !== "loopback" && !hasSharedSecret) {
|
||||||
findings.push({
|
findings.push({
|
||||||
checkId: "gateway.bind_no_auth",
|
checkId: "gateway.bind_no_auth",
|
||||||
severity: "critical",
|
severity: "critical",
|
||||||
@@ -236,13 +242,13 @@ function collectGatewayConfigFindings(cfg: ClawdbotConfig): SecurityAuditFinding
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bind === "loopback" && controlUiEnabled && auth.mode === "none") {
|
if (bind === "loopback" && controlUiEnabled && !hasGatewayAuth) {
|
||||||
findings.push({
|
findings.push({
|
||||||
checkId: "gateway.loopback_no_auth",
|
checkId: "gateway.loopback_no_auth",
|
||||||
severity: "critical",
|
severity: "critical",
|
||||||
title: "Gateway auth disabled on loopback",
|
title: "Gateway auth missing on loopback",
|
||||||
detail:
|
detail:
|
||||||
"gateway.bind is loopback and gateway.auth is disabled. " +
|
"gateway.bind is loopback but no gateway auth secret is configured. " +
|
||||||
"If the Control UI is exposed through a reverse proxy, unauthenticated access is possible.",
|
"If the Control UI is exposed through a reverse proxy, unauthenticated access is possible.",
|
||||||
remediation: "Set gateway.auth (token recommended) or keep the Control UI local-only.",
|
remediation: "Set gateway.auth (token recommended) or keep the Control UI local-only.",
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user