diff --git a/src/gateway/server.plugin-http-auth.test.ts b/src/gateway/server.plugin-http-auth.test.ts index 25568d4803e..79093169c6a 100644 --- a/src/gateway/server.plugin-http-auth.test.ts +++ b/src/gateway/server.plugin-http-auth.test.ts @@ -1,7 +1,9 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import { describe, expect, test, vi } from "vitest"; +import type { createSubsystemLogger } from "../logging/subsystem.js"; import type { ResolvedGatewayAuth } from "./auth.js"; -import { createGatewayHttpServer } from "./server-http.js"; +import type { HooksConfigResolved } from "./hooks.js"; +import { createGatewayHttpServer, createHooksRequestHandler } from "./server-http.js"; import { withTempConfig } from "./test-temp-config.js"; function createRequest(params: { @@ -65,6 +67,25 @@ async function dispatchRequest( await new Promise((resolve) => setImmediate(resolve)); } +function createHooksConfig(): HooksConfigResolved { + return { + basePath: "/hooks", + token: "hook-secret", + maxBodyBytes: 1024, + mappings: [], + agentPolicy: { + defaultAgentId: "main", + knownAgentIds: new Set(["main"]), + allowedAgentIds: undefined, + }, + sessionPolicy: { + allowRequestSessionKey: false, + defaultSessionKey: undefined, + allowedSessionKeyPrefixes: undefined, + }, + }; +} + describe("gateway plugin HTTP auth boundary", () => { test("applies default security headers and optional strict transport security", async () => { const resolvedAuth: ResolvedGatewayAuth = { @@ -220,4 +241,101 @@ describe("gateway plugin HTTP auth boundary", () => { }, }); }); + + test.each(["0.0.0.0", "::"])( + "returns 404 (not 500) for non-hook routes with hooks enabled and bindHost=%s", + async (bindHost) => { + const resolvedAuth: ResolvedGatewayAuth = { + mode: "none", + token: undefined, + password: undefined, + allowTailscale: false, + }; + + await withTempConfig({ + cfg: { gateway: { trustedProxies: [] } }, + prefix: "openclaw-plugin-http-hooks-bindhost-", + run: async () => { + const handleHooksRequest = createHooksRequestHandler({ + getHooksConfig: () => createHooksConfig(), + bindHost, + port: 18789, + logHooks: { + warn: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + error: vi.fn(), + } as unknown as ReturnType, + dispatchWakeHook: () => {}, + dispatchAgentHook: () => "run-1", + }); + const server = createGatewayHttpServer({ + canvasHost: null, + clients: new Set(), + controlUiEnabled: false, + controlUiBasePath: "/__control__", + openAiChatCompletionsEnabled: false, + openResponsesEnabled: false, + handleHooksRequest, + resolvedAuth, + }); + + const response = createResponse(); + await dispatchRequest(server, createRequest({ path: "/" }), response.res); + + expect(response.res.statusCode).toBe(404); + expect(response.getBody()).toBe("Not Found"); + }, + }); + }, + ); + + test("rejects query-token hooks requests with bindHost=::", async () => { + const resolvedAuth: ResolvedGatewayAuth = { + mode: "none", + token: undefined, + password: undefined, + allowTailscale: false, + }; + + await withTempConfig({ + cfg: { gateway: { trustedProxies: [] } }, + prefix: "openclaw-plugin-http-hooks-query-token-", + run: async () => { + const handleHooksRequest = createHooksRequestHandler({ + getHooksConfig: () => createHooksConfig(), + bindHost: "::", + port: 18789, + logHooks: { + warn: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + error: vi.fn(), + } as unknown as ReturnType, + dispatchWakeHook: () => {}, + dispatchAgentHook: () => "run-1", + }); + const server = createGatewayHttpServer({ + canvasHost: null, + clients: new Set(), + controlUiEnabled: false, + controlUiBasePath: "/__control__", + openAiChatCompletionsEnabled: false, + openResponsesEnabled: false, + handleHooksRequest, + resolvedAuth, + }); + + const response = createResponse(); + await dispatchRequest( + server, + createRequest({ path: "/hooks/wake?token=bad" }), + response.res, + ); + + expect(response.res.statusCode).toBe(400); + expect(response.getBody()).toContain("Hook token must be provided"); + }, + }); + }); });