Skip to content

fix(gateway): let POST requests pass through root-mounted Control UI to plugin handlers#31349

Merged
steipete merged 1 commit intoopenclaw:mainfrom
Sid-Qin:fix/bluebubbles-webhook-405-31344
Mar 2, 2026
Merged

fix(gateway): let POST requests pass through root-mounted Control UI to plugin handlers#31349
steipete merged 1 commit intoopenclaw:mainfrom
Sid-Qin:fix/bluebubbles-webhook-405-31344

Conversation

@Sid-Qin
Copy link
Contributor

@Sid-Qin Sid-Qin commented Mar 2, 2026

Summary

  • Problem: handleControlUiHttpRequest checked the HTTP method (GET/HEAD only) before any path-based routing. When Control UI is root-mounted (no basePath), every POST request to the gateway — including plugin webhook endpoints like /bluebubbles-webhook — was intercepted with HTTP 405 Method Not Allowed before the plugin handler could run.
  • Why it matters: Any extension/plugin that registers HTTP webhook endpoints (BlueBubbles, custom integrations) is completely broken on the default root-mounted Control UI deployment. The gateway silently rejects all inbound webhook POSTs.
  • What changed: Moved the method check to after path-based exclusions. For root-mounted Control UI, non-GET/HEAD requests now return false (pass-through) instead of 405, allowing plugin HTTP handlers to run. For basePath-mounted Control UI, 405 is still returned for confirmed Control UI paths.
  • What did NOT change (scope boundary): All existing GET/HEAD behavior is preserved — SPA fallback, security headers, symlink/path-traversal hardening, /api and /plugins exclusions. The basePath redirect still works. No plugin code was changed.

Change Type (select all)

  • Bug fix
  • Feature
  • Refactor
  • Docs
  • Security hardening
  • Chore/infra

Scope (select all touched areas)

  • Gateway / orchestration
  • Skills / tool execution
  • Auth / tokens
  • Memory / storage
  • Integrations
  • API / contracts
  • UI / DX
  • CI/CD / infra

Linked Issue/PR

User-visible / Behavior Changes

  • Plugin webhook POST endpoints (e.g. /bluebubbles-webhook) now receive requests correctly instead of 405 Method Not Allowed.
  • BlueBubbles inbound webhooks work again on root-mounted Control UI deployments.

Security Impact (required)

  • New permissions/capabilities? No
  • Secrets/tokens handling changed? No
  • New/changed network calls? No
  • Command/tool execution surface changed? No
  • Data access scope changed? No

Repro + Verification

Environment

  • OS: macOS (Darwin 25.3.0)
  • Runtime: Node.js 22+

Steps

  1. Configure channels.bluebubbles.webhookPath: "/bluebubbles-webhook" with default (root-mounted) Control UI
  2. Start gateway
  3. curl -s -o /dev/null -w "%{http_code}" -X POST http://localhost:18789/bluebubbles-webhook -H "Content-Type: application/json" -d '{}'

Expected

  • POST returns 400 (invalid payload accepted by plugin handler)

Actual

  • Before: POST returns 405 Method Not Allowed (Control UI rejects before plugin handler runs)
  • After: POST reaches plugin handler correctly

Evidence

  • Failing test/log before + passing after
  • Trace/log snippets
  • Screenshot/recording
  • Perf numbers (if relevant)

Three new test cases added:

  1. does not handle POST to root-mounted paths (plugin webhook passthrough) — verifies POST to /bluebubbles-webhook, /custom-webhook, /callback returns false
  2. returns 405 for POST to basePath-mounted control UI routes — verifies POST to /openclaw/some-page still returns 405
  3. does not handle POST to paths outside basePath — verifies POST to /bluebubbles-webhook with basePath /openclaw returns false

Human Verification (required)

  • Verified scenarios: POST to root-mounted webhook paths pass through; POST to basePath routes still 405; all 17 existing + 3 new tests pass
  • Edge cases checked: basePath vs root-mounted; /plugins and /api exclusions unchanged; HEAD requests still work; symlink hardening preserved
  • What you did not verify: End-to-end with a running BlueBubbles server (tested via unit tests and curl simulation)

