From ddcb2d79b17bf2a42c5037d8aeff1537a12b931e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 21 Feb 2026 19:34:00 +0100 Subject: [PATCH] fix(gateway): block node role when device identity is missing --- CHANGELOG.md | 1 + src/gateway/server.auth.e2e.test.ts | 22 +++++++++++++++++++ .../server/ws-connection/message-handler.ts | 2 +- 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 183d00b09..50cc758d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -125,6 +125,7 @@ Docs: https://docs.openclaw.ai - Gateway/Security: remove shared-IP fallback for canvas endpoints and require token or session capability for canvas access. Thanks @thewilloftheshadow. - Gateway/Security: require secure context and paired-device checks for Control UI auth even when `gateway.controlUi.allowInsecureAuth` is set, and align audit messaging with the hardened behavior. (#20684) Thanks @coygeek and @Vasco0x4 for reporting. - Gateway/Security: scope tokenless Tailscale forwarded-header auth to Control UI websocket auth only, so HTTP gateway routes still require token/password even on trusted hosts. Thanks @zpbrent for reporting. +- Gateway/Security: require device identity for `role: node` websocket connections even when shared-token auth succeeds, preventing unpaired device-less clients from invoking `node.event`. Thanks @tdjackey for reporting. - Docker/Security: run E2E and install-sh test images as non-root by adding appuser directives. Thanks @thewilloftheshadow. - Skills/Security: sanitize skill env overrides to block unsafe runtime injection variables and only allow sensitive keys when declared in skill metadata, with warnings for suspicious values. Thanks @thewilloftheshadow. - Security/Commands: block prototype-key injection in runtime `/debug` overrides and require own-property checks for gated command flags (`bash`, `config`, `debug`) so inherited prototype values cannot enable privileged commands. Thanks @tdjackey for reporting. diff --git a/src/gateway/server.auth.e2e.test.ts b/src/gateway/server.auth.e2e.test.ts index bea2cf227..f07900e2a 100644 --- a/src/gateway/server.auth.e2e.test.ts +++ b/src/gateway/server.auth.e2e.test.ts @@ -363,6 +363,28 @@ describe("gateway server auth/connect", () => { await expectMissingScopeAfterConnect(port, { device: null }); }); + test("rejects node role when device identity is omitted", async () => { + const ws = await openWs(port); + const token = resolveGatewayTokenOrEnv(); + try { + const res = await connectReq(ws, { + role: "node", + token, + device: null, + client: { + id: GATEWAY_CLIENT_NAMES.NODE_HOST, + version: "1.0.0", + platform: "test", + mode: GATEWAY_CLIENT_MODES.NODE, + }, + }); + expect(res.ok).toBe(false); + expect(res.error?.message ?? "").toContain("device identity required"); + } finally { + ws.close(); + } + }); + test("allows health when scopes are empty", async () => { const ws = await openWs(port); try { diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 0c94d5b05..aae94280d 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -490,7 +490,7 @@ export function attachGatewayWsMessageHandler(params: { return true; } clearUnboundScopes(); - const canSkipDevice = sharedAuthOk; + const canSkipDevice = role === "operator" && sharedAuthOk; if (isControlUi && !controlUiAuthPolicy.allowBypass) { const errorMessage =