Skip to content

Commit 32d2ca7

Browse files
committed
Gateway/UI: return 404 for missing static assets openclaw#12060 thanks @mcaxtr
1 parent 914a7c5 commit 32d2ca7

File tree

3 files changed

+135
-0
lines changed

3 files changed

+135
-0
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai
1313

1414
### Fixes
1515

16+
- 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.
1617
- 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.
1718
- 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.
1819
- 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.

src/gateway/control-ui.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,28 @@ function contentTypeForExt(ext: string): string {
6161
}
6262
}
6363

64+
/**
65+
* Extensions recognised as static assets. Missing files with these extensions
66+
* return 404 instead of the SPA index.html fallback. `.html` is intentionally
67+
* excluded — actual HTML files on disk are served earlier, and missing `.html`
68+
* paths should fall through to the SPA router (client-side routers may use
69+
* `.html`-suffixed routes).
70+
*/
71+
const STATIC_ASSET_EXTENSIONS = new Set([
72+
".js",
73+
".css",
74+
".json",
75+
".map",
76+
".svg",
77+
".png",
78+
".jpg",
79+
".jpeg",
80+
".gif",
81+
".webp",
82+
".ico",
83+
".txt",
84+
]);
85+
6486
export type ControlUiAvatarResolution =
6587
| { kind: "none"; reason: string }
6688
| { kind: "local"; filePath: string }
@@ -327,6 +349,16 @@ export function handleControlUiHttpRequest(
327349
return true;
328350
}
329351

352+
// If the requested path looks like a static asset (known extension), return
353+
// 404 rather than falling through to the SPA index.html fallback. We check
354+
// against the same set of extensions that contentTypeForExt() recognises so
355+
// that dotted SPA routes (e.g. /user/jane.doe, /v2.0) still get the
356+
// client-side router fallback.
357+
if (STATIC_ASSET_EXTENSIONS.has(path.extname(fileRel).toLowerCase())) {
358+
respondNotFound(res);
359+
return true;
360+
}
361+
330362
// SPA fallback (client-side router): serve index.html for unknown paths.
331363
const indexPath = path.join(root, "index.html");
332364
if (fs.existsSync(indexPath)) {

src/gateway/gateway-misc.test.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
1+
import * as fs from "node:fs/promises";
2+
import type { IncomingMessage, ServerResponse } from "node:http";
3+
import * as os from "node:os";
4+
import * as path from "node:path";
15
import { describe, expect, it, test, vi } from "vitest";
26
import { defaultVoiceWakeTriggers } from "../infra/voicewake.js";
37
import { GatewayClient } from "./client.js";
8+
import { handleControlUiHttpRequest } from "./control-ui.js";
49
import {
510
DEFAULT_DANGEROUS_NODE_COMMANDS,
611
resolveNodeCommandAllowlist,
@@ -15,6 +20,15 @@ import { createNodeSubscriptionManager } from "./server-node-subscriptions.js";
1520
import { formatError, normalizeVoiceWakeTriggers } from "./server-utils.js";
1621
import type { GatewayWsClient } from "./server/ws-types.js";
1722

23+
function makeControlUiResponse() {
24+
const res = {
25+
statusCode: 200,
26+
setHeader: vi.fn(),
27+
end: vi.fn(),
28+
} as unknown as ServerResponse;
29+
return { res };
30+
}
31+
1832
const wsMockState = vi.hoisted(() => ({
1933
last: null as { url: unknown; opts: unknown } | null,
2034
}));
@@ -41,6 +55,94 @@ describe("GatewayClient", () => {
4155
expect(last?.url).toBe("ws://127.0.0.1:1");
4256
expect(last?.opts).toEqual(expect.objectContaining({ maxPayload: 25 * 1024 * 1024 }));
4357
});
58+
59+
it("returns 404 for missing static asset paths instead of SPA fallback", async () => {
60+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-"));
61+
try {
62+
await fs.writeFile(path.join(tmp, "index.html"), "<html></html>\n");
63+
await fs.writeFile(path.join(tmp, "favicon.svg"), "<svg/>");
64+
const { res } = makeControlUiResponse();
65+
const handled = handleControlUiHttpRequest(
66+
{ url: "/webchat/favicon.svg", method: "GET" } as IncomingMessage,
67+
res,
68+
{ root: { kind: "resolved", path: tmp } },
69+
);
70+
expect(handled).toBe(true);
71+
expect(res.statusCode).toBe(404);
72+
} finally {
73+
await fs.rm(tmp, { recursive: true, force: true });
74+
}
75+
});
76+
77+
it("still serves SPA fallback for extensionless paths", async () => {
78+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-"));
79+
try {
80+
await fs.writeFile(path.join(tmp, "index.html"), "<html></html>\n");
81+
const { res } = makeControlUiResponse();
82+
const handled = handleControlUiHttpRequest(
83+
{ url: "/webchat/chat", method: "GET" } as IncomingMessage,
84+
res,
85+
{ root: { kind: "resolved", path: tmp } },
86+
);
87+
expect(handled).toBe(true);
88+
expect(res.statusCode).toBe(200);
89+
} finally {
90+
await fs.rm(tmp, { recursive: true, force: true });
91+
}
92+
});
93+
94+
it("HEAD returns 404 for missing static assets consistent with GET", async () => {
95+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-"));
96+
try {
97+
await fs.writeFile(path.join(tmp, "index.html"), "<html></html>\n");
98+
const { res } = makeControlUiResponse();
99+
const handled = handleControlUiHttpRequest(
100+
{ url: "/webchat/favicon.svg", method: "HEAD" } as IncomingMessage,
101+
res,
102+
{ root: { kind: "resolved", path: tmp } },
103+
);
104+
expect(handled).toBe(true);
105+
expect(res.statusCode).toBe(404);
106+
} finally {
107+
await fs.rm(tmp, { recursive: true, force: true });
108+
}
109+
});
110+
111+
it("serves SPA fallback for dotted path segments that are not static assets", async () => {
112+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-"));
113+
try {
114+
await fs.writeFile(path.join(tmp, "index.html"), "<html></html>\n");
115+
for (const route of ["/webchat/user/jane.doe", "/webchat/v2.0", "/settings/v1.2"]) {
116+
const { res } = makeControlUiResponse();
117+
const handled = handleControlUiHttpRequest(
118+
{ url: route, method: "GET" } as IncomingMessage,
119+
res,
120+
{ root: { kind: "resolved", path: tmp } },
121+
);
122+
expect(handled).toBe(true);
123+
expect(res.statusCode, `expected 200 for ${route}`).toBe(200);
124+
}
125+
} finally {
126+
await fs.rm(tmp, { recursive: true, force: true });
127+
}
128+
});
129+
130+
it("serves SPA fallback for .html paths that do not exist on disk", async () => {
131+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-"));
132+
try {
133+
await fs.writeFile(path.join(tmp, "index.html"), "<html></html>\n");
134+
const { res } = makeControlUiResponse();
135+
const handled = handleControlUiHttpRequest(
136+
{ url: "/webchat/foo.html", method: "GET" } as IncomingMessage,
137+
res,
138+
{ root: { kind: "resolved", path: tmp } },
139+
);
140+
expect(handled).toBe(true);
141+
expect(res.statusCode).toBe(200);
142+
} finally {
143+
await fs.rm(tmp, { recursive: true, force: true });
144+
}
145+
});
44146
});
45147

46148
type TestSocket = {

0 commit comments

Comments
 (0)