Compatibility / Migration

  • Backward compatible? Yes
  • Config/env changes? No
  • Migration needed? No

Failure Recovery (if this breaks)

  • How to disable/revert: Revert src/gateway/control-ui.ts to restore the early method check
  • Files/config to restore: src/gateway/control-ui.ts

Risks and Mitigations

  • Risk: Root-mounted Control UI no longer returns 405 for POST to non-plugin paths (e.g. POST to /random-path would fall through to 404 instead of 405)
    • Mitigation: This is actually correct behavior — unknown POST paths should 404, not 405. The 405 semantics ("method not allowed on this resource") were incorrect for paths that aren't Control UI resources.

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 2, 2026

Greptile Summary

This PR fixes a critical bug where plugin webhook POST endpoints were rejected with HTTP 405 when Control UI is root-mounted (the default configuration).

Key changes:

  • Moved HTTP method check to after path-based routing logic
  • Root-mounted Control UI: non-GET/HEAD requests to non-UI paths now pass through to plugin handlers instead of returning 405
  • basePath-mounted Control UI: behavior preserved - still returns 405 for non-GET/HEAD requests under the basePath
  • All GET/HEAD behavior unchanged, security checks preserved

Impact:

  • Plugin webhooks like BlueBubbles (/bluebubbles-webhook) now work correctly with default Control UI configuration
  • Fixes silently broken webhook integrations that were intercepted before reaching plugin handlers

Test coverage:

  • Added 3 comprehensive tests covering root-mounted passthrough, basePath 405 behavior, and basePath exclusions
  • All 17 existing tests pass, confirming no regression in GET/HEAD behavior or /api//plugins exclusions

Confidence Score: 5/5

  • This PR is safe to merge with minimal risk - it fixes a critical bug with well-tested logic changes
  • The implementation is sound with proper separation of concerns between root-mounted and basePath-mounted Control UI. The method check relocation is logical and preserves all existing behavior while enabling the pass-through needed for plugin webhooks. Comprehensive test coverage validates the three critical scenarios, and the PR description demonstrates thorough testing and understanding of edge cases.
  • No files require special attention

Last reviewed commit: dc8fb8b

…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 openclaw#31344

Made-with: Cursor
@steipete steipete force-pushed the fix/bluebubbles-webhook-405-31344 branch from dc8fb8b to 0abd3cd Compare March 2, 2026 16:06
@steipete steipete merged commit c4711a9 into openclaw:main Mar 2, 2026
6 checks passed
@aisle-research-bot
Copy link

aisle-research-bot bot commented Mar 2, 2026

🔒 Aisle Security Analysis

We found 2 potential security issue(s) in this PR:

# Severity Title
1 🟡 Medium Control UI basePath check can be bypassed via URL-encoded slashes, allowing plugin route handling under the UI mount
2 🔵 Low Open redirect via scheme-relative Control UI basePath in 302 Location header

1. 🟡 Control UI basePath check can be bypassed via URL-encoded slashes, allowing plugin route handling under the UI mount

Property Value
Severity Medium
CWE CWE-177
Location src/gateway/control-ui.ts:303-312

Description

handleControlUiHttpRequest decides whether a request is “under” the configured Control UI basePath using a raw string prefix check on url.pathname (WHATWG URL):

  • url.pathname preserves percent-encoded characters like %2F (encoded slash).
  • The code only treats requests as under basePath when pathname === basePath or pathname.startsWith(${basePath}/).
  • Therefore, requests such as GET /openclaw%2Fsecret are treated as outside the Control UI base path and fall through to later handlers.

However, the plugin HTTP route matcher does canonicalize and repeatedly decodeURIComponent() path variants (including decoding %2F into /) before matching routes:

  • findRegisteredPluginHttpRoute() canonicalizes the request path via canonicalizePathVariant().
  • canonicalizePathVariant() uses repeated decodeURIComponent() passes.

