Gateway: harden control-ui vs plugin HTTP precedence

This commit is contained in:
Gustavo Madeira Santana
2026-03-01 22:37:37 -05:00
parent 6532757cdf
commit 8e69fd80e0
4 changed files with 118 additions and 1 deletions

View File

@@ -341,6 +341,21 @@ describe("handleControlUiHttpRequest", () => {
});
});
it("does not handle /plugins paths when basePath is empty", async () => {
await withControlUiRoot({
fn: async (tmp) => {
for (const pluginPath of ["/plugins", "/plugins/diffs/view/abc/def"]) {
const { handled } = runControlUiRequest({
url: pluginPath,
method: "GET",
rootPath: tmp,
});
expect(handled, `expected ${pluginPath} to not be handled`).toBe(false);
}
},
});
});
it("rejects absolute-path escape attempts under basePath routes", async () => {
await withBasePathRootFixture({
siblingDir: "ui-secrets",

View File

@@ -292,6 +292,11 @@ export function handleControlUiHttpRequest(
respondNotFound(res);
return true;
}
// Keep plugin-owned HTTP routes outside the root-mounted Control UI SPA
// fallback so untrusted plugins cannot claim arbitrary UI paths.
if (pathname === "/plugins" || pathname.startsWith("/plugins/")) {
return false;
}
if (pathname === "/api" || pathname.startsWith("/api/")) {
return false;
}

View File

@@ -471,7 +471,8 @@ export function createGatewayHttpServer(opts: {
return;
}
}
// Plugins run last so built-in gateway routes keep precedence on overlapping paths.
// Plugins run after built-in gateway routes so core surfaces keep
// precedence on overlapping paths.
if (handlePluginRequest) {
if ((shouldEnforcePluginGatewayAuth ?? isProtectedPluginRoutePath)(requestPath)) {
const pluginAuthOk = await enforcePluginRouteGatewayAuth({

View File

@@ -496,6 +496,102 @@ describe("gateway plugin HTTP auth boundary", () => {
});
});
test("serves plugin routes before control ui spa fallback", async () => {
const resolvedAuth: ResolvedGatewayAuth = {
mode: "none",
token: undefined,
password: undefined,
allowTailscale: false,
};
await withTempConfig({
cfg: { gateway: { trustedProxies: [] } },
prefix: "openclaw-plugin-http-control-ui-precedence-test-",
run: async () => {
const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => {
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
if (pathname === "/plugins/diffs/view/demo-id/demo-token") {
res.statusCode = 200;
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end("<!doctype html><title>diff-view</title>");
return true;
}
return false;
});
const server = createGatewayHttpServer({
canvasHost: null,
clients: new Set(),
controlUiEnabled: true,
controlUiBasePath: "",
controlUiRoot: { kind: "missing" },
openAiChatCompletionsEnabled: false,
openResponsesEnabled: false,
handleHooksRequest: async () => false,
handlePluginRequest,
resolvedAuth,
});
const response = createResponse();
await dispatchRequest(
server,
createRequest({ path: "/plugins/diffs/view/demo-id/demo-token" }),
response.res,
);
expect(response.res.statusCode).toBe(200);
expect(response.getBody()).toContain("diff-view");
expect(handlePluginRequest).toHaveBeenCalledTimes(1);
},
});
});
test("does not let plugin handlers shadow control ui routes", async () => {
const resolvedAuth: ResolvedGatewayAuth = {
mode: "none",
token: undefined,
password: undefined,
allowTailscale: false,
};
await withTempConfig({
cfg: { gateway: { trustedProxies: [] } },
prefix: "openclaw-plugin-http-control-ui-shadow-test-",
run: async () => {
const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => {
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
if (pathname === "/chat") {
res.statusCode = 200;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("plugin-shadow");
return true;
}
return false;
});
const server = createGatewayHttpServer({
canvasHost: null,
clients: new Set(),
controlUiEnabled: true,
controlUiBasePath: "",
controlUiRoot: { kind: "missing" },
openAiChatCompletionsEnabled: false,
openResponsesEnabled: false,
handleHooksRequest: async () => false,
handlePluginRequest,
resolvedAuth,
});
const response = createResponse();
await dispatchRequest(server, createRequest({ path: "/chat" }), response.res);
expect(response.res.statusCode).toBe(503);
expect(response.getBody()).toContain("Control UI assets not found");
expect(handlePluginRequest).not.toHaveBeenCalled();
},
});
});
test("requires gateway auth for canonicalized /api/channels variants", async () => {
const resolvedAuth: ResolvedGatewayAuth = {
mode: "token",