Skip to content

Commit 0abd3cd

Browse files
SidQin-cybersteipete
authored andcommitted
fix(gateway): let POST requests pass through root-mounted Control UI to plugin handlers
The Control UI handler checked HTTP method before path routing, causing all POST requests (including plugin webhook endpoints like /bluebubbles-webhook) to receive 405 Method Not Allowed. Move the method check after path-based exclusions so non-GET/HEAD requests reach plugin HTTP handlers. Closes #31344 Made-with: Cursor
1 parent 64c443a commit 0abd3cd

File tree

4 files changed

+93
-25
lines changed

4 files changed

+93
-25
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ Docs: https://docs.openclaw.ai
5959
- 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.
6060
- 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.
6161
- 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.
62+
- 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.
6263
- Authentication: classify `permission_error` as `auth_permanent` for profile fallback. (#31324) Thanks @Sid-Qin.
6364
- 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)
6465
- 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.

src/gateway/control-ui.http.test.ts

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,38 @@ describe("handleControlUiHttpRequest", () => {
326326
});
327327
});
328328

329+
it("does not handle POST to root-mounted paths (plugin webhook passthrough)", async () => {
330+
await withControlUiRoot({
331+
fn: async (tmp) => {
332+
for (const webhookPath of ["/bluebubbles-webhook", "/custom-webhook", "/callback"]) {
333+
const { res } = makeMockHttpResponse();
334+
const handled = handleControlUiHttpRequest(
335+
{ url: webhookPath, method: "POST" } as IncomingMessage,
336+
res,
337+
{ root: { kind: "resolved", path: tmp } },
338+
);
339+
expect(handled, `POST to ${webhookPath} should pass through to plugin handlers`).toBe(
340+
false,
341+
);
342+
}
343+
},
344+
});
345+
});
346+
347+
it("does not handle POST to paths outside basePath", async () => {
348+
await withControlUiRoot({
349+
fn: async (tmp) => {
350+
const { res } = makeMockHttpResponse();
351+
const handled = handleControlUiHttpRequest(
352+
{ url: "/bluebubbles-webhook", method: "POST" } as IncomingMessage,
353+
res,
354+
{ basePath: "/openclaw", root: { kind: "resolved", path: tmp } },
355+
);
356+
expect(handled).toBe(false);
357+
},
358+
});
359+
});
360+
329361
it("does not handle /api paths when basePath is empty", async () => {
330362
await withControlUiRoot({
331363
fn: async (tmp) => {
@@ -373,15 +405,17 @@ describe("handleControlUiHttpRequest", () => {
373405
it("returns 405 for POST requests under configured basePath", async () => {
374406
await withControlUiRoot({
375407
fn: async (tmp) => {
376-
const { handled, res, end } = runControlUiRequest({
377-
url: "/openclaw/",
378-
method: "POST",
379-
rootPath: tmp,
380-
basePath: "/openclaw",
381-
});
382-
expect(handled).toBe(true);
383-
expect(res.statusCode).toBe(405);
384-
expect(end).toHaveBeenCalledWith("Method Not Allowed");
408+
for (const route of ["/openclaw", "/openclaw/", "/openclaw/some-page"]) {
409+
const { handled, res, end } = runControlUiRequest({
410+
url: route,
411+
method: "POST",
412+
rootPath: tmp,
413+
basePath: "/openclaw",
414+
});
415+
expect(handled, `expected ${route} to be handled`).toBe(true);
416+
expect(res.statusCode, `expected ${route} status`).toBe(405);
417+
expect(end, `expected ${route} body`).toHaveBeenCalledWith("Method Not Allowed");
418+
}
385419
},
386420
});
387421
});

src/gateway/control-ui.ts

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -293,32 +293,31 @@ export function handleControlUiHttpRequest(
293293
if (pathname === "/api" || pathname.startsWith("/api/")) {
294294
return false;
295295
}
296+
// Root-mounted SPA: non-GET/HEAD may be destined for plugin HTTP handlers
297+
// (e.g. BlueBubbles webhook POST) that run after Control UI in the chain.
298+
if (req.method !== "GET" && req.method !== "HEAD") {
299+
return false;
300+
}
296301
}
297302

298303
if (basePath) {
304+
if (!pathname.startsWith(`${basePath}/`) && pathname !== basePath) {
305+
return false;
306+
}
307+
// Requests under a configured basePath are always Control UI traffic.
308+
if (req.method !== "GET" && req.method !== "HEAD") {
309+
res.statusCode = 405;
310+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
311+
res.end("Method Not Allowed");
312+
return true;
313+
}
299314
if (pathname === basePath) {
300315
applyControlUiSecurityHeaders(res);
301316
res.statusCode = 302;
302317
res.setHeader("Location", `${basePath}/${url.search}`);
303318
res.end();
304319
return true;
305320
}
306-
if (!pathname.startsWith(`${basePath}/`)) {
307-
return false;
308-
}
309-
}
310-
311-
// Method guard must run AFTER path checks so that POST requests to non-UI
312-
// paths (channel webhooks etc.) fall through to later handlers. When no
313-
// basePath is configured the SPA catch-all would otherwise 405 every POST.
314-
if (req.method !== "GET" && req.method !== "HEAD") {
315-
if (!basePath) {
316-
return false;
317-
}
318-
res.statusCode = 405;
319-
res.setHeader("Content-Type", "text/plain; charset=utf-8");
320-
res.end("Method Not Allowed");
321-
return true;
322321
}
323322

324323
applyControlUiSecurityHeaders(res);

src/gateway/server.plugin-http-auth.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -554,6 +554,40 @@ describe("gateway plugin HTTP auth boundary", () => {
554554
});
555555
});
556556

557+
test("passes POST webhook routes through root-mounted control ui to plugins", async () => {
558+
const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => {
559+
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
560+
if (req.method !== "POST" || pathname !== "/bluebubbles-webhook") {
561+
return false;
562+
}
563+
res.statusCode = 200;
564+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
565+
res.end("plugin-webhook");
566+
return true;
567+
});
568+
569+
await withGatewayServer({
570+
prefix: "openclaw-plugin-http-control-ui-webhook-post-test-",
571+
resolvedAuth: AUTH_NONE,
572+
overrides: {
573+
controlUiEnabled: true,
574+
controlUiBasePath: "",
575+
controlUiRoot: { kind: "missing" },
576+
handlePluginRequest,
577+
},
578+
run: async (server) => {
579+
const response = await sendRequest(server, {
580+
path: "/bluebubbles-webhook",
581+
method: "POST",
582+
});
583+
584+
expect(response.res.statusCode).toBe(200);
585+
expect(response.getBody()).toBe("plugin-webhook");
586+
expect(handlePluginRequest).toHaveBeenCalledTimes(1);
587+
},
588+
});
589+
});
590+
557591
test("does not let plugin handlers shadow control ui routes", async () => {
558592
const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => {
559593
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;

0 commit comments

Comments
 (0)