diff --git a/CHANGELOG.md b/CHANGELOG.md index 4731001ac..005197e10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway/Auth: allow authenticated clients across roles/scopes to call `health` while preserving role and scope enforcement for non-health methods. (#19699) thanks @Nachx639. - Gateway/Hooks: include transform export name in hook-transform cache keys so distinct exports from the same module do not reuse the wrong cached transform function. (#13855) thanks @mcaxtr. - Gateway/Control UI: return 404 for missing static-asset paths instead of serving SPA fallback HTML, while preserving client-route fallback behavior for extensionless and non-asset dotted paths. (#12060) thanks @mcaxtr. - Gateway/Pairing: prevent device-token rotate scope escalation by enforcing an approved-scope baseline, preserving approved scopes across metadata updates, and rejecting rotate requests that exceed approved role scope implications. (#20703) thanks @coygeek. diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index 0ac6ba1ec..d1bc16630 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -39,6 +39,9 @@ function authorizeGatewayMethod(method: string, client: GatewayRequestOptions["c if (!client?.connect) { return null; } + if (method === "health") { + return null; + } const role = client.connect.role ?? "operator"; const scopes = client.connect.scopes ?? []; if (isNodeRoleMethod(method)) { diff --git a/src/gateway/server.auth.e2e.test.ts b/src/gateway/server.auth.e2e.test.ts index 01c5e8438..74bfd09ac 100644 --- a/src/gateway/server.auth.e2e.test.ts +++ b/src/gateway/server.auth.e2e.test.ts @@ -111,9 +111,9 @@ async function expectMissingScopeAfterConnect( try { const res = await connectReq(ws, opts); expect(res.ok).toBe(true); - const health = await rpcReq(ws, "health"); - expect(health.ok).toBe(false); - expect(health.error?.message).toContain("missing scope"); + const status = await rpcReq(ws, "status"); + expect(status.ok).toBe(false); + expect(status.error?.message).toContain("missing scope"); } finally { ws.close(); } @@ -363,6 +363,18 @@ describe("gateway server auth/connect", () => { await expectMissingScopeAfterConnect(port, { device: null }); }); + test("allows health when scopes are empty", async () => { + const ws = await openWs(port); + try { + const res = await connectReq(ws, { scopes: [] }); + expect(res.ok).toBe(true); + const health = await rpcReq(ws, "health"); + expect(health.ok).toBe(true); + } finally { + ws.close(); + } + }); + test("does not grant admin when scopes are omitted", async () => { const ws = await openWs(port); const token = resolveGatewayTokenOrEnv(); @@ -400,9 +412,11 @@ describe("gateway server auth/connect", () => { expect(presenceScopes).toEqual([]); expect(presenceScopes).not.toContain("operator.admin"); + const status = await rpcReq(ws, "status"); + expect(status.ok).toBe(false); + expect(status.error?.message).toContain("missing scope"); const health = await rpcReq(ws, "health"); - expect(health.ok).toBe(false); - expect(health.error?.message).toContain("missing scope"); + expect(health.ok).toBe(true); ws.close(); }); @@ -680,9 +694,11 @@ describe("gateway server auth/connect", () => { const ws = await openTailscaleWs(port); const res = await connectReq(ws, { token: "secret", device: null }); expect(res.ok).toBe(true); + const status = await rpcReq(ws, "status"); + expect(status.ok).toBe(false); + expect(status.error?.message).toContain("missing scope"); const health = await rpcReq(ws, "health"); - expect(health.ok).toBe(false); - expect(health.error?.message).toContain("missing scope"); + expect(health.ok).toBe(true); ws.close(); }); }); diff --git a/src/gateway/server.roles-allowlist-update.e2e.test.ts b/src/gateway/server.roles-allowlist-update.e2e.test.ts index abafb9997..5ed4f8575 100644 --- a/src/gateway/server.roles-allowlist-update.e2e.test.ts +++ b/src/gateway/server.roles-allowlist-update.e2e.test.ts @@ -96,6 +96,9 @@ describe("gateway role enforcement", () => { const statusRes = await rpcReq(nodeWs, "status", {}); expect(statusRes.ok).toBe(false); expect(statusRes.error?.message ?? "").toContain("unauthorized role"); + + const healthRes = await rpcReq(nodeWs, "health", {}); + expect(healthRes.ok).toBe(true); } finally { nodeWs.close(); }