From 32d2ca7a13cbce69e4ea819fed6841f28bbd1b9d Mon Sep 17 00:00:00 2001 From: mbelinky Date: Fri, 20 Feb 2026 18:40:11 +0100 Subject: [PATCH] Gateway/UI: return 404 for missing static assets openclaw#12060 thanks @mcaxtr --- CHANGELOG.md | 1 + src/gateway/control-ui.ts | 32 ++++++++++ src/gateway/gateway-misc.test.ts | 102 +++++++++++++++++++++++++++++++ 3 files changed, 135 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a022137431e..5dba04a671b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway/Control UI: return 404 for missing static-asset paths instead of serving SPA fallback HTML, while preserving client-route fallback behavior for extensionless and non-asset dotted paths. (#12060) thanks @mcaxtr. - Gateway/Pairing: prevent device-token rotate scope escalation by enforcing an approved-scope baseline, preserving approved scopes across metadata updates, and rejecting rotate requests that exceed approved role scope implications. (#20703) thanks @coygeek. - Gateway/Security: require secure context and paired-device checks for Control UI auth even when `gateway.controlUi.allowInsecureAuth` is set, and align audit messaging with the hardened behavior. (#20684) thanks @coygeek. - macOS/Build: default release packaging to `BUNDLE_ID=ai.openclaw.mac` in `scripts/package-mac-dist.sh`, so Sparkle feed URL is retained and auto-update no longer fails with an empty appcast feed. (#19750) thanks @loganprit. diff --git a/src/gateway/control-ui.ts b/src/gateway/control-ui.ts index de1f8e86aaa..4b05f1e349a 100644 --- a/src/gateway/control-ui.ts +++ b/src/gateway/control-ui.ts @@ -61,6 +61,28 @@ function contentTypeForExt(ext: string): string { } } +/** + * Extensions recognised as static assets. Missing files with these extensions + * return 404 instead of the SPA index.html fallback. `.html` is intentionally + * excluded — actual HTML files on disk are served earlier, and missing `.html` + * paths should fall through to the SPA router (client-side routers may use + * `.html`-suffixed routes). + */ +const STATIC_ASSET_EXTENSIONS = new Set([ + ".js", + ".css", + ".json", + ".map", + ".svg", + ".png", + ".jpg", + ".jpeg", + ".gif", + ".webp", + ".ico", + ".txt", +]); + export type ControlUiAvatarResolution = | { kind: "none"; reason: string } | { kind: "local"; filePath: string } @@ -327,6 +349,16 @@ export function handleControlUiHttpRequest( return true; } + // If the requested path looks like a static asset (known extension), return + // 404 rather than falling through to the SPA index.html fallback. We check + // against the same set of extensions that contentTypeForExt() recognises so + // that dotted SPA routes (e.g. /user/jane.doe, /v2.0) still get the + // client-side router fallback. + if (STATIC_ASSET_EXTENSIONS.has(path.extname(fileRel).toLowerCase())) { + respondNotFound(res); + return true; + } + // SPA fallback (client-side router): serve index.html for unknown paths. const indexPath = path.join(root, "index.html"); if (fs.existsSync(indexPath)) { diff --git a/src/gateway/gateway-misc.test.ts b/src/gateway/gateway-misc.test.ts index 8ed1dac1621..4743a2a3649 100644 --- a/src/gateway/gateway-misc.test.ts +++ b/src/gateway/gateway-misc.test.ts @@ -1,6 +1,11 @@ +import * as fs from "node:fs/promises"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import * as os from "node:os"; +import * as path from "node:path"; import { describe, expect, it, test, vi } from "vitest"; import { defaultVoiceWakeTriggers } from "../infra/voicewake.js"; import { GatewayClient } from "./client.js"; +import { handleControlUiHttpRequest } from "./control-ui.js"; import { DEFAULT_DANGEROUS_NODE_COMMANDS, resolveNodeCommandAllowlist, @@ -15,6 +20,15 @@ import { createNodeSubscriptionManager } from "./server-node-subscriptions.js"; import { formatError, normalizeVoiceWakeTriggers } from "./server-utils.js"; import type { GatewayWsClient } from "./server/ws-types.js"; +function makeControlUiResponse() { + const res = { + statusCode: 200, + setHeader: vi.fn(), + end: vi.fn(), + } as unknown as ServerResponse; + return { res }; +} + const wsMockState = vi.hoisted(() => ({ last: null as { url: unknown; opts: unknown } | null, })); @@ -41,6 +55,94 @@ describe("GatewayClient", () => { expect(last?.url).toBe("ws://127.0.0.1:1"); expect(last?.opts).toEqual(expect.objectContaining({ maxPayload: 25 * 1024 * 1024 })); }); + + it("returns 404 for missing static asset paths instead of SPA fallback", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-")); + try { + await fs.writeFile(path.join(tmp, "index.html"), "\n"); + await fs.writeFile(path.join(tmp, "favicon.svg"), ""); + const { res } = makeControlUiResponse(); + const handled = handleControlUiHttpRequest( + { url: "/webchat/favicon.svg", method: "GET" } as IncomingMessage, + res, + { root: { kind: "resolved", path: tmp } }, + ); + expect(handled).toBe(true); + expect(res.statusCode).toBe(404); + } finally { + await fs.rm(tmp, { recursive: true, force: true }); + } + }); + + it("still serves SPA fallback for extensionless paths", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-")); + try { + await fs.writeFile(path.join(tmp, "index.html"), "\n"); + const { res } = makeControlUiResponse(); + const handled = handleControlUiHttpRequest( + { url: "/webchat/chat", method: "GET" } as IncomingMessage, + res, + { root: { kind: "resolved", path: tmp } }, + ); + expect(handled).toBe(true); + expect(res.statusCode).toBe(200); + } finally { + await fs.rm(tmp, { recursive: true, force: true }); + } + }); + + it("HEAD returns 404 for missing static assets consistent with GET", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-")); + try { + await fs.writeFile(path.join(tmp, "index.html"), "\n"); + const { res } = makeControlUiResponse(); + const handled = handleControlUiHttpRequest( + { url: "/webchat/favicon.svg", method: "HEAD" } as IncomingMessage, + res, + { root: { kind: "resolved", path: tmp } }, + ); + expect(handled).toBe(true); + expect(res.statusCode).toBe(404); + } finally { + await fs.rm(tmp, { recursive: true, force: true }); + } + }); + + it("serves SPA fallback for dotted path segments that are not static assets", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-")); + try { + await fs.writeFile(path.join(tmp, "index.html"), "\n"); + for (const route of ["/webchat/user/jane.doe", "/webchat/v2.0", "/settings/v1.2"]) { + const { res } = makeControlUiResponse(); + const handled = handleControlUiHttpRequest( + { url: route, method: "GET" } as IncomingMessage, + res, + { root: { kind: "resolved", path: tmp } }, + ); + expect(handled).toBe(true); + expect(res.statusCode, `expected 200 for ${route}`).toBe(200); + } + } finally { + await fs.rm(tmp, { recursive: true, force: true }); + } + }); + + it("serves SPA fallback for .html paths that do not exist on disk", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-")); + try { + await fs.writeFile(path.join(tmp, "index.html"), "\n"); + const { res } = makeControlUiResponse(); + const handled = handleControlUiHttpRequest( + { url: "/webchat/foo.html", method: "GET" } as IncomingMessage, + res, + { root: { kind: "resolved", path: tmp } }, + ); + expect(handled).toBe(true); + expect(res.statusCode).toBe(200); + } finally { + await fs.rm(tmp, { recursive: true, force: true }); + } + }); }); type TestSocket = {