This mismatch enables route confusion: a request that Control UI treats as outside basePath may be treated by plugin routing as inside that prefix once decoded/canonicalized. That undermines the intended exclusivity of the Control UI mount (and the new “always Control UI traffic under basePath” method guard), and can allow plugins to respond on basePath-equivalent URLs (e.g., bypassing the Control UI 405 behavior for non-GET/HEAD on paths that canonicalize into the basePath).

Vulnerable code:

if (basePath) {
  if (!pathname.startsWith(`${basePath}/`) && pathname !== basePath) {
    return false;
  }// ... 405 for non-GET/HEAD ...
}

Relevant downstream canonicalization (for context):

  • src/gateway/server/plugins-http.ts uses canonicalizePathVariant(pathname) for route matching.
  • src/gateway/security-path.ts performs repeated decodeURIComponent() passes when canonicalizing.

Recommendation

Make basePath inclusion checks resilient to encoded path separators so Control UI and plugin routing cannot disagree.

A minimal, fail-closed mitigation (mirroring prefixMatch() in security-path.ts) is to also treat basePath followed by a percent-escape as “under basePath”:

// Treat /openclaw%2F... as under /openclaw to avoid routing confusion.
const underBasePath =
  pathname === basePath ||
  pathname.startsWith(`${basePath}/`) ||
  pathname.startsWith(`${basePath}%`);

if (!underBasePath) return false;

Alternatively (more robust), reuse the same canonicalization approach used for plugin security checks and evaluate basePath membership across canonicalized candidates, failing closed on malformed/overly-deep decoding.

This ensures paths like /openclaw%2Fsecret cannot bypass Control UI ownership of the configured mount and cannot be reinterpreted differently by downstream routing.


2. 🔵 Open redirect via scheme-relative Control UI basePath in 302 Location header

Property Value
Severity Low
CWE CWE-601
Location src/gateway/control-ui.ts:314-319

Description

The Control UI base-path redirect can become an open redirect if gateway.controlUi.basePath is configured as a scheme-relative value such as //evil.example.

  • handleControlUiHttpRequest issues a 302 when pathname === basePath and sets Location to ${basePath}/${url.search} ([control-ui.ts:314-319]).
  • basePath is derived from configuration and normalized by normalizeControlUiBasePath, which does not reject values starting with // ([control-ui-shared.ts:9-26]).
  • Browsers interpret Location: //evil.example/ as a scheme-relative absolute URL, redirecting the user off-site.

Vulnerable code:

res.statusCode = 302;
res.setHeader("Location", `${basePath}/${url.search}`);

Impact:

  • If an operator accidentally/maliciously configures basePath as //evil.example, an attacker can send a victim to http(s)://<gateway-host>//evil.example and the gateway will redirect the victim to http(s)://evil.example/.

Recommendation

Reject scheme-relative (and other non-path) base paths during normalization/config validation.

For example:

export function normalizeControlUiBasePath(basePath?: string): string {
  if (!basePath) return "";
  let normalized = basePath.trim();
  if (!normalized) return "";
  if (!normalized.startsWith("/")) normalized = `/${normalized}`;
  if (normalized === "/") return "";
  if (normalized.endsWith("/")) normalized = normalized.slice(0, -1);// Reject scheme-relative and other ambiguous paths
  if (normalized.startsWith("//")) {
    throw new Error("gateway.controlUi.basePath must not start with '//' (scheme-relative URL)");
  }
  return normalized;
}

This ensures Location is always a relative redirect within the same origin.


Analyzed PR: #31349 at commit 0abd3cd

Last updated on: 2026-03-02T16:33:31Z

@steipete
Copy link
Contributor

steipete commented Mar 2, 2026

Landed via temp rebase onto main.

  • Gate: pnpm build; pnpm test src/gateway/control-ui.http.test.ts src/gateway/server.plugin-http-auth.test.ts
  • Land commit: 0abd3cd
  • Merge commit: c4711a9

Thanks @Sid-Qin!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

app: web-ui App: web-ui gateway Gateway runtime size: S

Projects

None yet

Development

Successfully merging this pull request may close these issues.

BlueBubbles webhook POST returns 405 Method Not Allowed on 2026.3.1

2 participants