diff --git a/CHANGELOG.md b/CHANGELOG.md index 53cba72a64b..a52c2e835f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,7 @@ Docs: https://docs.openclaw.ai - Agents/Subagent announce cleanup: keep completion-message runs pending while descendants settle, add a 30 minute hard-expiry backstop to avoid indefinite pending state, and keep retry bookkeeping resumable across deferred wakes. (#23970) Thanks @tyler6204. - BlueBubbles/Message metadata: harden send response ID extraction, include sender identity in DM context, and normalize inbound `message_id` selection to avoid duplicate ID metadata. (#23970) Thanks @tyler6204. - Gateway/Control UI method guard: allow POST requests to non-UI routes to fall through when no base path is configured, and add POST regression coverage for fallthrough and base-path 405 behavior. (#23970) Thanks @tyler6204. +- Gateway/Control UI basePath POST handling: return 405 for `POST` on exact basePath routes (for example `/openclaw`) instead of redirecting, and add end-to-end regression coverage that root-mounted webhook POST paths still pass through to plugin handlers. (#31349) Thanks @Sid-Qin. - Authentication: classify `permission_error` as `auth_permanent` for profile fallback. (#31324) Thanks @Sid-Qin. - Security/Prompt spoofing hardening: stop injecting queued runtime events into user-role prompt text, route them through trusted system-prompt context, and neutralize inbound spoof markers like `[System Message]` and line-leading `System:` in untrusted message content. (#30448) - Gateway/Node browser proxy routing: honor `profile` from `browser.request` JSON body when query params omit it, while preserving query-profile precedence when both are present. (#28852) Thanks @Sid-Qin. diff --git a/src/gateway/control-ui.http.test.ts b/src/gateway/control-ui.http.test.ts index 7df42766cb2..d0d5adec41c 100644 --- a/src/gateway/control-ui.http.test.ts +++ b/src/gateway/control-ui.http.test.ts @@ -326,6 +326,38 @@ describe("handleControlUiHttpRequest", () => { }); }); + it("does not handle POST to root-mounted paths (plugin webhook passthrough)", async () => { + await withControlUiRoot({ + fn: async (tmp) => { + for (const webhookPath of ["/bluebubbles-webhook", "/custom-webhook", "/callback"]) { + const { res } = makeMockHttpResponse(); + const handled = handleControlUiHttpRequest( + { url: webhookPath, method: "POST" } as IncomingMessage, + res, + { root: { kind: "resolved", path: tmp } }, + ); + expect(handled, `POST to ${webhookPath} should pass through to plugin handlers`).toBe( + false, + ); + } + }, + }); + }); + + it("does not handle POST to paths outside basePath", async () => { + await withControlUiRoot({ + fn: async (tmp) => { + const { res } = makeMockHttpResponse(); + const handled = handleControlUiHttpRequest( + { url: "/bluebubbles-webhook", method: "POST" } as IncomingMessage, + res, + { basePath: "/openclaw", root: { kind: "resolved", path: tmp } }, + ); + expect(handled).toBe(false); + }, + }); + }); + it("does not handle /api paths when basePath is empty", async () => { await withControlUiRoot({ fn: async (tmp) => { @@ -373,15 +405,17 @@ describe("handleControlUiHttpRequest", () => { it("returns 405 for POST requests under configured basePath", async () => { await withControlUiRoot({ fn: async (tmp) => { - const { handled, res, end } = runControlUiRequest({ - url: "/openclaw/", - method: "POST", - rootPath: tmp, - basePath: "/openclaw", - }); - expect(handled).toBe(true); - expect(res.statusCode).toBe(405); - expect(end).toHaveBeenCalledWith("Method Not Allowed"); + for (const route of ["/openclaw", "/openclaw/", "/openclaw/some-page"]) { + const { handled, res, end } = runControlUiRequest({ + url: route, + method: "POST", + rootPath: tmp, + basePath: "/openclaw", + }); + expect(handled, `expected ${route} to be handled`).toBe(true); + expect(res.statusCode, `expected ${route} status`).toBe(405); + expect(end, `expected ${route} body`).toHaveBeenCalledWith("Method Not Allowed"); + } }, }); }); diff --git a/src/gateway/control-ui.ts b/src/gateway/control-ui.ts index ddfc70418e3..4858a6e5004 100644 --- a/src/gateway/control-ui.ts +++ b/src/gateway/control-ui.ts @@ -293,9 +293,24 @@ export function handleControlUiHttpRequest( if (pathname === "/api" || pathname.startsWith("/api/")) { return false; } + // Root-mounted SPA: non-GET/HEAD may be destined for plugin HTTP handlers + // (e.g. BlueBubbles webhook POST) that run after Control UI in the chain. + if (req.method !== "GET" && req.method !== "HEAD") { + return false; + } } if (basePath) { + if (!pathname.startsWith(`${basePath}/`) && pathname !== basePath) { + return false; + } + // Requests under a configured basePath are always Control UI traffic. + if (req.method !== "GET" && req.method !== "HEAD") { + res.statusCode = 405; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end("Method Not Allowed"); + return true; + } if (pathname === basePath) { applyControlUiSecurityHeaders(res); res.statusCode = 302; @@ -303,22 +318,6 @@ export function handleControlUiHttpRequest( res.end(); return true; } - if (!pathname.startsWith(`${basePath}/`)) { - return false; - } - } - - // Method guard must run AFTER path checks so that POST requests to non-UI - // paths (channel webhooks etc.) fall through to later handlers. When no - // basePath is configured the SPA catch-all would otherwise 405 every POST. - if (req.method !== "GET" && req.method !== "HEAD") { - if (!basePath) { - return false; - } - res.statusCode = 405; - res.setHeader("Content-Type", "text/plain; charset=utf-8"); - res.end("Method Not Allowed"); - return true; } applyControlUiSecurityHeaders(res); diff --git a/src/gateway/server.plugin-http-auth.test.ts b/src/gateway/server.plugin-http-auth.test.ts index bfe0b06ebfa..c43130df45e 100644 --- a/src/gateway/server.plugin-http-auth.test.ts +++ b/src/gateway/server.plugin-http-auth.test.ts @@ -554,6 +554,40 @@ describe("gateway plugin HTTP auth boundary", () => { }); }); + test("passes POST webhook routes through root-mounted control ui to plugins", async () => { + const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => { + const pathname = new URL(req.url ?? "/", "http://localhost").pathname; + if (req.method !== "POST" || pathname !== "/bluebubbles-webhook") { + return false; + } + res.statusCode = 200; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end("plugin-webhook"); + return true; + }); + + await withGatewayServer({ + prefix: "openclaw-plugin-http-control-ui-webhook-post-test-", + resolvedAuth: AUTH_NONE, + overrides: { + controlUiEnabled: true, + controlUiBasePath: "", + controlUiRoot: { kind: "missing" }, + handlePluginRequest, + }, + run: async (server) => { + const response = await sendRequest(server, { + path: "/bluebubbles-webhook", + method: "POST", + }); + + expect(response.res.statusCode).toBe(200); + expect(response.getBody()).toBe("plugin-webhook"); + expect(handlePluginRequest).toHaveBeenCalledTimes(1); + }, + }); + }); + test("does not let plugin handlers shadow control ui routes", async () => { const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => { const pathname = new URL(req.url ?? "/", "http://localhost").pathname;