mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
Gateway: harden control-ui vs plugin HTTP precedence
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user