Skip to content

fix(gateway): prevent /api/* routes from returning SPA HTML when basePath is empty#30333

Merged
velvet-shark merged 3 commits intoopenclaw:mainfrom
Sid-Qin:fix/30295-api-routes-html-regression
Mar 1, 2026
Merged

fix(gateway): prevent /api/* routes from returning SPA HTML when basePath is empty#30333
velvet-shark merged 3 commits intoopenclaw:mainfrom
Sid-Qin:fix/30295-api-routes-html-regression

Conversation

@Sid-Qin
Copy link
Copy Markdown
Contributor

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

Summary

  • Problem: When gateway.controlUi.basePath is unset (empty string, the default), all /api/* HTTP requests return the Control UI SPA index.html instead of JSON or a proper 404. This breaks the browser UI, curl-based API usage, and Discord integration.
  • Why it matters: Any user running OpenClaw 2026.2.26 with the default configuration cannot use HTTP API endpoints — they receive HTML instead of JSON.
  • What changed: src/gateway/control-ui.ts — added an early return false for /api and /api/* paths in the !basePath branch of handleControlUiHttpRequest, so these paths fall through to the gateway's normal handler chain and ultimately reach the 404 handler.
  • What did NOT change: When basePath is set (e.g. /webchat), the existing guard at line 305 already excludes non-basePath paths. The SPA fallback for legitimate UI routes is unaffected.

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

  • /api/* HTTP requests now correctly return 404 (or the appropriate API response) instead of HTML when basePath is empty.

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: Any
  • Runtime: Node.js
  • Integration/channel: Control UI, curl, Discord

Steps

  1. Run OpenClaw with default config (no gateway.controlUi.basePath set)
  2. curl http://127.0.0.1:18789/api/sessions

Expected

  • JSON response or 404 Not Found (plain text)

Actual

  • Before fix: 200 OK with HTML (<!doctype html>...)
  • After fix: 404 Not Found (plain text)

Evidence

 ✓ src/gateway/control-ui.http.test.ts (13 tests) 17ms
   ✓ does not handle /api paths when basePath is empty
   ... (12 existing tests still pass)

Human Verification (required)

  • Verified scenarios: /api, /api/sessions, /api/channels/nostr all return handled: false when basePath is empty
  • Edge cases checked: Existing SPA routes still served correctly; basePath-configured instances unaffected
  • What I did not verify: End-to-end with a running gateway instance

Compatibility / Migration

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

Failure Recovery (if this breaks)

  • How to disable/revert: Revert this commit
  • Files/config to restore: src/gateway/control-ui.ts
  • Known bad symptoms: /api/* returns HTML again

Risks and Mitigations

None — this restores the expected behavior where the Control UI handler only serves SPA content for UI paths.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 1, 2026

Greptile Summary

This PR fixes a real bug where GET /api/* requests returned the Control UI SPA index.html (with a 200 status) instead of falling through to the API handler when gateway.controlUi.basePath is unset. The one-line guard added in src/gateway/control-ui.ts is minimal and correctly scoped to the !basePath branch.

Key observations:

  • The fix is correct for GET and HEAD requests to /api/*.
  • Incomplete coverage for mutating methods: The existing 405 Method Not Allowed guard (line 278) runs for every non-GET/HEAD request before the new /api check is reached. This means POST /api/sessions, PUT /api/..., PATCH /api/..., and DELETE /api/... are still intercepted by the Control UI handler and receive a 405 instead of being forwarded to the real API layer. The Discord integration referenced in the PR description typically issues POST requests, so those remain broken.
  • The new test covers only GET paths, which is consistent with the fix's scope but leaves the mutating-method gap untested.
  • Existing test suite (13 tests) still passes, confirming no regressions for covered paths.

Confidence Score: 3/5

  • Safe to merge for GET/HEAD API traffic, but mutating API methods (POST, PUT, DELETE, PATCH) to /api/* remain broken by the pre-existing method guard that fires before the new check.
  • The fix correctly resolves the stated GET-request issue and introduces no regressions. However, the method-not-allowed guard at line 278 still intercepts all non-GET/HEAD requests to /api/* with a 405 before the new early-return is reachable, leaving the broader "HTTP API endpoints" problem partially unresolved for mutating methods.
  • src/gateway/control-ui.ts — the ordering of the method guard (line 278) relative to the new /api path check (line 295) needs attention to fully unblock non-GET/HEAD API requests.

Last reviewed commit: ab21f3e

Copy link
Copy Markdown
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 files reviewed, 1 comment

Edit Code Review Agent Settings | Greptile

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 1, 2026

Additional Comments (1)

src/gateway/control-ui.ts
POST/PUT/DELETE to /api/* still intercepted with 405

The method-not-allowed guard at line 278 fires for every non-GET/HEAD request before the new /api early-return is reached. As a result, POST /api/sessions (and any other mutating API call) still returns 405 Method Not Allowed from the Control UI handler instead of falling through to the real API handler.

Repro:

curl -X POST http://127.0.0.1:18789/api/sessions
# → 405 Method Not Allowed  (from Control UI, not API layer)

The fix only covers GET and HEAD. Any API client (including the Discord integration mentioned in the PR description) that uses POST, PUT, PATCH, or DELETE against /api/* remains broken.

To cover all methods, the /api check needs to happen before the method guard:

// Proposed reordering (sketch — not a drop-in suggestion block):
if (!basePath && (pathname === "/api" || pathname.startsWith("/api/"))) {
  return false;
}

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

Note: the method guard should still reject non-GET/HEAD requests to Control UI asset paths (/ui/, SPA fallback, etc.), so the condition either needs to remain in place for those paths or the early-return for API paths simply prevents the guard from being relevant for them.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gateway/control-ui.ts
Line: 278-297

Comment:
**`POST`/`PUT`/`DELETE` to `/api/*` still intercepted with 405**

The method-not-allowed guard at line 278 fires for every non-`GET`/`HEAD` request **before** the new `/api` early-return is reached. As a result, `POST /api/sessions` (and any other mutating API call) still returns `405 Method Not Allowed` from the Control UI handler instead of falling through to the real API handler.

Repro:
```
curl -X POST http://127.0.0.1:18789/api/sessions
# → 405 Method Not Allowed  (from Control UI, not API layer)
```

The fix only covers `GET` and `HEAD`. Any API client (including the Discord integration mentioned in the PR description) that uses `POST`, `PUT`, `PATCH`, or `DELETE` against `/api/*` remains broken.

To cover all methods, the `/api` check needs to happen before the method guard:

```typescript
// Proposed reordering (sketch — not a drop-in suggestion block):
if (!basePath && (pathname === "/api" || pathname.startsWith("/api/"))) {
  return false;
}

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;
}
```

Note: the method guard should still reject non-`GET`/`HEAD` requests to Control UI asset paths (`/ui/`, SPA fallback, etc.), so the condition either needs to remain in place for those paths or the early-return for API paths simply prevents the guard from being relevant for them.

How can I resolve this? If you propose a fix, please make it concise.

@velvet-shark velvet-shark self-assigned this Mar 1, 2026
@velvet-shark velvet-shark force-pushed the fix/30295-api-routes-html-regression branch from ab21f3e to 04584e8 Compare March 1, 2026 20:39
@aisle-research-bot
Copy link
Copy Markdown

aisle-research-bot bot commented Mar 1, 2026

🔒 Aisle Security Analysis

✅ We scanned this PR and did not find any security vulnerabilities.

Aisle supplements but does not replace security review.


Analyzed PR: #30333 at commit 12591f3

Last updated on: 2026-03-01T21:47:30Z

@aisle-research-bot
Copy link
Copy Markdown

aisle-research-bot bot commented Mar 1, 2026

🔒 Aisle Security Analysis

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

# Severity Title
1 🟡 Medium Control UI /api exclusion can be bypassed via protocol-relative paths and encoded separators (path confusion)

1. 🟡 Control UI /api exclusion can be bypassed via protocol-relative paths and encoded separators (path confusion)

Property Value
Severity Medium
CWE CWE-20
Location src/gateway/control-ui.ts:285-297

Description

The new /api exclusion in handleControlUiHttpRequest() relies on pathname derived from new URL(req.url, "http://localhost").pathname and then performs a simple string comparison:

  • URL parsing treats request-targets beginning with // as scheme-relative URLs (authority present), not as an origin-form path. For example, req.url = "//api/sessions" is parsed as http://api/sessions, producing pathname === "/sessions", so the /api guard does not trigger.
  • Percent-encoded path separators like %2F are preserved in URL.pathname, so /api%2Fsessions results in pathname === "/api%2Fsessions", which also bypasses the guard.

As a result, the Control UI handler may still return true and serve SPA HTML for paths that other components (reverse proxies, middleware, caches) may normalize/canonicalize to /api/..., creating risk of route confusion (e.g., serving HTML on intended API endpoints, cache poisoning/content-type confusion, or bypassing downstream API routing assumptions).

Vulnerable code:

const url = new URL(urlRaw, "http://localhost");
const pathname = url.pathname;
...
if (pathname === "/api" || pathname.startsWith("/api/")) {
  return false;
}

Recommendation

Harden request-target parsing and apply the /api exclusion to a canonicalized path representation.

  1. Prevent //... from being interpreted as an authority component by building a full URL string explicitly (or rejecting such paths):
const urlRaw = req.url ?? "/";
if (!urlRaw.startsWith("/")) return false;// Avoid scheme-relative parsing quirks for leading "//"
const url = new URL(`http://localhost${urlRaw}`);
  1. Canonicalize before matching /api (decode repeated encodings, collapse multiple slashes, resolve dot-segments), and fail closed for encoded-prefix variants:
import { canonicalizePathForSecurity } from "./security-path.js";

const { canonicalPath, candidates } = canonicalizePathForSecurity(url.pathname);
const isApi = candidates.some((p) => p === "/api" || p.startsWith("/api/"));
if (isApi) return false;

If you do not want to accept encoded separators at all, explicitly reject requests whose raw path contains %2f, %5c, or other encoded delimiter characters.


Analyzed PR: #30333 at commit e5b0f56

Last updated on: 2026-03-01T21:23:12Z

@velvet-shark velvet-shark force-pushed the fix/30295-api-routes-html-regression branch 2 times, most recently from dbdb26b to 0f908ad Compare March 1, 2026 21:09
SidQin-cyber and others added 3 commits March 1, 2026 22:23
…Path is empty

When controlUiBasePath is unset (empty string), handleControlUiHttpRequest
acts as a catch-all and serves index.html for every unmatched path — including
/api/* routes.  This causes API consumers (browser UI, curl, Discord) to
receive an HTML page instead of the expected JSON 404.

Add an early return false for /api and /api/* paths in the empty-basePath
branch so they fall through to the gateway's normal 404 handler.

Closes openclaw#30295
@velvet-shark velvet-shark force-pushed the fix/30295-api-routes-html-regression branch from 0f908ad to 12591f3 Compare March 1, 2026 21:23
@velvet-shark velvet-shark merged commit c1428e8 into openclaw:main Mar 1, 2026
9 checks passed
@velvet-shark
Copy link
Copy Markdown
Member

Merged via squash.

Thanks @Sid-Qin!

CyberK13 pushed a commit to CyberK13/clawdbot that referenced this pull request Mar 1, 2026
…Path is empty (openclaw#30333)

Merged via squash.

Prepared head SHA: 12591f3
Co-authored-by: Sid-Qin <[email protected]>
Co-authored-by: velvet-shark <[email protected]>
Reviewed-by: @velvet-shark
ansh pushed a commit to vibecode/openclaw that referenced this pull request Mar 2, 2026
…Path is empty (openclaw#30333)

Merged via squash.

Prepared head SHA: 12591f3
Co-authored-by: Sid-Qin <[email protected]>
Co-authored-by: velvet-shark <[email protected]>
Reviewed-by: @velvet-shark
steipete pushed a commit to Sid-Qin/openclaw that referenced this pull request Mar 2, 2026
…Path is empty (openclaw#30333)

Merged via squash.

Prepared head SHA: 12591f3
Co-authored-by: Sid-Qin <[email protected]>
Co-authored-by: velvet-shark <[email protected]>
Reviewed-by: @velvet-shark
safzanpirani pushed a commit to safzanpirani/clawdbot that referenced this pull request Mar 2, 2026
…Path is empty (openclaw#30333)

Merged via squash.

Prepared head SHA: 12591f3
Co-authored-by: Sid-Qin <[email protected]>
Co-authored-by: velvet-shark <[email protected]>
Reviewed-by: @velvet-shark
steipete pushed a commit to Sid-Qin/openclaw that referenced this pull request Mar 2, 2026
…Path is empty (openclaw#30333)

Merged via squash.

Prepared head SHA: 12591f3
Co-authored-by: Sid-Qin <[email protected]>
Co-authored-by: velvet-shark <[email protected]>
Reviewed-by: @velvet-shark
robertchang-ga pushed a commit to robertchang-ga/openclaw that referenced this pull request Mar 2, 2026
…Path is empty (openclaw#30333)

Merged via squash.

Prepared head SHA: 12591f3
Co-authored-by: Sid-Qin <[email protected]>
Co-authored-by: velvet-shark <[email protected]>
Reviewed-by: @velvet-shark
hanqizheng pushed a commit to hanqizheng/openclaw that referenced this pull request Mar 2, 2026
…Path is empty (openclaw#30333)

Merged via squash.

Prepared head SHA: 12591f3
Co-authored-by: Sid-Qin <[email protected]>
Co-authored-by: velvet-shark <[email protected]>
Reviewed-by: @velvet-shark
execute008 pushed a commit to execute008/openclaw that referenced this pull request Mar 2, 2026
…Path is empty (openclaw#30333)

Merged via squash.

Prepared head SHA: 12591f3
Co-authored-by: Sid-Qin <[email protected]>
Co-authored-by: velvet-shark <[email protected]>
Reviewed-by: @velvet-shark
dorgonman pushed a commit to kanohorizonia/openclaw that referenced this pull request Mar 3, 2026
…Path is empty (openclaw#30333)

Merged via squash.

Prepared head SHA: 12591f3
Co-authored-by: Sid-Qin <[email protected]>
Co-authored-by: velvet-shark <[email protected]>
Reviewed-by: @velvet-shark
sachinkundu pushed a commit to sachinkundu/openclaw that referenced this pull request Mar 6, 2026
…Path is empty (openclaw#30333)

Merged via squash.

Prepared head SHA: 12591f3
Co-authored-by: Sid-Qin <[email protected]>
Co-authored-by: velvet-shark <[email protected]>
Reviewed-by: @velvet-shark
zooqueen pushed a commit to hanzoai/bot that referenced this pull request Mar 6, 2026
…Path is empty (openclaw#30333)

Merged via squash.

Prepared head SHA: 12591f3
Co-authored-by: Sid-Qin <[email protected]>
Co-authored-by: velvet-shark <[email protected]>
Reviewed-by: @velvet-shark
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: XS

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Gateway 2026.2.26: API routes return HTML instead of JSON (routing regression)

2 participants