fix(gateway): keep SPA fallback away from non-HTML Accept requests (#118048)

* fix(gateway): keep SPA fallback away from non-HTML Accept requests

* fix(gateway): honor zero Accept quality

* fix(gateway): accept text media ranges for SPA
This commit is contained in:
Peter Steinberger
2026-08-02 11:08:14 -07:00
committed by GitHub
parent 72c4d27667
commit cafb5887fe
6 changed files with 306 additions and 12 deletions
+21
View File
@@ -9,6 +9,27 @@ export function isReadHttpMethod(method: string | undefined): boolean {
return method === "GET" || method === "HEAD";
}
/** Returns whether an Accept header permits an HTML document response. */
export function acceptsControlUiHtmlResponse(accept: string | undefined): boolean {
const normalized = accept?.trim();
if (!normalized) {
return true;
}
return normalized.split(",").some((entry) => {
const [rawMediaType, ...parameters] = entry.split(";");
if (parameters.some((parameter) => /^\s*q\s*=\s*0(?:\.0{0,3})?\s*$/i.test(parameter))) {
return false;
}
const mediaType = rawMediaType?.trim().toLowerCase();
return (
mediaType === "*/*" ||
mediaType === "text/*" ||
mediaType === "text/html" ||
mediaType === "application/xhtml+xml"
);
});
}
/** Sends a plain-text response with the standard UTF-8 content type. */
export function respondPlainText(res: ServerResponse, statusCode: number, body: string): void {
res.statusCode = statusCode;
+162 -8
View File
@@ -44,6 +44,160 @@ describe("isControlUiApprovalDocumentPath", () => {
});
});
describe("Control UI SPA fallback Accept routing", () => {
it.each([
{
name: "missing Accept header",
basePath: "",
pathname: "/chat",
method: "GET",
accept: undefined,
expected: true,
},
{
name: "empty Accept header",
basePath: "/openclaw",
pathname: "/openclaw/chat",
method: "HEAD",
accept: " ",
expected: true,
},
{
name: "browser Accept header",
basePath: "",
pathname: "/chat",
method: "GET",
accept: "text/html, application/xhtml+xml;q=0.9, application/xml;q=0.8",
expected: true,
},
{
name: "text wildcard at root",
basePath: "",
pathname: "/chat",
method: "GET",
accept: "text/*",
expected: true,
},
{
name: "nonzero text wildcard under a base path",
basePath: "/openclaw",
pathname: "/openclaw/chat",
method: "HEAD",
accept: "application/json, text/*;q=0.5",
expected: true,
},
{
name: "zero-quality text wildcard rejection",
basePath: "",
pathname: "/chat",
method: "GET",
accept: "text/*;q=0",
expected: false,
},
{
name: "zero-quality HTML rejection",
basePath: "",
pathname: "/chat",
method: "GET",
accept: "text/html;q=0",
expected: false,
},
{
name: "rejected HTML entry with an accepting wildcard",
basePath: "",
pathname: "/chat",
method: "GET",
accept: "text/html;q=0, */*",
expected: true,
},
{
name: "nonzero HTML quality",
basePath: "",
pathname: "/chat",
method: "GET",
accept: "text/html;q=0.5",
expected: true,
},
{
name: "mixed-case zero-quality parameter",
basePath: "",
pathname: "/chat",
method: "GET",
accept: "text/html; Q = 0",
expected: false,
},
{
name: "trailing-dot zero quality",
basePath: "",
pathname: "/chat",
method: "GET",
accept: "text/html; q=0.",
expected: false,
},
{
name: "one-decimal zero quality",
basePath: "",
pathname: "/chat",
method: "GET",
accept: "text/html;q=0.0",
expected: false,
},
{
name: "mixed-case XHTML three-decimal rejection",
basePath: "/openclaw",
pathname: "/openclaw/chat",
method: "HEAD",
accept: "application/json, Application/XHTML+XML ; q=0.000",
expected: false,
},
{
name: "zero-quality wildcard rejection",
basePath: "",
pathname: "/chat",
method: "GET",
accept: "application/json, */*;q=0",
expected: false,
},
{
name: "JSON-only Accept header",
basePath: "",
pathname: "/chat",
method: "GET",
accept: "application/json",
expected: false,
},
{
name: "event-stream Accept header under a base path",
basePath: "/openclaw",
pathname: "/openclaw/chat",
method: "HEAD",
accept: "text/event-stream",
expected: false,
},
{
name: "plugin manager recovery at root",
basePath: "",
pathname: "/settings/plugins",
method: "GET",
accept: "application/json",
expected: true,
},
{
name: "plugin manager recovery under a base path",
basePath: "/openclaw",
pathname: "/openclaw/settings/plugins/",
method: "HEAD",
accept: "text/event-stream",
expected: true,
},
])("classifies $name", ({ basePath, pathname, method, accept, expected }) => {
expect(classifyControlUiRequest({ basePath, pathname, search: "", method, accept })).toEqual({
kind: "serve",
spaFallback: expected,
});
});
});
describe("classifyControlUiRequest", () => {
describe("root-mounted control ui", () => {
it.each([
@@ -51,19 +205,19 @@ describe("classifyControlUiRequest", () => {
name: "serves the root entrypoint",
pathname: "/",
method: "GET",
expected: { kind: "serve" as const },
expected: { kind: "serve" as const, spaFallback: true },
},
{
name: "serves other read-only SPA routes",
pathname: "/chat",
method: "HEAD",
expected: { kind: "serve" as const },
expected: { kind: "serve" as const, spaFallback: true },
},
{
name: "serves the plugin manager without claiming plugin HTTP routes",
pathname: "/settings/plugins",
method: "GET",
expected: { kind: "serve" as const },
expected: { kind: "serve" as const, spaFallback: true },
},
{
name: "keeps health probes outside the SPA catch-all",
@@ -141,7 +295,7 @@ describe("classifyControlUiRequest", () => {
name: "preserves SPA routes that only resemble the standalone MCP App namespace",
pathname: "/__openclaw__/mcp-apps",
method: "GET",
expected: { kind: "serve" as const },
expected: { kind: "serve" as const, spaFallback: true },
},
{
name: "keeps MCP App descendants outside the SPA catch-all",
@@ -165,19 +319,19 @@ describe("classifyControlUiRequest", () => {
name: "preserves SPA routes that only resemble probe paths",
pathname: "/healthcheck",
method: "GET",
expected: { kind: "serve" as const },
expected: { kind: "serve" as const, spaFallback: true },
},
{
name: "preserves the SPA root that only resembles the OpenAI-compatible API",
pathname: "/v12",
method: "GET",
expected: { kind: "serve" as const },
expected: { kind: "serve" as const, spaFallback: true },
},
{
name: "preserves SPA routes that only resemble the OpenAI-compatible API",
pathname: "/v12/models",
method: "GET",
expected: { kind: "serve" as const },
expected: { kind: "serve" as const, spaFallback: true },
},
{
name: "returns not-found for legacy ui routes",
@@ -217,7 +371,7 @@ describe("classifyControlUiRequest", () => {
pathname: "/openclaw/chat",
search: "",
method: "HEAD",
expected: { kind: "serve" as const },
expected: { kind: "serve" as const, spaFallback: true },
},
{
name: "falls through unmatched paths",
+9 -4
View File
@@ -1,5 +1,5 @@
// Control UI route classifier for base-path and root-mounted SPA serving.
import { isReadHttpMethod } from "./control-ui-http-utils.js";
import { acceptsControlUiHtmlResponse, isReadHttpMethod } from "./control-ui-http-utils.js";
import {
classifyGatewayProbePath,
classifyMcpAppStandalonePath,
@@ -9,7 +9,7 @@ type ControlUiRequestClassification =
| { kind: "not-control-ui" }
| { kind: "not-found" }
| { kind: "redirect"; location: string }
| { kind: "serve" };
| { kind: "serve"; spaFallback: boolean };
const CONTROL_UI_PLUGIN_MANAGER_PATH = "/settings/plugins";
@@ -49,8 +49,13 @@ export function classifyControlUiRequest(params: {
pathname: string;
search: string;
method: string | undefined;
accept?: string;
}): ControlUiRequestClassification {
const { basePath, pathname, search, method } = params;
// SPA fallback owns ambiguous browser reads, while plugin recovery is explicit.
// Decline only clearly non-HTML Accept values so headerless/wildcard clients keep working.
const spaFallback =
isControlUiPluginManagerRequest(params) || acceptsControlUiHtmlResponse(params.accept);
if (!basePath) {
if (pathname === "/ui" || pathname.startsWith("/ui/")) {
return { kind: "not-found" };
@@ -80,7 +85,7 @@ export function classifyControlUiRequest(params: {
if (!isReadHttpMethod(method)) {
return { kind: "not-control-ui" };
}
return { kind: "serve" };
return { kind: "serve", spaFallback };
}
if (!pathname.startsWith(`${basePath}/`) && pathname !== basePath) {
@@ -92,5 +97,5 @@ export function classifyControlUiRequest(params: {
if (pathname === basePath) {
return { kind: "redirect", location: `${basePath}/${search}` };
}
return { kind: "serve" };
return { kind: "serve", spaFallback };
}
+29
View File
@@ -2680,6 +2680,35 @@ describe("handleControlUiHttpRequest", () => {
});
});
it("keeps JSON-Accept requests for explicit assets and plugin recovery routes", async () => {
await withControlUiRoot({
indexHtml: "<html><body>plugin-recovery</body></html>\n",
fn: async (tmp) => {
await writeAssetFile(tmp, "actual.txt", "inside-ok\n");
const asset = await runControlUiRequest({
url: "/assets/actual.txt",
method: "GET",
rootPath: tmp,
headers: { accept: "application/json" },
});
expect(asset.handled).toBe(true);
expect(asset.res.statusCode).toBe(200);
expect(responseBody(asset.end)).toBe("inside-ok\n");
const recovery = await runControlUiRequest({
url: "/settings/plugins",
method: "GET",
rootPath: tmp,
headers: { accept: "application/json" },
});
expect(recovery.handled).toBe(true);
expect(recovery.res.statusCode).toBe(200);
expect(responseBody(recovery.end)).toContain("plugin-recovery");
},
});
});
it("compresses bundled assets and caches them immutably", async () => {
await withControlUiRoot({
fn: async (tmp) => {
+5
View File
@@ -1001,6 +1001,7 @@ export async function handleControlUiHttpRequest(
pathname,
search: url.search,
method: req.method,
accept: req.headers?.accept,
});
if (route.kind === "not-control-ui") {
return false;
@@ -1239,6 +1240,10 @@ export async function handleControlUiHttpRequest(
return true;
}
if (!route.spaFallback) {
return false;
}
// SPA fallback (client-side router): serve index.html for unknown paths.
const indexPath = path.join(root, "index.html");
const safeIndex = resolveSafeControlUiFile(rootReal, indexPath, rejectHardlinks);
+80
View File
@@ -166,6 +166,86 @@ describe("startup plugin HTTP routing", () => {
});
});
});
it("uses Accept to route only the unclaimed Control UI SPA fallback", async () => {
await withMarkedControlUiRoot(async (controlUiRoot) => {
let sidecarsReady = false;
await withGatewayServer({
prefix: "startup-plugin-get-accept-root-control-ui",
resolvedAuth: AUTH_NONE,
overrides: {
controlUiEnabled: true,
controlUiBasePath: "",
controlUiRoot: { kind: "resolved", path: controlUiRoot },
handlePluginRequest: async () => false,
shouldEnforcePluginGatewayAuth: () => false,
isStartupPluginRuntimeReady: () => sidecarsReady,
},
run: async (server) => {
const htmlCases = [
{
name: "browser",
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
},
{ name: "bare curl", accept: "*/*" },
{ name: "missing header", accept: undefined },
{ name: "empty header", accept: "" },
{ name: "rejected HTML with wildcard", accept: "text/html;q=0, */*" },
{ name: "nonzero HTML quality", accept: "text/html;q=0.5" },
{ name: "text wildcard", accept: "text/*" },
];
const nonHtmlCases = [
{ name: "JSON", accept: "application/json" },
{ name: "event stream", accept: "text/event-stream" },
{ name: "zero-quality HTML", accept: "text/html;q=0" },
{ name: "zero-quality wildcard", accept: "*/*;q=0" },
{ name: "mixed-case zero quality", accept: "text/html;Q=0" },
{ name: "zero-quality text wildcard", accept: "text/*;q=0" },
];
for (const ready of [false, true]) {
sidecarsReady = ready;
for (const testCase of htmlCases) {
const { res, getBody } = await sendGatewayRequest(server, {
path: "/unclaimed-spa-route",
method: "GET",
headers: testCase.accept === undefined ? undefined : { accept: testCase.accept },
});
expect(res.statusCode, `${testCase.name} ready=${ready}`).toBe(200);
expect(getBody(), `${testCase.name} ready=${ready}`).toContain("spa fallback");
}
for (const testCase of nonHtmlCases) {
const response = createResponse();
await dispatchRequest(
server,
createRequest({
path: "/unclaimed-spa-route",
method: "GET",
headers: { accept: testCase.accept },
}),
response.res,
);
expect(response.res.statusCode, `${testCase.name} ready=${ready}`).toBe(
ready ? 404 : 503,
);
expect(response.setHeader).toHaveBeenCalledWith(
"Content-Type",
"text/plain; charset=utf-8",
);
expect(response.getBody()).toBe(ready ? "Not Found" : "Plugin runtime is starting");
if (ready) {
expect(response.setHeader).not.toHaveBeenCalledWith("Retry-After", "1");
} else {
expect(response.setHeader).toHaveBeenCalledWith("Retry-After", "1");
}
}
}
},
});
});
});
});
describe("standalone MCP App HTTP routing", () => {