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
This commit is contained in:
SidQin-cyber
2026-03-02 14:11:36 +08:00
committed by Peter Steinberger
parent ea204e65a0
commit c4711a9b69
4 changed files with 93 additions and 25 deletions

View File

@@ -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.

View File

@@ -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");
}
},
});
});

View File

@@ -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);

View File

@@ -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;