mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
refactor(gateway): unify control-ui and plugin webhook routing
This commit is contained in:
@@ -4,6 +4,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import {
|
||||
isRequestBodyLimitError,
|
||||
readRequestBodyWithLimit,
|
||||
registerPluginHttpRoute,
|
||||
registerWebhookTarget,
|
||||
rejectNonPostWebhookRequest,
|
||||
requestBodyErrorToText,
|
||||
@@ -235,7 +236,24 @@ function removeDebouncer(target: WebhookTarget): void {
|
||||
}
|
||||
|
||||
export function registerBlueBubblesWebhookTarget(target: WebhookTarget): () => void {
|
||||
const registered = registerWebhookTarget(webhookTargets, target);
|
||||
const registered = registerWebhookTarget(webhookTargets, target, {
|
||||
onFirstPathTarget: ({ path }) =>
|
||||
registerPluginHttpRoute({
|
||||
path,
|
||||
pluginId: "bluebubbles",
|
||||
source: "bluebubbles-webhook",
|
||||
accountId: target.account.accountId,
|
||||
log: target.runtime.log,
|
||||
handler: async (req, res) => {
|
||||
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
||||
if (!handled && !res.headersSent) {
|
||||
res.statusCode = 404;
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
res.end("Not Found");
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
return () => {
|
||||
registered.unregister();
|
||||
// Clean up debouncer when target is unregistered
|
||||
|
||||
44
extensions/bluebubbles/src/monitor.webhook-route.test.ts
Normal file
44
extensions/bluebubbles/src/monitor.webhook-route.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createEmptyPluginRegistry } from "../../../src/plugins/registry.js";
|
||||
import { setActivePluginRegistry } from "../../../src/plugins/runtime.js";
|
||||
import type { WebhookTarget } from "./monitor-shared.js";
|
||||
import { registerBlueBubblesWebhookTarget } from "./monitor.js";
|
||||
|
||||
function createTarget(): WebhookTarget {
|
||||
return {
|
||||
account: { accountId: "default" } as WebhookTarget["account"],
|
||||
config: {} as OpenClawConfig,
|
||||
runtime: {},
|
||||
core: {} as WebhookTarget["core"],
|
||||
path: "/bluebubbles-webhook",
|
||||
};
|
||||
}
|
||||
|
||||
describe("registerBlueBubblesWebhookTarget", () => {
|
||||
afterEach(() => {
|
||||
setActivePluginRegistry(createEmptyPluginRegistry());
|
||||
});
|
||||
|
||||
it("registers and unregisters plugin HTTP route at path boundaries", () => {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
setActivePluginRegistry(registry);
|
||||
|
||||
const unregisterA = registerBlueBubblesWebhookTarget(createTarget());
|
||||
const unregisterB = registerBlueBubblesWebhookTarget(createTarget());
|
||||
|
||||
expect(registry.httpRoutes).toHaveLength(1);
|
||||
expect(registry.httpRoutes[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
pluginId: "bluebubbles",
|
||||
path: "/bluebubbles-webhook",
|
||||
source: "bluebubbles-webhook",
|
||||
}),
|
||||
);
|
||||
|
||||
unregisterA();
|
||||
expect(registry.httpRoutes).toHaveLength(1);
|
||||
unregisterB();
|
||||
expect(registry.httpRoutes).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
createScopedPairingAccess,
|
||||
createReplyPrefixOptions,
|
||||
readJsonBodyWithLimit,
|
||||
registerPluginHttpRoute,
|
||||
registerWebhookTarget,
|
||||
rejectNonPostWebhookRequest,
|
||||
isDangerousNameMatchingEnabled,
|
||||
@@ -100,7 +101,24 @@ function warnDeprecatedUsersEmailEntries(
|
||||
}
|
||||
|
||||
export function registerGoogleChatWebhookTarget(target: WebhookTarget): () => void {
|
||||
return registerWebhookTarget(webhookTargets, target).unregister;
|
||||
return registerWebhookTarget(webhookTargets, target, {
|
||||
onFirstPathTarget: ({ path }) =>
|
||||
registerPluginHttpRoute({
|
||||
path,
|
||||
pluginId: "googlechat",
|
||||
source: "googlechat-webhook",
|
||||
accountId: target.account.accountId,
|
||||
log: target.runtime.log,
|
||||
handler: async (req, res) => {
|
||||
const handled = await handleGoogleChatWebhookRequest(req, res);
|
||||
if (!handled && !res.headersSent) {
|
||||
res.statusCode = 404;
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
res.end("Not Found");
|
||||
}
|
||||
},
|
||||
}),
|
||||
}).unregister;
|
||||
}
|
||||
|
||||
function normalizeAudienceType(value?: string | null): GoogleChatAudienceType | undefined {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createEmptyPluginRegistry } from "../../../src/plugins/registry.js";
|
||||
import { setActivePluginRegistry } from "../../../src/plugins/runtime.js";
|
||||
import { createMockServerResponse } from "../../../src/test-utils/mock-http-response.js";
|
||||
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
||||
import { verifyGoogleChatRequest } from "./auth.js";
|
||||
@@ -86,6 +88,47 @@ function registerTwoTargets() {
|
||||
}
|
||||
|
||||
describe("Google Chat webhook routing", () => {
|
||||
afterEach(() => {
|
||||
setActivePluginRegistry(createEmptyPluginRegistry());
|
||||
});
|
||||
|
||||
it("registers and unregisters plugin HTTP route at path boundaries", () => {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
setActivePluginRegistry(registry);
|
||||
const unregisterA = registerGoogleChatWebhookTarget({
|
||||
account: baseAccount("A"),
|
||||
config: {} as OpenClawConfig,
|
||||
runtime: {},
|
||||
core: {} as PluginRuntime,
|
||||
path: "/googlechat",
|
||||
statusSink: vi.fn(),
|
||||
mediaMaxMb: 5,
|
||||
});
|
||||
const unregisterB = registerGoogleChatWebhookTarget({
|
||||
account: baseAccount("B"),
|
||||
config: {} as OpenClawConfig,
|
||||
runtime: {},
|
||||
core: {} as PluginRuntime,
|
||||
path: "/googlechat",
|
||||
statusSink: vi.fn(),
|
||||
mediaMaxMb: 5,
|
||||
});
|
||||
|
||||
expect(registry.httpRoutes).toHaveLength(1);
|
||||
expect(registry.httpRoutes[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
pluginId: "googlechat",
|
||||
path: "/googlechat",
|
||||
source: "googlechat-webhook",
|
||||
}),
|
||||
);
|
||||
|
||||
unregisterA();
|
||||
expect(registry.httpRoutes).toHaveLength(1);
|
||||
unregisterB();
|
||||
expect(registry.httpRoutes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects ambiguous routing when multiple targets on the same path verify successfully", async () => {
|
||||
vi.mocked(verifyGoogleChatRequest).mockResolvedValue({ ok: true });
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { MarkdownTableMode, OpenClawConfig, OutboundReplyPayload } from "op
|
||||
import {
|
||||
createScopedPairingAccess,
|
||||
createReplyPrefixOptions,
|
||||
registerPluginHttpRoute,
|
||||
resolveDirectDmAuthorizationOutcome,
|
||||
resolveSenderCommandAuthorizationWithRuntime,
|
||||
resolveOutboundMediaUrls,
|
||||
@@ -75,7 +76,24 @@ function logVerbose(core: ZaloCoreRuntime, runtime: ZaloRuntimeEnv, message: str
|
||||
}
|
||||
|
||||
export function registerZaloWebhookTarget(target: ZaloWebhookTarget): () => void {
|
||||
return registerZaloWebhookTargetInternal(target);
|
||||
return registerZaloWebhookTargetInternal(target, {
|
||||
onFirstPathTarget: ({ path }) =>
|
||||
registerPluginHttpRoute({
|
||||
path,
|
||||
pluginId: "zalo",
|
||||
source: "zalo-webhook",
|
||||
accountId: target.account.accountId,
|
||||
log: target.runtime.log,
|
||||
handler: async (req, res) => {
|
||||
const handled = await handleZaloWebhookRequest(req, res);
|
||||
if (!handled && !res.headersSent) {
|
||||
res.statusCode = 404;
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
res.end("Not Found");
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
|
||||
@@ -2,6 +2,8 @@ import { createServer, type RequestListener } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createEmptyPluginRegistry } from "../../../src/plugins/registry.js";
|
||||
import { setActivePluginRegistry } from "../../../src/plugins/runtime.js";
|
||||
import {
|
||||
clearZaloWebhookSecurityStateForTest,
|
||||
getZaloWebhookRateLimitStateSizeForTest,
|
||||
@@ -95,6 +97,28 @@ function createPairingAuthCore(params?: { storeAllowFrom?: string[]; pairingCrea
|
||||
describe("handleZaloWebhookRequest", () => {
|
||||
afterEach(() => {
|
||||
clearZaloWebhookSecurityStateForTest();
|
||||
setActivePluginRegistry(createEmptyPluginRegistry());
|
||||
});
|
||||
|
||||
it("registers and unregisters plugin HTTP route at path boundaries", () => {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
setActivePluginRegistry(registry);
|
||||
const unregisterA = registerTarget({ path: "/hook" });
|
||||
const unregisterB = registerTarget({ path: "/hook" });
|
||||
|
||||
expect(registry.httpRoutes).toHaveLength(1);
|
||||
expect(registry.httpRoutes[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
pluginId: "zalo",
|
||||
path: "/hook",
|
||||
source: "zalo-webhook",
|
||||
}),
|
||||
);
|
||||
|
||||
unregisterA();
|
||||
expect(registry.httpRoutes).toHaveLength(1);
|
||||
unregisterB();
|
||||
expect(registry.httpRoutes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns 400 for non-object payloads", async () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
createWebhookAnomalyTracker,
|
||||
readJsonWebhookBodyOrReject,
|
||||
applyBasicWebhookRequestGuards,
|
||||
type RegisterWebhookTargetOptions,
|
||||
registerWebhookTarget,
|
||||
resolveSingleWebhookTarget,
|
||||
resolveWebhookTargets,
|
||||
@@ -106,8 +107,14 @@ function recordWebhookStatus(
|
||||
});
|
||||
}
|
||||
|
||||
export function registerZaloWebhookTarget(target: ZaloWebhookTarget): () => void {
|
||||
return registerWebhookTarget(webhookTargets, target).unregister;
|
||||
export function registerZaloWebhookTarget(
|
||||
target: ZaloWebhookTarget,
|
||||
opts?: Pick<
|
||||
RegisterWebhookTargetOptions<ZaloWebhookTarget>,
|
||||
"onFirstPathTarget" | "onLastPathTargetRemoved"
|
||||
>,
|
||||
): () => void {
|
||||
return registerWebhookTarget(webhookTargets, target, opts).unregister;
|
||||
}
|
||||
|
||||
export async function handleZaloWebhookRequest(
|
||||
|
||||
19
src/gateway/control-ui-http-utils.ts
Normal file
19
src/gateway/control-ui-http-utils.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { ServerResponse } from "node:http";
|
||||
|
||||
export function isReadHttpMethod(method: string | undefined): boolean {
|
||||
return method === "GET" || method === "HEAD";
|
||||
}
|
||||
|
||||
export function respondPlainText(res: ServerResponse, statusCode: number, body: string): void {
|
||||
res.statusCode = statusCode;
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
export function respondNotFound(res: ServerResponse): void {
|
||||
respondPlainText(res, 404, "Not Found");
|
||||
}
|
||||
|
||||
export function respondMethodNotAllowed(res: ServerResponse): void {
|
||||
respondPlainText(res, 405, "Method Not Allowed");
|
||||
}
|
||||
54
src/gateway/control-ui-routing.test.ts
Normal file
54
src/gateway/control-ui-routing.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { classifyControlUiRequest } from "./control-ui-routing.js";
|
||||
|
||||
describe("classifyControlUiRequest", () => {
|
||||
it("falls through non-read root requests for plugin webhooks", () => {
|
||||
const classified = classifyControlUiRequest({
|
||||
basePath: "",
|
||||
pathname: "/bluebubbles-webhook",
|
||||
search: "",
|
||||
method: "POST",
|
||||
});
|
||||
expect(classified).toEqual({ kind: "not-control-ui" });
|
||||
});
|
||||
|
||||
it("returns not-found for legacy /ui routes when root-mounted", () => {
|
||||
const classified = classifyControlUiRequest({
|
||||
basePath: "",
|
||||
pathname: "/ui/settings",
|
||||
search: "",
|
||||
method: "GET",
|
||||
});
|
||||
expect(classified).toEqual({ kind: "not-found" });
|
||||
});
|
||||
|
||||
it("returns method-not-allowed for basePath non-read methods", () => {
|
||||
const classified = classifyControlUiRequest({
|
||||
basePath: "/openclaw",
|
||||
pathname: "/openclaw",
|
||||
search: "",
|
||||
method: "POST",
|
||||
});
|
||||
expect(classified).toEqual({ kind: "method-not-allowed" });
|
||||
});
|
||||
|
||||
it("returns redirect for basePath entrypoint GET", () => {
|
||||
const classified = classifyControlUiRequest({
|
||||
basePath: "/openclaw",
|
||||
pathname: "/openclaw",
|
||||
search: "?foo=1",
|
||||
method: "GET",
|
||||
});
|
||||
expect(classified).toEqual({ kind: "redirect", location: "/openclaw/?foo=1" });
|
||||
});
|
||||
|
||||
it("classifies basePath subroutes as control ui", () => {
|
||||
const classified = classifyControlUiRequest({
|
||||
basePath: "/openclaw",
|
||||
pathname: "/openclaw/chat",
|
||||
search: "",
|
||||
method: "HEAD",
|
||||
});
|
||||
expect(classified).toEqual({ kind: "serve" });
|
||||
});
|
||||
});
|
||||
45
src/gateway/control-ui-routing.ts
Normal file
45
src/gateway/control-ui-routing.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { isReadHttpMethod } from "./control-ui-http-utils.js";
|
||||
|
||||
export type ControlUiRequestClassification =
|
||||
| { kind: "not-control-ui" }
|
||||
| { kind: "not-found" }
|
||||
| { kind: "method-not-allowed" }
|
||||
| { kind: "redirect"; location: string }
|
||||
| { kind: "serve" };
|
||||
|
||||
export function classifyControlUiRequest(params: {
|
||||
basePath: string;
|
||||
pathname: string;
|
||||
search: string;
|
||||
method: string | undefined;
|
||||
}): ControlUiRequestClassification {
|
||||
const { basePath, pathname, search, method } = params;
|
||||
if (!basePath) {
|
||||
if (pathname === "/ui" || pathname.startsWith("/ui/")) {
|
||||
return { kind: "not-found" };
|
||||
}
|
||||
// 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 { kind: "not-control-ui" };
|
||||
}
|
||||
if (pathname === "/api" || pathname.startsWith("/api/")) {
|
||||
return { kind: "not-control-ui" };
|
||||
}
|
||||
if (!isReadHttpMethod(method)) {
|
||||
return { kind: "not-control-ui" };
|
||||
}
|
||||
return { kind: "serve" };
|
||||
}
|
||||
|
||||
if (!pathname.startsWith(`${basePath}/`) && pathname !== basePath) {
|
||||
return { kind: "not-control-ui" };
|
||||
}
|
||||
if (!isReadHttpMethod(method)) {
|
||||
return { kind: "method-not-allowed" };
|
||||
}
|
||||
if (pathname === basePath) {
|
||||
return { kind: "redirect", location: `${basePath}/${search}` };
|
||||
}
|
||||
return { kind: "serve" };
|
||||
}
|
||||
@@ -13,6 +13,13 @@ import {
|
||||
type ControlUiBootstrapConfig,
|
||||
} from "./control-ui-contract.js";
|
||||
import { buildControlUiCspHeader } from "./control-ui-csp.js";
|
||||
import {
|
||||
isReadHttpMethod,
|
||||
respondMethodNotAllowed,
|
||||
respondNotFound as respondControlUiNotFound,
|
||||
respondPlainText,
|
||||
} from "./control-ui-http-utils.js";
|
||||
import { classifyControlUiRequest } from "./control-ui-routing.js";
|
||||
import {
|
||||
buildControlUiAvatarUrl,
|
||||
CONTROL_UI_AVATAR_PREFIX,
|
||||
@@ -124,7 +131,7 @@ export function handleControlUiAvatarRequest(
|
||||
if (!urlRaw) {
|
||||
return false;
|
||||
}
|
||||
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||
if (!isReadHttpMethod(req.method)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -143,7 +150,7 @@ export function handleControlUiAvatarRequest(
|
||||
const agentIdParts = pathname.slice(pathWithBase.length).split("/").filter(Boolean);
|
||||
const agentId = agentIdParts[0] ?? "";
|
||||
if (agentIdParts.length !== 1 || !agentId || !isValidAgentId(agentId)) {
|
||||
respondNotFound(res);
|
||||
respondControlUiNotFound(res);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -161,13 +168,13 @@ export function handleControlUiAvatarRequest(
|
||||
|
||||
const resolved = opts.resolveAvatar(agentId);
|
||||
if (resolved.kind !== "local") {
|
||||
respondNotFound(res);
|
||||
respondControlUiNotFound(res);
|
||||
return true;
|
||||
}
|
||||
|
||||
const safeAvatar = resolveSafeAvatarFile(resolved.filePath);
|
||||
if (!safeAvatar) {
|
||||
respondNotFound(res);
|
||||
respondControlUiNotFound(res);
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
@@ -186,12 +193,6 @@ export function handleControlUiAvatarRequest(
|
||||
}
|
||||
}
|
||||
|
||||
function respondNotFound(res: ServerResponse) {
|
||||
res.statusCode = 404;
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
res.end("Not Found");
|
||||
}
|
||||
|
||||
function setStaticFileHeaders(res: ServerResponse, filePath: string) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
res.setHeader("Content-Type", contentTypeForExt(ext));
|
||||
@@ -278,46 +279,30 @@ export function handleControlUiHttpRequest(
|
||||
const url = new URL(urlRaw, "http://localhost");
|
||||
const basePath = normalizeControlUiBasePath(opts?.basePath);
|
||||
const pathname = url.pathname;
|
||||
|
||||
if (!basePath) {
|
||||
if (pathname === "/ui" || pathname.startsWith("/ui/")) {
|
||||
applyControlUiSecurityHeaders(res);
|
||||
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;
|
||||
}
|
||||
// Root-mounted SPA: non-GET/HEAD may be destined for plugin HTTP handlers
|
||||
// (e.g. BlueBubbles webhook POST) that run after Control UI in the chain.
|
||||
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||
return false;
|
||||
}
|
||||
const route = classifyControlUiRequest({
|
||||
basePath,
|
||||
pathname,
|
||||
search: url.search,
|
||||
method: req.method,
|
||||
});
|
||||
if (route.kind === "not-control-ui") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (basePath) {
|
||||
if (!pathname.startsWith(`${basePath}/`) && pathname !== basePath) {
|
||||
return false;
|
||||
}
|
||||
// Requests under a configured basePath are always Control UI traffic.
|
||||
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||
res.statusCode = 405;
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
res.end("Method Not Allowed");
|
||||
return true;
|
||||
}
|
||||
if (pathname === basePath) {
|
||||
applyControlUiSecurityHeaders(res);
|
||||
res.statusCode = 302;
|
||||
res.setHeader("Location", `${basePath}/${url.search}`);
|
||||
res.end();
|
||||
return true;
|
||||
}
|
||||
if (route.kind === "not-found") {
|
||||
applyControlUiSecurityHeaders(res);
|
||||
respondControlUiNotFound(res);
|
||||
return true;
|
||||
}
|
||||
if (route.kind === "method-not-allowed") {
|
||||
respondMethodNotAllowed(res);
|
||||
return true;
|
||||
}
|
||||
if (route.kind === "redirect") {
|
||||
applyControlUiSecurityHeaders(res);
|
||||
res.statusCode = 302;
|
||||
res.setHeader("Location", route.location);
|
||||
res.end();
|
||||
return true;
|
||||
}
|
||||
|
||||
applyControlUiSecurityHeaders(res);
|
||||
@@ -353,17 +338,17 @@ export function handleControlUiHttpRequest(
|
||||
|
||||
const rootState = opts?.root;
|
||||
if (rootState?.kind === "invalid") {
|
||||
res.statusCode = 503;
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
res.end(
|
||||
respondPlainText(
|
||||
res,
|
||||
503,
|
||||
`Control UI assets not found at ${rootState.path}. Build them with \`pnpm ui:build\` (auto-installs UI deps), or update gateway.controlUi.root.`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
if (rootState?.kind === "missing") {
|
||||
res.statusCode = 503;
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
res.end(
|
||||
respondPlainText(
|
||||
res,
|
||||
503,
|
||||
"Control UI assets not found. Build them with `pnpm ui:build` (auto-installs UI deps), or run `pnpm ui:dev` during development.",
|
||||
);
|
||||
return true;
|
||||
@@ -378,9 +363,9 @@ export function handleControlUiHttpRequest(
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
if (!root) {
|
||||
res.statusCode = 503;
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
res.end(
|
||||
respondPlainText(
|
||||
res,
|
||||
503,
|
||||
"Control UI assets not found. Build them with `pnpm ui:build` (auto-installs UI deps), or run `pnpm ui:dev` during development.",
|
||||
);
|
||||
return true;
|
||||
@@ -397,9 +382,9 @@ export function handleControlUiHttpRequest(
|
||||
}
|
||||
})();
|
||||
if (!rootReal) {
|
||||
res.statusCode = 503;
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
res.end(
|
||||
respondPlainText(
|
||||
res,
|
||||
503,
|
||||
"Control UI assets not found. Build them with `pnpm ui:build` (auto-installs UI deps), or run `pnpm ui:dev` during development.",
|
||||
);
|
||||
return true;
|
||||
@@ -420,13 +405,13 @@ export function handleControlUiHttpRequest(
|
||||
const requested = rel && !rel.endsWith("/") ? rel : `${rel}index.html`;
|
||||
const fileRel = requested || "index.html";
|
||||
if (!isSafeRelativePath(fileRel)) {
|
||||
respondNotFound(res);
|
||||
respondControlUiNotFound(res);
|
||||
return true;
|
||||
}
|
||||
|
||||
const filePath = path.resolve(root, fileRel);
|
||||
if (!isWithinDir(root, filePath)) {
|
||||
respondNotFound(res);
|
||||
respondControlUiNotFound(res);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -456,7 +441,7 @@ export function handleControlUiHttpRequest(
|
||||
// that dotted SPA routes (e.g. /user/jane.doe, /v2.0) still get the
|
||||
// client-side router fallback.
|
||||
if (STATIC_ASSET_EXTENSIONS.has(path.extname(fileRel).toLowerCase())) {
|
||||
respondNotFound(res);
|
||||
respondControlUiNotFound(res);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -478,6 +463,6 @@ export function handleControlUiHttpRequest(
|
||||
}
|
||||
}
|
||||
|
||||
respondNotFound(res);
|
||||
respondControlUiNotFound(res);
|
||||
return true;
|
||||
}
|
||||
|
||||
268
src/gateway/server-http.test-harness.ts
Normal file
268
src/gateway/server-http.test-harness.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { expect, vi } from "vitest";
|
||||
import type { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import type { ResolvedGatewayAuth } from "./auth.js";
|
||||
import { createGatewayRequest, createHooksConfig } from "./hooks-test-helpers.js";
|
||||
import { canonicalizePathVariant, isProtectedPluginRoutePath } from "./security-path.js";
|
||||
import { createGatewayHttpServer, createHooksRequestHandler } from "./server-http.js";
|
||||
import { withTempConfig } from "./test-temp-config.js";
|
||||
|
||||
export type GatewayHttpServer = ReturnType<typeof createGatewayHttpServer>;
|
||||
export type GatewayServerOptions = Partial<Parameters<typeof createGatewayHttpServer>[0]>;
|
||||
|
||||
export const AUTH_NONE: ResolvedGatewayAuth = {
|
||||
mode: "none",
|
||||
token: undefined,
|
||||
password: undefined,
|
||||
allowTailscale: false,
|
||||
};
|
||||
|
||||
export const AUTH_TOKEN: ResolvedGatewayAuth = {
|
||||
mode: "token",
|
||||
token: "test-token",
|
||||
password: undefined,
|
||||
allowTailscale: false,
|
||||
};
|
||||
|
||||
export function createRequest(params: {
|
||||
path: string;
|
||||
authorization?: string;
|
||||
method?: string;
|
||||
}): IncomingMessage {
|
||||
return createGatewayRequest({
|
||||
path: params.path,
|
||||
authorization: params.authorization,
|
||||
method: params.method,
|
||||
});
|
||||
}
|
||||
|
||||
export function createResponse(): {
|
||||
res: ServerResponse;
|
||||
setHeader: ReturnType<typeof vi.fn>;
|
||||
end: ReturnType<typeof vi.fn>;
|
||||
getBody: () => string;
|
||||
} {
|
||||
const setHeader = vi.fn();
|
||||
let body = "";
|
||||
const end = vi.fn((chunk?: unknown) => {
|
||||
if (typeof chunk === "string") {
|
||||
body = chunk;
|
||||
return;
|
||||
}
|
||||
if (chunk == null) {
|
||||
body = "";
|
||||
return;
|
||||
}
|
||||
body = JSON.stringify(chunk);
|
||||
});
|
||||
const res = {
|
||||
headersSent: false,
|
||||
statusCode: 200,
|
||||
setHeader,
|
||||
end,
|
||||
} as unknown as ServerResponse;
|
||||
return {
|
||||
res,
|
||||
setHeader,
|
||||
end,
|
||||
getBody: () => body,
|
||||
};
|
||||
}
|
||||
|
||||
export async function dispatchRequest(
|
||||
server: GatewayHttpServer,
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
): Promise<void> {
|
||||
server.emit("request", req, res);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
export async function withGatewayTempConfig(
|
||||
prefix: string,
|
||||
run: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
await withTempConfig({
|
||||
cfg: { gateway: { trustedProxies: [] } },
|
||||
prefix,
|
||||
run,
|
||||
});
|
||||
}
|
||||
|
||||
export function createTestGatewayServer(options: {
|
||||
resolvedAuth: ResolvedGatewayAuth;
|
||||
overrides?: GatewayServerOptions;
|
||||
}): GatewayHttpServer {
|
||||
return createGatewayHttpServer({
|
||||
canvasHost: null,
|
||||
clients: new Set(),
|
||||
controlUiEnabled: false,
|
||||
controlUiBasePath: "/__control__",
|
||||
openAiChatCompletionsEnabled: false,
|
||||
openResponsesEnabled: false,
|
||||
handleHooksRequest: async () => false,
|
||||
...options.overrides,
|
||||
resolvedAuth: options.resolvedAuth,
|
||||
});
|
||||
}
|
||||
|
||||
export async function withGatewayServer(params: {
|
||||
prefix: string;
|
||||
resolvedAuth: ResolvedGatewayAuth;
|
||||
overrides?: GatewayServerOptions;
|
||||
run: (server: GatewayHttpServer) => Promise<void>;
|
||||
}): Promise<void> {
|
||||
await withGatewayTempConfig(params.prefix, async () => {
|
||||
const server = createTestGatewayServer({
|
||||
resolvedAuth: params.resolvedAuth,
|
||||
overrides: params.overrides,
|
||||
});
|
||||
await params.run(server);
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendRequest(
|
||||
server: GatewayHttpServer,
|
||||
params: {
|
||||
path: string;
|
||||
authorization?: string;
|
||||
method?: string;
|
||||
},
|
||||
) {
|
||||
const response = createResponse();
|
||||
await dispatchRequest(server, createRequest(params), response.res);
|
||||
return response;
|
||||
}
|
||||
|
||||
export function expectUnauthorizedResponse(
|
||||
response: ReturnType<typeof createResponse>,
|
||||
label?: string,
|
||||
): void {
|
||||
expect(response.res.statusCode, label).toBe(401);
|
||||
expect(response.getBody(), label).toContain("Unauthorized");
|
||||
}
|
||||
|
||||
export function createCanonicalizedChannelPluginHandler() {
|
||||
return vi.fn(async (req: IncomingMessage, res: ServerResponse) => {
|
||||
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
|
||||
const canonicalPath = canonicalizePathVariant(pathname);
|
||||
if (canonicalPath !== "/api/channels/nostr/default/profile") {
|
||||
return false;
|
||||
}
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
res.end(JSON.stringify({ ok: true, route: "channel-canonicalized" }));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function createHooksHandler(bindHost: string) {
|
||||
return createHooksRequestHandler({
|
||||
getHooksConfig: () => createHooksConfig(),
|
||||
bindHost,
|
||||
port: 18789,
|
||||
logHooks: {
|
||||
warn: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
} as unknown as ReturnType<typeof createSubsystemLogger>,
|
||||
dispatchWakeHook: () => {},
|
||||
dispatchAgentHook: () => "run-1",
|
||||
});
|
||||
}
|
||||
|
||||
export type RouteVariant = {
|
||||
label: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
export const CANONICAL_UNAUTH_VARIANTS: RouteVariant[] = [
|
||||
{ label: "case-variant", path: "/API/channels/nostr/default/profile" },
|
||||
{ label: "encoded-slash", path: "/api/channels%2Fnostr%2Fdefault%2Fprofile" },
|
||||
{
|
||||
label: "encoded-slash-4x",
|
||||
path: "/api%2525252fchannels%2525252fnostr%2525252fdefault%2525252fprofile",
|
||||
},
|
||||
{ label: "encoded-segment", path: "/api/%63hannels/nostr/default/profile" },
|
||||
{ label: "dot-traversal-encoded-slash", path: "/api/foo/..%2fchannels/nostr/default/profile" },
|
||||
{
|
||||
label: "dot-traversal-encoded-dotdot-slash",
|
||||
path: "/api/foo/%2e%2e%2fchannels/nostr/default/profile",
|
||||
},
|
||||
{
|
||||
label: "dot-traversal-double-encoded",
|
||||
path: "/api/foo/%252e%252e%252fchannels/nostr/default/profile",
|
||||
},
|
||||
{ label: "duplicate-slashes", path: "/api/channels//nostr/default/profile" },
|
||||
{ label: "trailing-slash", path: "/api/channels/nostr/default/profile/" },
|
||||
{ label: "malformed-short-percent", path: "/api/channels%2" },
|
||||
{ label: "malformed-double-slash-short-percent", path: "/api//channels%2" },
|
||||
];
|
||||
|
||||
export const CANONICAL_AUTH_VARIANTS: RouteVariant[] = [
|
||||
{ label: "auth-case-variant", path: "/API/channels/nostr/default/profile" },
|
||||
{
|
||||
label: "auth-encoded-slash-4x",
|
||||
path: "/api%2525252fchannels%2525252fnostr%2525252fdefault%2525252fprofile",
|
||||
},
|
||||
{ label: "auth-encoded-segment", path: "/api/%63hannels/nostr/default/profile" },
|
||||
{ label: "auth-duplicate-trailing-slash", path: "/api/channels//nostr/default/profile/" },
|
||||
{
|
||||
label: "auth-dot-traversal-encoded-slash",
|
||||
path: "/api/foo/..%2fchannels/nostr/default/profile",
|
||||
},
|
||||
{
|
||||
label: "auth-dot-traversal-double-encoded",
|
||||
path: "/api/foo/%252e%252e%252fchannels/nostr/default/profile",
|
||||
},
|
||||
];
|
||||
|
||||
export function buildChannelPathFuzzCorpus(): RouteVariant[] {
|
||||
const variants = [
|
||||
"/api/channels/nostr/default/profile",
|
||||
"/API/channels/nostr/default/profile",
|
||||
"/api/foo/..%2fchannels/nostr/default/profile",
|
||||
"/api/foo/%2e%2e%2fchannels/nostr/default/profile",
|
||||
"/api/foo/%252e%252e%252fchannels/nostr/default/profile",
|
||||
"/api/channels//nostr/default/profile/",
|
||||
"/api/channels%2Fnostr%2Fdefault%2Fprofile",
|
||||
"/api/channels%252Fnostr%252Fdefault%252Fprofile",
|
||||
"/api%2525252fchannels%2525252fnostr%2525252fdefault%2525252fprofile",
|
||||
"/api//channels/nostr/default/profile",
|
||||
"/api/channels%2",
|
||||
"/api/channels%zz",
|
||||
"/api//channels%2",
|
||||
"/api//channels%zz",
|
||||
];
|
||||
return variants.map((path) => ({ label: `fuzz:${path}`, path }));
|
||||
}
|
||||
|
||||
export async function expectUnauthorizedVariants(params: {
|
||||
server: GatewayHttpServer;
|
||||
variants: RouteVariant[];
|
||||
}) {
|
||||
for (const variant of params.variants) {
|
||||
const response = await sendRequest(params.server, { path: variant.path });
|
||||
expectUnauthorizedResponse(response, variant.label);
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectAuthorizedVariants(params: {
|
||||
server: GatewayHttpServer;
|
||||
variants: RouteVariant[];
|
||||
authorization: string;
|
||||
}) {
|
||||
for (const variant of params.variants) {
|
||||
const response = await sendRequest(params.server, {
|
||||
path: variant.path,
|
||||
authorization: params.authorization,
|
||||
});
|
||||
expect(response.res.statusCode, variant.label).toBe(200);
|
||||
expect(response.getBody(), variant.label).toContain('"route":"channel-canonicalized"');
|
||||
}
|
||||
}
|
||||
|
||||
export function defaultProtectedPluginRoutePath(pathname: string): boolean {
|
||||
return isProtectedPluginRoutePath(pathname);
|
||||
}
|
||||
@@ -146,6 +146,22 @@ function writeUpgradeAuthFailure(
|
||||
|
||||
export type HooksRequestHandler = (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
|
||||
|
||||
type GatewayHttpRequestStage = {
|
||||
name: string;
|
||||
run: () => Promise<boolean> | boolean;
|
||||
};
|
||||
|
||||
async function runGatewayHttpRequestStages(
|
||||
stages: readonly GatewayHttpRequestStage[],
|
||||
): Promise<boolean> {
|
||||
for (const stage of stages) {
|
||||
if (await stage.run()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function createHooksRequestHandler(
|
||||
opts: {
|
||||
getHooksConfig: () => HooksConfigResolved | null;
|
||||
@@ -429,113 +445,144 @@ export function createGatewayHttpServer(opts: {
|
||||
req.url = scopedCanvas.rewrittenUrl;
|
||||
}
|
||||
const requestPath = new URL(req.url ?? "/", "http://localhost").pathname;
|
||||
if (await handleHooksRequest(req, res)) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
await handleToolsInvokeHttpRequest(req, res, {
|
||||
auth: resolvedAuth,
|
||||
trustedProxies,
|
||||
allowRealIpFallback,
|
||||
rateLimiter,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (await handleSlackHttpRequest(req, res)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestStages: GatewayHttpRequestStage[] = [
|
||||
{
|
||||
name: "hooks",
|
||||
run: () => handleHooksRequest(req, res),
|
||||
},
|
||||
{
|
||||
name: "tools-invoke",
|
||||
run: () =>
|
||||
handleToolsInvokeHttpRequest(req, res, {
|
||||
auth: resolvedAuth,
|
||||
trustedProxies,
|
||||
allowRealIpFallback,
|
||||
rateLimiter,
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "slack",
|
||||
run: () => handleSlackHttpRequest(req, res),
|
||||
},
|
||||
];
|
||||
|
||||
if (openResponsesEnabled) {
|
||||
if (
|
||||
await handleOpenResponsesHttpRequest(req, res, {
|
||||
auth: resolvedAuth,
|
||||
config: openResponsesConfig,
|
||||
trustedProxies,
|
||||
allowRealIpFallback,
|
||||
rateLimiter,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
requestStages.push({
|
||||
name: "openresponses",
|
||||
run: () =>
|
||||
handleOpenResponsesHttpRequest(req, res, {
|
||||
auth: resolvedAuth,
|
||||
config: openResponsesConfig,
|
||||
trustedProxies,
|
||||
allowRealIpFallback,
|
||||
rateLimiter,
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (openAiChatCompletionsEnabled) {
|
||||
if (
|
||||
await handleOpenAiHttpRequest(req, res, {
|
||||
auth: resolvedAuth,
|
||||
trustedProxies,
|
||||
allowRealIpFallback,
|
||||
rateLimiter,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
requestStages.push({
|
||||
name: "openai",
|
||||
run: () =>
|
||||
handleOpenAiHttpRequest(req, res, {
|
||||
auth: resolvedAuth,
|
||||
trustedProxies,
|
||||
allowRealIpFallback,
|
||||
rateLimiter,
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (canvasHost) {
|
||||
if (isCanvasPath(requestPath)) {
|
||||
const ok = await authorizeCanvasRequest({
|
||||
req,
|
||||
auth: resolvedAuth,
|
||||
trustedProxies,
|
||||
allowRealIpFallback,
|
||||
clients,
|
||||
canvasCapability: scopedCanvas.capability,
|
||||
malformedScopedPath: scopedCanvas.malformedScopedPath,
|
||||
rateLimiter,
|
||||
});
|
||||
if (!ok.ok) {
|
||||
sendGatewayAuthFailure(res, ok);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (await handleA2uiHttpRequest(req, res)) {
|
||||
return;
|
||||
}
|
||||
if (await canvasHost.handleHttpRequest(req, res)) {
|
||||
return;
|
||||
}
|
||||
requestStages.push({
|
||||
name: "canvas-auth",
|
||||
run: async () => {
|
||||
if (!isCanvasPath(requestPath)) {
|
||||
return false;
|
||||
}
|
||||
const ok = await authorizeCanvasRequest({
|
||||
req,
|
||||
auth: resolvedAuth,
|
||||
trustedProxies,
|
||||
allowRealIpFallback,
|
||||
clients,
|
||||
canvasCapability: scopedCanvas.capability,
|
||||
malformedScopedPath: scopedCanvas.malformedScopedPath,
|
||||
rateLimiter,
|
||||
});
|
||||
if (!ok.ok) {
|
||||
sendGatewayAuthFailure(res, ok);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
});
|
||||
requestStages.push({
|
||||
name: "a2ui",
|
||||
run: () => handleA2uiHttpRequest(req, res),
|
||||
});
|
||||
requestStages.push({
|
||||
name: "canvas-http",
|
||||
run: () => canvasHost.handleHttpRequest(req, res),
|
||||
});
|
||||
}
|
||||
if (controlUiEnabled) {
|
||||
if (
|
||||
handleControlUiAvatarRequest(req, res, {
|
||||
basePath: controlUiBasePath,
|
||||
resolveAvatar: (agentId) => resolveAgentAvatar(configSnapshot, agentId),
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
handleControlUiHttpRequest(req, res, {
|
||||
basePath: controlUiBasePath,
|
||||
config: configSnapshot,
|
||||
root: controlUiRoot,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
requestStages.push({
|
||||
name: "control-ui-avatar",
|
||||
run: () =>
|
||||
handleControlUiAvatarRequest(req, res, {
|
||||
basePath: controlUiBasePath,
|
||||
resolveAvatar: (agentId) => resolveAgentAvatar(configSnapshot, agentId),
|
||||
}),
|
||||
});
|
||||
requestStages.push({
|
||||
name: "control-ui-http",
|
||||
run: () =>
|
||||
handleControlUiHttpRequest(req, res, {
|
||||
basePath: controlUiBasePath,
|
||||
config: configSnapshot,
|
||||
root: controlUiRoot,
|
||||
}),
|
||||
});
|
||||
}
|
||||
// Plugins run after built-in gateway routes so core surfaces keep
|
||||
// precedence on overlapping paths.
|
||||
if (handlePluginRequest) {
|
||||
if (
|
||||
(shouldEnforcePluginGatewayAuth ?? shouldEnforceDefaultPluginGatewayAuth)(requestPath)
|
||||
) {
|
||||
const pluginAuthOk = await enforcePluginRouteGatewayAuth({
|
||||
req,
|
||||
res,
|
||||
auth: resolvedAuth,
|
||||
trustedProxies,
|
||||
allowRealIpFallback,
|
||||
rateLimiter,
|
||||
});
|
||||
if (!pluginAuthOk) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (await handlePluginRequest(req, res)) {
|
||||
return;
|
||||
}
|
||||
requestStages.push({
|
||||
name: "plugin-auth",
|
||||
run: async () => {
|
||||
if (
|
||||
!(shouldEnforcePluginGatewayAuth ?? shouldEnforceDefaultPluginGatewayAuth)(
|
||||
requestPath,
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const pluginAuthOk = await enforcePluginRouteGatewayAuth({
|
||||
req,
|
||||
res,
|
||||
auth: resolvedAuth,
|
||||
trustedProxies,
|
||||
allowRealIpFallback,
|
||||
rateLimiter,
|
||||
});
|
||||
if (!pluginAuthOk) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
});
|
||||
requestStages.push({
|
||||
name: "plugin-http",
|
||||
run: () => handlePluginRequest(req, res),
|
||||
});
|
||||
}
|
||||
if (handleGatewayProbeRequest(req, res, requestPath)) {
|
||||
|
||||
requestStages.push({
|
||||
name: "gateway-probes",
|
||||
run: () => handleGatewayProbeRequest(req, res, requestPath),
|
||||
});
|
||||
|
||||
if (await runGatewayHttpRequestStages(requestStages)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,269 +1,27 @@
|
||||
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 { createGatewayRequest, createHooksConfig } from "./hooks-test-helpers.js";
|
||||
import { canonicalizePathVariant, isProtectedPluginRoutePath } from "./security-path.js";
|
||||
import { createGatewayHttpServer, createHooksRequestHandler } from "./server-http.js";
|
||||
import { withTempConfig } from "./test-temp-config.js";
|
||||
|
||||
type GatewayHttpServer = ReturnType<typeof createGatewayHttpServer>;
|
||||
type GatewayServerOptions = Partial<Parameters<typeof createGatewayHttpServer>[0]>;
|
||||
|
||||
const AUTH_NONE: ResolvedGatewayAuth = {
|
||||
mode: "none",
|
||||
token: undefined,
|
||||
password: undefined,
|
||||
allowTailscale: false,
|
||||
};
|
||||
|
||||
const AUTH_TOKEN: ResolvedGatewayAuth = {
|
||||
mode: "token",
|
||||
token: "test-token",
|
||||
password: undefined,
|
||||
allowTailscale: false,
|
||||
};
|
||||
|
||||
function createRequest(params: {
|
||||
path: string;
|
||||
authorization?: string;
|
||||
method?: string;
|
||||
}): IncomingMessage {
|
||||
return createGatewayRequest({
|
||||
path: params.path,
|
||||
authorization: params.authorization,
|
||||
method: params.method,
|
||||
});
|
||||
}
|
||||
|
||||
function createResponse(): {
|
||||
res: ServerResponse;
|
||||
setHeader: ReturnType<typeof vi.fn>;
|
||||
end: ReturnType<typeof vi.fn>;
|
||||
getBody: () => string;
|
||||
} {
|
||||
const setHeader = vi.fn();
|
||||
let body = "";
|
||||
const end = vi.fn((chunk?: unknown) => {
|
||||
if (typeof chunk === "string") {
|
||||
body = chunk;
|
||||
return;
|
||||
}
|
||||
if (chunk == null) {
|
||||
body = "";
|
||||
return;
|
||||
}
|
||||
body = JSON.stringify(chunk);
|
||||
});
|
||||
const res = {
|
||||
headersSent: false,
|
||||
statusCode: 200,
|
||||
setHeader,
|
||||
end,
|
||||
} as unknown as ServerResponse;
|
||||
return {
|
||||
res,
|
||||
setHeader,
|
||||
end,
|
||||
getBody: () => body,
|
||||
};
|
||||
}
|
||||
|
||||
async function dispatchRequest(
|
||||
server: GatewayHttpServer,
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
): Promise<void> {
|
||||
server.emit("request", req, res);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
async function withGatewayTempConfig(prefix: string, run: () => Promise<void>): Promise<void> {
|
||||
await withTempConfig({
|
||||
cfg: { gateway: { trustedProxies: [] } },
|
||||
prefix,
|
||||
run,
|
||||
});
|
||||
}
|
||||
|
||||
function createTestGatewayServer(options: {
|
||||
resolvedAuth: ResolvedGatewayAuth;
|
||||
overrides?: GatewayServerOptions;
|
||||
}): GatewayHttpServer {
|
||||
return createGatewayHttpServer({
|
||||
canvasHost: null,
|
||||
clients: new Set(),
|
||||
controlUiEnabled: false,
|
||||
controlUiBasePath: "/__control__",
|
||||
openAiChatCompletionsEnabled: false,
|
||||
openResponsesEnabled: false,
|
||||
handleHooksRequest: async () => false,
|
||||
...options.overrides,
|
||||
resolvedAuth: options.resolvedAuth,
|
||||
});
|
||||
}
|
||||
|
||||
async function withGatewayServer(params: {
|
||||
prefix: string;
|
||||
resolvedAuth: ResolvedGatewayAuth;
|
||||
overrides?: GatewayServerOptions;
|
||||
run: (server: GatewayHttpServer) => Promise<void>;
|
||||
}): Promise<void> {
|
||||
await withGatewayTempConfig(params.prefix, async () => {
|
||||
const server = createTestGatewayServer({
|
||||
resolvedAuth: params.resolvedAuth,
|
||||
overrides: params.overrides,
|
||||
});
|
||||
await params.run(server);
|
||||
});
|
||||
}
|
||||
|
||||
async function sendRequest(
|
||||
server: GatewayHttpServer,
|
||||
params: {
|
||||
path: string;
|
||||
authorization?: string;
|
||||
method?: string;
|
||||
},
|
||||
) {
|
||||
const response = createResponse();
|
||||
await dispatchRequest(server, createRequest(params), response.res);
|
||||
return response;
|
||||
}
|
||||
|
||||
function expectUnauthorizedResponse(
|
||||
response: ReturnType<typeof createResponse>,
|
||||
label?: string,
|
||||
): void {
|
||||
expect(response.res.statusCode, label).toBe(401);
|
||||
expect(response.getBody(), label).toContain("Unauthorized");
|
||||
}
|
||||
import {
|
||||
AUTH_NONE,
|
||||
AUTH_TOKEN,
|
||||
buildChannelPathFuzzCorpus,
|
||||
CANONICAL_AUTH_VARIANTS,
|
||||
CANONICAL_UNAUTH_VARIANTS,
|
||||
createCanonicalizedChannelPluginHandler,
|
||||
createHooksHandler,
|
||||
createTestGatewayServer,
|
||||
expectAuthorizedVariants,
|
||||
expectUnauthorizedResponse,
|
||||
expectUnauthorizedVariants,
|
||||
sendRequest,
|
||||
withGatewayServer,
|
||||
withGatewayTempConfig,
|
||||
} from "./server-http.test-harness.js";
|
||||
|
||||
function canonicalizePluginPath(pathname: string): string {
|
||||
return canonicalizePathVariant(pathname);
|
||||
}
|
||||
|
||||
function createCanonicalizedChannelPluginHandler() {
|
||||
return vi.fn(async (req: IncomingMessage, res: ServerResponse) => {
|
||||
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
|
||||
const canonicalPath = canonicalizePluginPath(pathname);
|
||||
if (canonicalPath !== "/api/channels/nostr/default/profile") {
|
||||
return false;
|
||||
}
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
res.end(JSON.stringify({ ok: true, route: "channel-canonicalized" }));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function createHooksHandler(bindHost: string) {
|
||||
return createHooksRequestHandler({
|
||||
getHooksConfig: () => createHooksConfig(),
|
||||
bindHost,
|
||||
port: 18789,
|
||||
logHooks: {
|
||||
warn: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
} as unknown as ReturnType<typeof createSubsystemLogger>,
|
||||
dispatchWakeHook: () => {},
|
||||
dispatchAgentHook: () => "run-1",
|
||||
});
|
||||
}
|
||||
|
||||
type RouteVariant = {
|
||||
label: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
const CANONICAL_UNAUTH_VARIANTS: RouteVariant[] = [
|
||||
{ label: "case-variant", path: "/API/channels/nostr/default/profile" },
|
||||
{ label: "encoded-slash", path: "/api/channels%2Fnostr%2Fdefault%2Fprofile" },
|
||||
{
|
||||
label: "encoded-slash-4x",
|
||||
path: "/api%2525252fchannels%2525252fnostr%2525252fdefault%2525252fprofile",
|
||||
},
|
||||
{ label: "encoded-segment", path: "/api/%63hannels/nostr/default/profile" },
|
||||
{ label: "dot-traversal-encoded-slash", path: "/api/foo/..%2fchannels/nostr/default/profile" },
|
||||
{
|
||||
label: "dot-traversal-encoded-dotdot-slash",
|
||||
path: "/api/foo/%2e%2e%2fchannels/nostr/default/profile",
|
||||
},
|
||||
{
|
||||
label: "dot-traversal-double-encoded",
|
||||
path: "/api/foo/%252e%252e%252fchannels/nostr/default/profile",
|
||||
},
|
||||
{ label: "duplicate-slashes", path: "/api/channels//nostr/default/profile" },
|
||||
{ label: "trailing-slash", path: "/api/channels/nostr/default/profile/" },
|
||||
{ label: "malformed-short-percent", path: "/api/channels%2" },
|
||||
{ label: "malformed-double-slash-short-percent", path: "/api//channels%2" },
|
||||
];
|
||||
|
||||
const CANONICAL_AUTH_VARIANTS: RouteVariant[] = [
|
||||
{ label: "auth-case-variant", path: "/API/channels/nostr/default/profile" },
|
||||
{
|
||||
label: "auth-encoded-slash-4x",
|
||||
path: "/api%2525252fchannels%2525252fnostr%2525252fdefault%2525252fprofile",
|
||||
},
|
||||
{ label: "auth-encoded-segment", path: "/api/%63hannels/nostr/default/profile" },
|
||||
{ label: "auth-duplicate-trailing-slash", path: "/api/channels//nostr/default/profile/" },
|
||||
{
|
||||
label: "auth-dot-traversal-encoded-slash",
|
||||
path: "/api/foo/..%2fchannels/nostr/default/profile",
|
||||
},
|
||||
{
|
||||
label: "auth-dot-traversal-double-encoded",
|
||||
path: "/api/foo/%252e%252e%252fchannels/nostr/default/profile",
|
||||
},
|
||||
];
|
||||
|
||||
function buildChannelPathFuzzCorpus(): RouteVariant[] {
|
||||
const variants = [
|
||||
"/api/channels/nostr/default/profile",
|
||||
"/API/channels/nostr/default/profile",
|
||||
"/api/foo/..%2fchannels/nostr/default/profile",
|
||||
"/api/foo/%2e%2e%2fchannels/nostr/default/profile",
|
||||
"/api/foo/%252e%252e%252fchannels/nostr/default/profile",
|
||||
"/api/channels//nostr/default/profile/",
|
||||
"/api/channels%2Fnostr%2Fdefault%2Fprofile",
|
||||
"/api/channels%252Fnostr%252Fdefault%252Fprofile",
|
||||
"/api%2525252fchannels%2525252fnostr%2525252fdefault%2525252fprofile",
|
||||
"/api//channels/nostr/default/profile",
|
||||
"/api/channels%2",
|
||||
"/api/channels%zz",
|
||||
"/api//channels%2",
|
||||
"/api//channels%zz",
|
||||
];
|
||||
return variants.map((path) => ({ label: `fuzz:${path}`, path }));
|
||||
}
|
||||
|
||||
async function expectUnauthorizedVariants(params: {
|
||||
server: GatewayHttpServer;
|
||||
variants: RouteVariant[];
|
||||
}) {
|
||||
for (const variant of params.variants) {
|
||||
const response = await sendRequest(params.server, { path: variant.path });
|
||||
expectUnauthorizedResponse(response, variant.label);
|
||||
}
|
||||
}
|
||||
|
||||
async function expectAuthorizedVariants(params: {
|
||||
server: GatewayHttpServer;
|
||||
variants: RouteVariant[];
|
||||
authorization: string;
|
||||
}) {
|
||||
for (const variant of params.variants) {
|
||||
const response = await sendRequest(params.server, {
|
||||
path: variant.path,
|
||||
authorization: params.authorization,
|
||||
});
|
||||
expect(response.res.statusCode, variant.label).toBe(200);
|
||||
expect(response.getBody(), variant.label).toContain('"route":"channel-canonicalized"');
|
||||
}
|
||||
}
|
||||
|
||||
describe("gateway plugin HTTP auth boundary", () => {
|
||||
test("applies default security headers and optional strict transport security", async () => {
|
||||
await withGatewayTempConfig("openclaw-plugin-http-security-headers-test-", async () => {
|
||||
|
||||
@@ -128,7 +128,7 @@ export {
|
||||
resolveSingleWebhookTargetAsync,
|
||||
resolveWebhookTargets,
|
||||
} from "./webhook-targets.js";
|
||||
export type { WebhookTargetMatchResult } from "./webhook-targets.js";
|
||||
export type { RegisterWebhookTargetOptions, WebhookTargetMatchResult } from "./webhook-targets.js";
|
||||
export {
|
||||
applyBasicWebhookRequestGuards,
|
||||
isJsonContentType,
|
||||
|
||||
@@ -31,6 +31,59 @@ describe("registerWebhookTarget", () => {
|
||||
registered.unregister();
|
||||
expect(targets.has("/hook")).toBe(false);
|
||||
});
|
||||
|
||||
it("runs first/last path lifecycle hooks only at path boundaries", () => {
|
||||
const targets = new Map<string, Array<{ path: string; id: string }>>();
|
||||
const teardown = vi.fn();
|
||||
const onFirstPathTarget = vi.fn(() => teardown);
|
||||
const onLastPathTargetRemoved = vi.fn();
|
||||
|
||||
const registeredA = registerWebhookTarget(
|
||||
targets,
|
||||
{ path: "hook", id: "A" },
|
||||
{ onFirstPathTarget, onLastPathTargetRemoved },
|
||||
);
|
||||
const registeredB = registerWebhookTarget(
|
||||
targets,
|
||||
{ path: "/hook", id: "B" },
|
||||
{ onFirstPathTarget, onLastPathTargetRemoved },
|
||||
);
|
||||
|
||||
expect(onFirstPathTarget).toHaveBeenCalledTimes(1);
|
||||
expect(onFirstPathTarget).toHaveBeenCalledWith({
|
||||
path: "/hook",
|
||||
target: expect.objectContaining({ id: "A", path: "/hook" }),
|
||||
});
|
||||
|
||||
registeredB.unregister();
|
||||
expect(teardown).not.toHaveBeenCalled();
|
||||
expect(onLastPathTargetRemoved).not.toHaveBeenCalled();
|
||||
|
||||
registeredA.unregister();
|
||||
expect(teardown).toHaveBeenCalledTimes(1);
|
||||
expect(onLastPathTargetRemoved).toHaveBeenCalledTimes(1);
|
||||
expect(onLastPathTargetRemoved).toHaveBeenCalledWith({ path: "/hook" });
|
||||
|
||||
registeredA.unregister();
|
||||
expect(teardown).toHaveBeenCalledTimes(1);
|
||||
expect(onLastPathTargetRemoved).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not register target when first-path hook throws", () => {
|
||||
const targets = new Map<string, Array<{ path: string; id: string }>>();
|
||||
expect(() =>
|
||||
registerWebhookTarget(
|
||||
targets,
|
||||
{ path: "/hook", id: "A" },
|
||||
{
|
||||
onFirstPathTarget: () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
},
|
||||
),
|
||||
).toThrow("boom");
|
||||
expect(targets.has("/hook")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveWebhookTargets", () => {
|
||||
|
||||
@@ -6,21 +6,65 @@ export type RegisteredWebhookTarget<T> = {
|
||||
unregister: () => void;
|
||||
};
|
||||
|
||||
export type RegisterWebhookTargetOptions<T extends { path: string }> = {
|
||||
onFirstPathTarget?: (params: { path: string; target: T }) => void | (() => void);
|
||||
onLastPathTargetRemoved?: (params: { path: string }) => void;
|
||||
};
|
||||
|
||||
const pathTeardownByTargetMap = new WeakMap<Map<string, unknown[]>, Map<string, () => void>>();
|
||||
|
||||
function getPathTeardownMap<T>(targetsByPath: Map<string, T[]>): Map<string, () => void> {
|
||||
const mapKey = targetsByPath as unknown as Map<string, unknown[]>;
|
||||
const existing = pathTeardownByTargetMap.get(mapKey);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const created = new Map<string, () => void>();
|
||||
pathTeardownByTargetMap.set(mapKey, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
export function registerWebhookTarget<T extends { path: string }>(
|
||||
targetsByPath: Map<string, T[]>,
|
||||
target: T,
|
||||
opts?: RegisterWebhookTargetOptions<T>,
|
||||
): RegisteredWebhookTarget<T> {
|
||||
const key = normalizeWebhookPath(target.path);
|
||||
const normalizedTarget = { ...target, path: key };
|
||||
const existing = targetsByPath.get(key) ?? [];
|
||||
|
||||
if (existing.length === 0) {
|
||||
const onFirstPathResult = opts?.onFirstPathTarget?.({
|
||||
path: key,
|
||||
target: normalizedTarget,
|
||||
});
|
||||
if (typeof onFirstPathResult === "function") {
|
||||
getPathTeardownMap(targetsByPath).set(key, onFirstPathResult);
|
||||
}
|
||||
}
|
||||
|
||||
targetsByPath.set(key, [...existing, normalizedTarget]);
|
||||
|
||||
let isActive = true;
|
||||
const unregister = () => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
isActive = false;
|
||||
|
||||
const updated = (targetsByPath.get(key) ?? []).filter((entry) => entry !== normalizedTarget);
|
||||
if (updated.length > 0) {
|
||||
targetsByPath.set(key, updated);
|
||||
return;
|
||||
}
|
||||
targetsByPath.delete(key);
|
||||
|
||||
const teardown = getPathTeardownMap(targetsByPath).get(key);
|
||||
if (teardown) {
|
||||
getPathTeardownMap(targetsByPath).delete(key);
|
||||
teardown();
|
||||
}
|
||||
opts?.onLastPathTargetRemoved?.({ path: key });
|
||||
};
|
||||
return { target: normalizedTarget, unregister };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user