diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a52abd7ffd..2615d0224d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway/Security: enforce gateway auth for the exact `/api/channels` plugin root path (plus `/api/channels/` descendants), with regression coverage for query/trailing-slash variants and near-miss paths that must remain plugin-owned. (#25753) Thanks @bmendonca3. - Security/Workspace FS: normalize `@`-prefixed paths before workspace-boundary checks (including workspace-only read/write/edit and sandbox mount path guards), preventing absolute-path escape attempts from bypassing guard validation. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Native images: enforce `tools.fs.workspaceOnly` for native prompt image auto-load (including history refs), preventing out-of-workspace sandbox mounts from being implicitly ingested as vision input. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Exec approvals: bind `system.run` command display/approval text to full argv when shell-wrapper inline payloads carry positional argv values, and reject payload-only `rawCommand` mismatches for those wrapper-carrier forms, preventing hidden command execution under misleading approval text. This ships in the next npm release. Thanks @tdjackey for reporting. diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 72a81a769ad..4f8455a11aa 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -88,6 +88,10 @@ function isCanvasPath(pathname: string): boolean { ); } +function isAuthProtectedChannelPluginPath(pathname: string): boolean { + return pathname === "/api/channels" || pathname.startsWith("/api/channels/"); +} + function isNodeWsClient(client: GatewayWsClient): boolean { if (client.connect.role === "node") { return true; @@ -491,7 +495,7 @@ export function createGatewayHttpServer(opts: { // Channel HTTP endpoints are gateway-auth protected by default. // Non-channel plugin routes remain plugin-owned and must enforce // their own auth when exposing sensitive functionality. - if (requestPath === "/api/channels" || requestPath.startsWith("/api/channels/")) { + if (isAuthProtectedChannelPluginPath(requestPath)) { const token = getBearerToken(req); const authResult = await authorizeHttpGatewayConnect({ auth: resolvedAuth, diff --git a/src/gateway/server.plugin-http-auth.test.ts b/src/gateway/server.plugin-http-auth.test.ts index 25568d4803e..a800a3d9795 100644 --- a/src/gateway/server.plugin-http-auth.test.ts +++ b/src/gateway/server.plugin-http-auth.test.ts @@ -142,6 +142,12 @@ describe("gateway plugin HTTP auth boundary", () => { run: async () => { const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => { const pathname = new URL(req.url ?? "/", "http://localhost").pathname; + if (pathname === "/api/channels-health") { + res.statusCode = 200; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.end(JSON.stringify({ ok: true, route: "channel-health" })); + return true; + } if (pathname === "/api/channels") { res.statusCode = 200; res.setHeader("Content-Type", "application/json; charset=utf-8"); @@ -195,6 +201,26 @@ describe("gateway plugin HTTP auth boundary", () => { expect(unauthenticatedRoot.getBody()).toContain("Unauthorized"); expect(handlePluginRequest).not.toHaveBeenCalled(); + const unauthenticatedRootWithQuery = createResponse(); + await dispatchRequest( + server, + createRequest({ path: "/api/channels?view=compact" }), + unauthenticatedRootWithQuery.res, + ); + expect(unauthenticatedRootWithQuery.res.statusCode).toBe(401); + expect(unauthenticatedRootWithQuery.getBody()).toContain("Unauthorized"); + expect(handlePluginRequest).not.toHaveBeenCalled(); + + const unauthenticatedRootWithTrailingSlash = createResponse(); + await dispatchRequest( + server, + createRequest({ path: "/api/channels/" }), + unauthenticatedRootWithTrailingSlash.res, + ); + expect(unauthenticatedRootWithTrailingSlash.res.statusCode).toBe(401); + expect(unauthenticatedRootWithTrailingSlash.getBody()).toContain("Unauthorized"); + expect(handlePluginRequest).not.toHaveBeenCalled(); + const authenticated = createResponse(); await dispatchRequest( server, @@ -216,7 +242,16 @@ describe("gateway plugin HTTP auth boundary", () => { expect(unauthenticatedPublic.res.statusCode).toBe(200); expect(unauthenticatedPublic.getBody()).toContain('"route":"public"'); - expect(handlePluginRequest).toHaveBeenCalledTimes(2); + const unauthenticatedNearMiss = createResponse(); + await dispatchRequest( + server, + createRequest({ path: "/api/channels-health" }), + unauthenticatedNearMiss.res, + ); + expect(unauthenticatedNearMiss.res.statusCode).toBe(200); + expect(unauthenticatedNearMiss.getBody()).toContain('"route":"channel-health"'); + + expect(handlePluginRequest).toHaveBeenCalledTimes(3); }, }); });