mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 11:25:50 -06:00
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:
committed by
GitHub
parent
72c4d27667
commit
cafb5887fe
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user