diff --git a/src/gateway/control-ui.http.test.ts b/src/gateway/control-ui.http.test.ts index 06bca5e35e9..bb376bded4b 100644 --- a/src/gateway/control-ui.http.test.ts +++ b/src/gateway/control-ui.http.test.ts @@ -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", diff --git a/src/gateway/control-ui.ts b/src/gateway/control-ui.ts index 18b8fb98753..e410eb23d17 100644 --- a/src/gateway/control-ui.ts +++ b/src/gateway/control-ui.ts @@ -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; } diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 95e42d6f7c4..ad3260f6b6c 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -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({ diff --git a/src/gateway/server.plugin-http-auth.test.ts b/src/gateway/server.plugin-http-auth.test.ts index ff0a03b094d..bee47b6f34c 100644 --- a/src/gateway/server.plugin-http-auth.test.ts +++ b/src/gateway/server.plugin-http-auth.test.ts @@ -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("diff-view"); + 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",