refactor(gateway): hard-break plugin wildcard http handlers

This commit is contained in:
Peter Steinberger
2026-03-02 16:22:31 +00:00
parent b13d48987c
commit 2fd8264ab0
31 changed files with 347 additions and 174 deletions

View File

@@ -41,6 +41,7 @@ Docs: https://docs.openclaw.ai
- **BREAKING:** Zalo Personal plugin (`@openclaw/zalouser`) no longer depends on external `zca`-compatible CLI binaries (`openzca`, `zca-cli`) for runtime send/listen/login; operators should use `openclaw channels login --channel zalouser` after upgrade to refresh sessions in the new JS-native path.
- **BREAKING:** Node exec approval payloads now require `systemRunPlan`. `host=node` approval requests without that plan are rejected.
- **BREAKING:** Node `system.run` execution now pins path-token commands to the canonical executable path (`realpath`) in both allowlist and approval execution flows. Integrations/tests that asserted token-form argv (for example `tr`) must now accept canonical paths (for example `/usr/bin/tr`).
- **BREAKING:** Plugin SDK removed `api.registerHttpHandler(...)`. Plugins must register explicit HTTP routes via `api.registerHttpRoute({ path, auth, match, handler })`, and dynamic webhook lifecycles should use `registerPluginHttpRoute(...)`.
### Fixes

View File

@@ -10,7 +10,7 @@ If youre looking for **how to use BlueBubbles as an agent/tool user**, see:
- Extension package: `extensions/bluebubbles/` (entry: `index.ts`).
- Channel implementation: `extensions/bluebubbles/src/channel.ts`.
- Webhook handling: `extensions/bluebubbles/src/monitor.ts` (register via `api.registerHttpHandler`).
- Webhook handling: `extensions/bluebubbles/src/monitor.ts` (register per-account route via `registerPluginHttpRoute`).
- REST helpers: `extensions/bluebubbles/src/send.ts` + `extensions/bluebubbles/src/probe.ts`.
- Runtime bridge: `extensions/bluebubbles/src/runtime.ts` (set via `api.runtime`).
- Catalog entry for onboarding: `src/channels/plugins/catalog.ts`.

View File

@@ -1,7 +1,6 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
import { bluebubblesPlugin } from "./src/channel.js";
import { handleBlueBubblesWebhookRequest } from "./src/monitor.js";
import { setBlueBubblesRuntime } from "./src/runtime.js";
const plugin = {
@@ -12,7 +11,6 @@ const plugin = {
register(api: OpenClawPluginApi) {
setBlueBubblesRuntime(api.runtime);
api.registerChannel({ plugin: bluebubblesPlugin });
api.registerHttpHandler(handleBlueBubblesWebhookRequest);
},
};

View File

@@ -530,10 +530,20 @@ export async function monitorBlueBubblesProvider(
path,
statusSink,
});
const unregisterRoute = registerPluginHttpRoute({
path,
auth: "plugin",
match: "exact",
pluginId: "bluebubbles",
accountId: account.accountId,
log: (message) => logVerbose(core, runtime, message),
handler: handleBlueBubblesWebhookRequest,
});
return await new Promise((resolve) => {
const stop = () => {
unregister();
unregisterRoute();
resolve();
};

View File

@@ -4,9 +4,9 @@ import { createMockServerResponse } from "../../src/test-utils/mock-http-respons
import plugin from "./index.js";
describe("diffs plugin registration", () => {
it("registers the tool, http handler, and prompt guidance hook", () => {
it("registers the tool, http route, and prompt guidance hook", () => {
const registerTool = vi.fn();
const registerHttpHandler = vi.fn();
const registerHttpRoute = vi.fn();
const on = vi.fn();
plugin.register?.({
@@ -23,8 +23,7 @@ describe("diffs plugin registration", () => {
},
registerTool,
registerHook() {},
registerHttpHandler,
registerHttpRoute() {},
registerHttpRoute,
registerChannel() {},
registerGatewayMethod() {},
registerCli() {},
@@ -38,7 +37,12 @@ describe("diffs plugin registration", () => {
});
expect(registerTool).toHaveBeenCalledTimes(1);
expect(registerHttpHandler).toHaveBeenCalledTimes(1);
expect(registerHttpRoute).toHaveBeenCalledTimes(1);
expect(registerHttpRoute.mock.calls[0]?.[0]).toMatchObject({
path: "/plugins/diffs",
auth: "plugin",
match: "prefix",
});
expect(on).toHaveBeenCalledTimes(1);
expect(on.mock.calls[0]?.[0]).toBe("before_prompt_build");
});
@@ -47,7 +51,7 @@ describe("diffs plugin registration", () => {
let registeredTool:
| { execute?: (toolCallId: string, params: Record<string, unknown>) => Promise<unknown> }
| undefined;
let registeredHttpHandler:
let registeredHttpRouteHandler:
| ((
req: IncomingMessage,
res: ReturnType<typeof createMockServerResponse>,
@@ -85,10 +89,9 @@ describe("diffs plugin registration", () => {
registeredTool = typeof tool === "function" ? undefined : tool;
},
registerHook() {},
registerHttpHandler(handler) {
registeredHttpHandler = handler as typeof registeredHttpHandler;
registerHttpRoute(params) {
registeredHttpRouteHandler = params.handler as typeof registeredHttpRouteHandler;
},
registerHttpRoute() {},
registerChannel() {},
registerGatewayMethod() {},
registerCli() {},
@@ -109,7 +112,7 @@ describe("diffs plugin registration", () => {
(result as { details?: Record<string, unknown> } | undefined)?.details?.viewerPath,
);
const res = createMockServerResponse();
const handled = await registeredHttpHandler?.(
const handled = await registeredHttpRouteHandler?.(
localReq({
method: "GET",
url: viewerPath,

View File

@@ -25,13 +25,16 @@ const plugin = {
});
api.registerTool(createDiffsTool({ api, store, defaults }));
api.registerHttpHandler(
createDiffsHttpHandler({
api.registerHttpRoute({
path: "/plugins/diffs",
auth: "plugin",
match: "prefix",
handler: createDiffsHttpHandler({
store,
logger: api.logger,
allowRemoteViewer: security.allowRemoteViewer,
}),
);
});
api.on("before_prompt_build", async () => ({
prependContext: DIFFS_AGENT_GUIDANCE,
}));

View File

@@ -434,7 +434,6 @@ function createApi(): OpenClawPluginApi {
},
registerTool() {},
registerHook() {},
registerHttpHandler() {},
registerHttpRoute() {},
registerChannel() {},
registerGatewayMethod() {},

View File

@@ -1,7 +1,6 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
import { googlechatDock, googlechatPlugin } from "./src/channel.js";
import { handleGoogleChatWebhookRequest } from "./src/monitor.js";
import { setGoogleChatRuntime } from "./src/runtime.js";
const plugin = {
@@ -12,7 +11,6 @@ const plugin = {
register(api: OpenClawPluginApi) {
setGoogleChatRuntime(api.runtime);
api.registerChannel({ plugin: googlechatPlugin, dock: googlechatDock });
api.registerHttpHandler(handleGoogleChatWebhookRequest);
},
};

View File

@@ -7,6 +7,7 @@ import {
readJsonBodyWithLimit,
registerPluginHttpRoute,
registerWebhookTarget,
registerPluginHttpRoute,
rejectNonPostWebhookRequest,
isDangerousNameMatchingEnabled,
resolveAllowlistProviderRuntimeGroupPolicy,
@@ -969,7 +970,7 @@ export function monitorGoogleChatProvider(options: GoogleChatMonitorOptions): ()
const audience = options.account.config.audience?.trim();
const mediaMaxMb = options.account.config.mediaMaxMb ?? 20;
const unregister = registerGoogleChatWebhookTarget({
const unregisterTarget = registerGoogleChatWebhookTarget({
account: options.account,
config: options.config,
runtime: options.runtime,
@@ -980,8 +981,20 @@ export function monitorGoogleChatProvider(options: GoogleChatMonitorOptions): ()
statusSink: options.statusSink,
mediaMaxMb,
});
const unregisterRoute = registerPluginHttpRoute({
path: webhookPath,
auth: "plugin",
match: "exact",
pluginId: "googlechat",
accountId: options.account.accountId,
log: (message) => logVerbose(core, options.runtime, message),
handler: handleGoogleChatWebhookRequest,
});
return unregister;
return () => {
unregisterTarget();
unregisterRoute();
};
}
export async function startGoogleChatMonitor(

View File

@@ -38,7 +38,6 @@ function fakeApi(overrides: Partial<OpenClawPluginApi> = {}): OpenClawPluginApi
runtime: { version: "test" } as any,
logger: { info() {}, warn() {}, error() {}, debug() {} },
registerTool() {},
registerHttpHandler() {},
registerChannel() {},
registerGatewayMethod() {},
registerCli() {},

View File

@@ -61,7 +61,12 @@ const plugin = {
log: api.logger,
});
api.registerHttpHandler(httpHandler);
api.registerHttpRoute({
path: "/api/channels/nostr",
auth: "gateway",
match: "prefix",
handler: httpHandler,
});
},
};

View File

@@ -1,7 +1,6 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
import { zaloDock, zaloPlugin } from "./src/channel.js";
import { handleZaloWebhookRequest } from "./src/monitor.js";
import { setZaloRuntime } from "./src/runtime.js";
const plugin = {
@@ -12,7 +11,6 @@ const plugin = {
register(api: OpenClawPluginApi) {
setZaloRuntime(api.runtime);
api.registerChannel({ plugin: zaloPlugin, dock: zaloDock });
api.registerHttpHandler(handleZaloWebhookRequest);
},
};

View File

@@ -653,7 +653,17 @@ export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise<
mediaMaxMb: effectiveMediaMaxMb,
fetcher,
});
const unregisterRoute = registerPluginHttpRoute({
path,
auth: "plugin",
match: "exact",
pluginId: "zalo",
accountId: account.accountId,
log: (message) => logVerbose(core, runtime, message),
handler: handleZaloWebhookRequest,
});
stopHandlers.push(unregister);
stopHandlers.push(unregisterRoute);
abortSignal.addEventListener(
"abort",
() => {

View File

@@ -59,7 +59,7 @@
"build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json",
"build:strict-smoke": "pnpm canvas:a2ui:bundle && tsdown && pnpm build:plugin-sdk:dts",
"canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh",
"check": "pnpm format:check && pnpm tsgo && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope && pnpm check:host-env-policy:swift",
"check": "pnpm format:check && pnpm tsgo && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:plugins:no-register-http-handler && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope && pnpm check:host-env-policy:swift",
"check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-links",
"check:host-env-policy:swift": "node scripts/generate-host-env-security-policy-swift.mjs --check",
"check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500",
@@ -102,6 +102,7 @@
"lint:docs": "pnpm dlx markdownlint-cli2",
"lint:docs:fix": "pnpm dlx markdownlint-cli2 --fix",
"lint:fix": "oxlint --type-aware --fix && pnpm format",
"lint:plugins:no-register-http-handler": "node scripts/check-no-register-http-handler.mjs",
"lint:swift": "swiftlint lint --config .swiftlint.yml && (cd apps/ios && swiftlint lint --config .swiftlint.yml)",
"lint:tmp:channel-agnostic-boundaries": "node scripts/check-channel-agnostic-boundaries.mjs",
"lint:tmp:no-random-messaging": "node scripts/check-no-random-messaging-tmp.mjs",

View File

@@ -70,7 +70,6 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry =>
channels,
providers: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],
cliRegistrars: [],
services: [],

View File

@@ -48,13 +48,17 @@ import {
import { sendGatewayAuthFailure, setDefaultSecurityHeaders } from "./http-common.js";
import { handleOpenAiHttpRequest } from "./openai-http.js";
import { handleOpenResponsesHttpRequest } from "./openresponses-http.js";
import { hasSecurityPathCanonicalizationAnomaly } from "./security-path.js";
import { isProtectedPluginRoutePath } from "./security-path.js";
import {
authorizeCanvasRequest,
enforcePluginRouteGatewayAuth,
isCanvasPath,
} from "./server/http-auth.js";
import {
isProtectedPluginRoutePathFromContext,
resolvePluginRoutePathContext,
type PluginHttpRequestHandler,
type PluginRoutePathContext,
} from "./server/plugins-http.js";
import type { GatewayWsClient } from "./server/ws-types.js";
import { handleToolsInvokeHttpRequest } from "./tools-invoke-http.js";
@@ -81,8 +85,12 @@ const GATEWAY_PROBE_STATUS_BY_PATH = new Map<string, "live" | "ready">([
["/readyz", "ready"],
]);
function shouldEnforceDefaultPluginGatewayAuth(pathname: string): boolean {
return hasSecurityPathCanonicalizationAnomaly(pathname) || isProtectedPluginRoutePath(pathname);
function shouldEnforceDefaultPluginGatewayAuth(pathContext: PluginRoutePathContext): boolean {
return (
pathContext.malformedEncoding ||
pathContext.decodePassLimitReached ||
isProtectedPluginRoutePathFromContext(pathContext)
);
}
function handleGatewayProbeRequest(
@@ -391,8 +399,8 @@ export function createGatewayHttpServer(opts: {
openResponsesConfig?: import("../config/types.gateway.js").GatewayHttpResponsesConfig;
strictTransportSecurityHeader?: string;
handleHooksRequest: HooksRequestHandler;
handlePluginRequest?: HooksRequestHandler;
shouldEnforcePluginGatewayAuth?: (requestPath: string) => boolean;
handlePluginRequest?: PluginHttpRequestHandler;
shouldEnforcePluginGatewayAuth?: (pathContext: PluginRoutePathContext) => boolean;
resolvedAuth: ResolvedGatewayAuth;
/** Optional rate limiter for auth brute-force protection. */
rateLimiter?: AuthRateLimiter;
@@ -445,7 +453,9 @@ export function createGatewayHttpServer(opts: {
req.url = scopedCanvas.rewrittenUrl;
}
const requestPath = new URL(req.url ?? "/", "http://localhost").pathname;
const pluginPathContext = handlePluginRequest
? resolvePluginRoutePathContext(requestPath)
: null;
const requestStages: GatewayHttpRequestStage[] = [
{
name: "hooks",
@@ -466,7 +476,6 @@ export function createGatewayHttpServer(opts: {
run: () => handleSlackHttpRequest(req, res),
},
];
if (openResponsesEnabled) {
requestStages.push({
name: "openresponses",
@@ -550,9 +559,10 @@ export function createGatewayHttpServer(opts: {
requestStages.push({
name: "plugin-auth",
run: async () => {
const pathContext = pluginPathContext ?? resolvePluginRoutePathContext(requestPath);
if (
!(shouldEnforcePluginGatewayAuth ?? shouldEnforceDefaultPluginGatewayAuth)(
requestPath,
pathContext,
)
) {
return false;
@@ -573,7 +583,10 @@ export function createGatewayHttpServer(opts: {
});
requestStages.push({
name: "plugin-http",
run: () => handlePluginRequest(req, res),
run: () => {
const pathContext = pluginPathContext ?? resolvePluginRoutePathContext(requestPath);
return handlePluginRequest(req, res, pathContext);
},
});
}

View File

@@ -18,7 +18,6 @@ const createRegistry = (diagnostics: PluginDiagnostic[]): PluginRegistry => ({
commands: [],
providers: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],
cliRegistrars: [],
services: [],

View File

@@ -30,6 +30,7 @@ import { listenGatewayHttpServer } from "./server/http-listen.js";
import {
createGatewayPluginRequestHandler,
shouldEnforceGatewayAuthForPluginPath,
type PluginRoutePathContext,
} from "./server/plugins-http.js";
import type { GatewayTlsRuntime } from "./server/tls.js";
import type { GatewayWsClient } from "./server/ws-types.js";
@@ -118,8 +119,8 @@ export async function createGatewayRuntimeState(params: {
registry: params.pluginRegistry,
log: params.logPlugins,
});
const shouldEnforcePluginGatewayAuth = (requestPath: string): boolean => {
return shouldEnforceGatewayAuthForPluginPath(params.pluginRegistry, requestPath);
const shouldEnforcePluginGatewayAuth = (pathContext: PluginRoutePathContext): boolean => {
return shouldEnforceGatewayAuthForPluginPath(params.pluginRegistry, pathContext);
};
const bindHosts = await resolveGatewayListenHosts(params.bindHost);

View File

@@ -145,8 +145,9 @@ describe("gateway plugin HTTP auth boundary", () => {
resolvedAuth: AUTH_TOKEN,
overrides: {
handlePluginRequest,
shouldEnforcePluginGatewayAuth: (requestPath) =>
isProtectedPluginRoutePath(requestPath) || requestPath === "/plugin/public",
shouldEnforcePluginGatewayAuth: (pathContext) =>
isProtectedPluginRoutePath(pathContext.pathname) ||
pathContext.pathname === "/plugin/public",
},
run: async (server) => {
const unauthenticated = await sendRequest(server, {
@@ -197,8 +198,9 @@ describe("gateway plugin HTTP auth boundary", () => {
resolvedAuth: AUTH_TOKEN,
overrides: {
handlePluginRequest,
shouldEnforcePluginGatewayAuth: (requestPath) =>
requestPath.startsWith("/api/channels") || requestPath === "/plugin/routed",
shouldEnforcePluginGatewayAuth: (pathContext) =>
pathContext.pathname.startsWith("/api/channels") ||
pathContext.pathname === "/plugin/routed",
},
run: async (server) => {
const unauthenticatedRouted = await sendRequest(server, { path: "/plugin/routed" });
@@ -385,7 +387,8 @@ describe("gateway plugin HTTP auth boundary", () => {
resolvedAuth: AUTH_TOKEN,
overrides: {
handlePluginRequest,
shouldEnforcePluginGatewayAuth: isProtectedPluginRoutePath,
shouldEnforcePluginGatewayAuth: (pathContext) =>
isProtectedPluginRoutePath(pathContext.pathname),
},
run: async (server) => {
await expectUnauthorizedVariants({ server, variants: CANONICAL_UNAUTH_VARIANTS });
@@ -409,7 +412,8 @@ describe("gateway plugin HTTP auth boundary", () => {
resolvedAuth: AUTH_TOKEN,
overrides: {
handlePluginRequest,
shouldEnforcePluginGatewayAuth: isProtectedPluginRoutePath,
shouldEnforcePluginGatewayAuth: (pathContext) =>
isProtectedPluginRoutePath(pathContext.pathname),
},
run: async (server) => {
for (const variant of buildChannelPathFuzzCorpus()) {

View File

@@ -5,7 +5,6 @@ export const createTestRegistry = (overrides: Partial<PluginRegistry> = {}): Plu
return {
...merged,
gatewayHandlers: merged.gatewayHandlers ?? {},
httpHandlers: merged.httpHandlers ?? [],
httpRoutes: merged.httpRoutes ?? [],
};
};

View File

@@ -17,11 +17,15 @@ function createPluginLog(): PluginHandlerLog {
function createRoute(params: {
path: string;
pluginId?: string;
handler?: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>;
auth?: "gateway" | "plugin";
match?: "exact" | "prefix";
handler?: (req: IncomingMessage, res: ServerResponse) => boolean | void | Promise<boolean | void>;
}) {
return {
pluginId: params.pluginId ?? "route",
path: params.path,
auth: params.auth ?? "gateway",
match: params.match ?? "exact",
handler: params.handler ?? (() => {}),
source: params.pluginId ?? "route",
};
@@ -36,7 +40,7 @@ function buildRepeatedEncodedSlash(depth: number): string {
}
describe("createGatewayPluginRequestHandler", () => {
it("returns false when no handlers are registered", async () => {
it("returns false when no routes are registered", async () => {
const log = createPluginLog();
const handler = createGatewayPluginRequestHandler({
registry: createTestRegistry(),
@@ -47,35 +51,13 @@ describe("createGatewayPluginRequestHandler", () => {
expect(handled).toBe(false);
});
it("continues until a handler reports it handled the request", async () => {
const first = vi.fn(async () => false);
const second = vi.fn(async () => true);
const handler = createGatewayPluginRequestHandler({
registry: createTestRegistry({
httpHandlers: [
{ pluginId: "first", handler: first, source: "first" },
{ pluginId: "second", handler: second, source: "second" },
],
}),
log: createPluginLog(),
});
const { res } = makeMockHttpResponse();
const handled = await handler({} as IncomingMessage, res);
expect(handled).toBe(true);
expect(first).toHaveBeenCalledTimes(1);
expect(second).toHaveBeenCalledTimes(1);
});
it("handles registered http routes before generic handlers", async () => {
it("handles exact route matches", async () => {
const routeHandler = vi.fn(async (_req, res: ServerResponse) => {
res.statusCode = 200;
});
const fallback = vi.fn(async () => true);
const handler = createGatewayPluginRequestHandler({
registry: createTestRegistry({
httpRoutes: [createRoute({ path: "/demo", handler: routeHandler })],
httpHandlers: [{ pluginId: "fallback", handler: fallback, source: "fallback" }],
}),
log: createPluginLog(),
});
@@ -84,18 +66,57 @@ describe("createGatewayPluginRequestHandler", () => {
const handled = await handler({ url: "/demo" } as IncomingMessage, res);
expect(handled).toBe(true);
expect(routeHandler).toHaveBeenCalledTimes(1);
expect(fallback).not.toHaveBeenCalled();
});
it("matches canonicalized route variants before generic handlers", async () => {
it("prefers exact matches before prefix matches", async () => {
const exactHandler = vi.fn(async (_req, res: ServerResponse) => {
res.statusCode = 200;
});
const prefixHandler = vi.fn(async () => true);
const handler = createGatewayPluginRequestHandler({
registry: createTestRegistry({
httpRoutes: [
createRoute({ path: "/api", match: "prefix", handler: prefixHandler }),
createRoute({ path: "/api/demo", match: "exact", handler: exactHandler }),
],
}),
log: createPluginLog(),
});
const { res } = makeMockHttpResponse();
const handled = await handler({ url: "/api/demo" } as IncomingMessage, res);
expect(handled).toBe(true);
expect(exactHandler).toHaveBeenCalledTimes(1);
expect(prefixHandler).not.toHaveBeenCalled();
});
it("supports route fallthrough when handler returns false", async () => {
const first = vi.fn(async () => false);
const second = vi.fn(async () => true);
const handler = createGatewayPluginRequestHandler({
registry: createTestRegistry({
httpRoutes: [
createRoute({ path: "/hook", match: "exact", handler: first }),
createRoute({ path: "/hook", match: "prefix", handler: second }),
],
}),
log: createPluginLog(),
});
const { res } = makeMockHttpResponse();
const handled = await handler({ url: "/hook" } as IncomingMessage, res);
expect(handled).toBe(true);
expect(first).toHaveBeenCalledTimes(1);
expect(second).toHaveBeenCalledTimes(1);
});
it("matches canonicalized route variants", async () => {
const routeHandler = vi.fn(async (_req, res: ServerResponse) => {
res.statusCode = 200;
});
const fallback = vi.fn(async () => true);
const handler = createGatewayPluginRequestHandler({
registry: createTestRegistry({
httpRoutes: [createRoute({ path: "/api/demo", handler: routeHandler })],
httpHandlers: [{ pluginId: "fallback", handler: fallback, source: "fallback" }],
}),
log: createPluginLog(),
});
@@ -104,28 +125,26 @@ describe("createGatewayPluginRequestHandler", () => {
const handled = await handler({ url: "/API//demo" } as IncomingMessage, res);
expect(handled).toBe(true);
expect(routeHandler).toHaveBeenCalledTimes(1);
expect(fallback).not.toHaveBeenCalled();
});
it("logs and responds with 500 when a handler throws", async () => {
it("logs and responds with 500 when a route throws", async () => {
const log = createPluginLog();
const handler = createGatewayPluginRequestHandler({
registry: createTestRegistry({
httpHandlers: [
{
pluginId: "boom",
httpRoutes: [
createRoute({
path: "/boom",
handler: async () => {
throw new Error("boom");
},
source: "boom",
},
}),
],
}),
log,
});
const { res, setHeader, end } = makeMockHttpResponse();
const handled = await handler({} as IncomingMessage, res);
const handled = await handler({ url: "/boom" } as IncomingMessage, res);
expect(handled).toBe(true);
expect(log.warn).toHaveBeenCalledWith(expect.stringContaining("boom"));
expect(res.statusCode).toBe(500);
@@ -134,7 +153,7 @@ describe("createGatewayPluginRequestHandler", () => {
});
});
describe("plugin HTTP registry helpers", () => {
describe("plugin HTTP route auth checks", () => {
const deeplyEncodedChannelPath =
"/api%2525252fchannels%2525252fnostr%2525252fdefault%2525252fprofile";
const decodeOverflowPublicPath = `/googlechat${buildRepeatedEncodedSlash(40)}public`;
@@ -156,11 +175,15 @@ describe("plugin HTTP registry helpers", () => {
expect(isRegisteredPluginHttpRoutePath(registry, "/api/%2564emo")).toBe(true);
});
it("enforces auth for protected and registered plugin routes", () => {
it("enforces auth for protected and gateway-auth routes", () => {
const registry = createTestRegistry({
httpRoutes: [createRoute({ path: "/api/demo" })],
httpRoutes: [
createRoute({ path: "/googlechat", match: "prefix", auth: "plugin" }),
createRoute({ path: "/api/demo", auth: "gateway" }),
],
});
expect(shouldEnforceGatewayAuthForPluginPath(registry, "/api//demo")).toBe(true);
expect(shouldEnforceGatewayAuthForPluginPath(registry, "/googlechat/public")).toBe(false);
expect(shouldEnforceGatewayAuthForPluginPath(registry, "/api/channels/status")).toBe(true);
expect(shouldEnforceGatewayAuthForPluginPath(registry, deeplyEncodedChannelPath)).toBe(true);
expect(shouldEnforceGatewayAuthForPluginPath(registry, decodeOverflowPublicPath)).toBe(true);

View File

@@ -1,31 +1,117 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { createSubsystemLogger } from "../../logging/subsystem.js";
import type { PluginRegistry } from "../../plugins/registry.js";
import { canonicalizePathVariant } from "../security-path.js";
import { hasSecurityPathCanonicalizationAnomaly } from "../security-path.js";
import { isProtectedPluginRoutePath } from "../security-path.js";
import {
PROTECTED_PLUGIN_ROUTE_PREFIXES,
canonicalizePathForSecurity,
canonicalizePathVariant,
} from "../security-path.js";
type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
export type PluginRoutePathContext = {
pathname: string;
canonicalPath: string;
candidates: string[];
malformedEncoding: boolean;
decodePassLimitReached: boolean;
rawNormalizedPath: string;
};
export type PluginHttpRequestHandler = (
req: IncomingMessage,
res: ServerResponse,
pathContext?: PluginRoutePathContext,
) => Promise<boolean>;
type PluginHttpRouteEntry = NonNullable<PluginRegistry["httpRoutes"]>[number];
function normalizeProtectedPrefix(prefix: string): string {
const collapsed = prefix.toLowerCase().replace(/\/{2,}/g, "/");
if (collapsed.length <= 1) {
return collapsed || "/";
}
return collapsed.replace(/\/+$/, "");
}
function prefixMatch(pathname: string, prefix: string): boolean {
return (
pathname === prefix || pathname.startsWith(`${prefix}/`) || pathname.startsWith(`${prefix}%`)
);
}
const NORMALIZED_PROTECTED_PLUGIN_ROUTE_PREFIXES =
PROTECTED_PLUGIN_ROUTE_PREFIXES.map(normalizeProtectedPrefix);
export function isProtectedPluginRoutePathFromContext(context: PluginRoutePathContext): boolean {
if (
context.candidates.some((candidate) =>
NORMALIZED_PROTECTED_PLUGIN_ROUTE_PREFIXES.some((prefix) => prefixMatch(candidate, prefix)),
)
) {
return true;
}
if (!context.malformedEncoding) {
return false;
}
return NORMALIZED_PROTECTED_PLUGIN_ROUTE_PREFIXES.some((prefix) =>
prefixMatch(context.rawNormalizedPath, prefix),
);
}
export function resolvePluginRoutePathContext(pathname: string): PluginRoutePathContext {
const canonical = canonicalizePathForSecurity(pathname);
return {
pathname,
canonicalPath: canonical.canonicalPath,
candidates: canonical.candidates,
malformedEncoding: canonical.malformedEncoding,
decodePassLimitReached: canonical.decodePassLimitReached,
rawNormalizedPath: canonical.rawNormalizedPath,
};
}
function doesRouteMatchPath(route: PluginHttpRouteEntry, context: PluginRoutePathContext): boolean {
const routeCanonicalPath = canonicalizePathVariant(route.path);
if (route.match === "prefix") {
return context.candidates.some((candidate) => prefixMatch(candidate, routeCanonicalPath));
}
return context.candidates.some((candidate) => candidate === routeCanonicalPath);
}
function findMatchingPluginHttpRoutes(
registry: PluginRegistry,
context: PluginRoutePathContext,
): PluginHttpRouteEntry[] {
const routes = registry.httpRoutes ?? [];
if (routes.length === 0) {
return [];
}
const exactMatches: PluginHttpRouteEntry[] = [];
const prefixMatches: PluginHttpRouteEntry[] = [];
for (const route of routes) {
if (!doesRouteMatchPath(route, context)) {
continue;
}
if (route.match === "prefix") {
prefixMatches.push(route);
} else {
exactMatches.push(route);
}
}
exactMatches.sort((a, b) => b.path.length - a.path.length);
prefixMatches.sort((a, b) => b.path.length - a.path.length);
return [...exactMatches, ...prefixMatches];
}
export function findRegisteredPluginHttpRoute(
registry: PluginRegistry,
pathname: string,
): PluginHttpRouteEntry | undefined {
const canonicalPath = canonicalizePathVariant(pathname);
const routes = registry.httpRoutes ?? [];
return routes.find((entry) => canonicalizePathVariant(entry.path) === canonicalPath);
const pathContext = resolvePluginRoutePathContext(pathname);
return findMatchingPluginHttpRoutes(registry, pathContext)[0];
}
// Only checks specific routes registered via registerHttpRoute, not wildcard handlers
// registered via registerHttpHandler. Wildcard handlers (e.g., webhooks) implement
// their own signature-based auth and are handled separately in the auth enforcement logic.
export function isRegisteredPluginHttpRoutePath(
registry: PluginRegistry,
pathname: string,
@@ -35,13 +121,23 @@ export function isRegisteredPluginHttpRoutePath(
export function shouldEnforceGatewayAuthForPluginPath(
registry: PluginRegistry,
pathname: string,
pathnameOrContext: string | PluginRoutePathContext,
): boolean {
return (
hasSecurityPathCanonicalizationAnomaly(pathname) ||
isProtectedPluginRoutePath(pathname) ||
isRegisteredPluginHttpRoutePath(registry, pathname)
);
const pathContext =
typeof pathnameOrContext === "string"
? resolvePluginRoutePathContext(pathnameOrContext)
: pathnameOrContext;
if (pathContext.malformedEncoding || pathContext.decodePassLimitReached) {
return true;
}
if (isProtectedPluginRoutePathFromContext(pathContext)) {
return true;
}
const route = findMatchingPluginHttpRoutes(registry, pathContext)[0];
if (!route) {
return false;
}
return route.auth === "gateway";
}
export function createGatewayPluginRequestHandler(params: {
@@ -49,40 +145,31 @@ export function createGatewayPluginRequestHandler(params: {
log: SubsystemLogger;
}): PluginHttpRequestHandler {
const { registry, log } = params;
return async (req, res) => {
return async (req, res, providedPathContext) => {
const routes = registry.httpRoutes ?? [];
const handlers = registry.httpHandlers ?? [];
if (routes.length === 0 && handlers.length === 0) {
if (routes.length === 0) {
return false;
}
if (routes.length > 0) {
const url = new URL(req.url ?? "/", "http://localhost");
const route = findRegisteredPluginHttpRoute(registry, url.pathname);
if (route) {
try {
await route.handler(req, res);
return true;
} catch (err) {
log.warn(`plugin http route failed (${route.pluginId ?? "unknown"}): ${String(err)}`);
if (!res.headersSent) {
res.statusCode = 500;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Internal Server Error");
}
return true;
}
}
const pathContext =
providedPathContext ??
(() => {
const url = new URL(req.url ?? "/", "http://localhost");
return resolvePluginRoutePathContext(url.pathname);
})();
const matchedRoutes = findMatchingPluginHttpRoutes(registry, pathContext);
if (matchedRoutes.length === 0) {
return false;
}
for (const entry of handlers) {
for (const route of matchedRoutes) {
try {
const handled = await entry.handler(req, res);
if (handled) {
const handled = await route.handler(req, res);
if (handled !== false) {
return true;
}
} catch (err) {
log.warn(`plugin http handler failed (${entry.pluginId}): ${String(err)}`);
log.warn(`plugin http route failed (${route.pluginId ?? "unknown"}): ${String(err)}`);
if (!res.headersSent) {
res.statusCode = 500;
res.setHeader("Content-Type", "text/plain; charset=utf-8");

View File

@@ -146,7 +146,6 @@ const createStubPluginRegistry = (): PluginRegistry => ({
],
providers: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],
cliRegistrars: [],
services: [],

View File

@@ -13,7 +13,6 @@ export function createMockPluginRegistry(
source: "test",
})),
tools: [],
httpHandlers: [],
httpRoutes: [],
channelRegistrations: [],
gatewayHandlers: {},

View File

@@ -16,6 +16,8 @@ describe("registerPluginHttpRoute", () => {
expect(registry.httpRoutes).toHaveLength(1);
expect(registry.httpRoutes[0]?.path).toBe("/plugins/demo");
expect(registry.httpRoutes[0]?.handler).toBe(handler);
expect(registry.httpRoutes[0]?.auth).toBe("gateway");
expect(registry.httpRoutes[0]?.match).toBe("exact");
unregister();
expect(registry.httpRoutes).toHaveLength(0);
@@ -64,7 +66,7 @@ describe("registerPluginHttpRoute", () => {
expect(registry.httpRoutes).toHaveLength(1);
expect(registry.httpRoutes[0]?.handler).toBe(secondHandler);
expect(logs).toContain(
'plugin: replacing stale webhook path /plugins/synology for account "default" (synology-chat)',
'plugin: replacing stale webhook path /plugins/synology (exact) for account "default" (synology-chat)',
);
// Old unregister must not remove the replacement route.

View File

@@ -6,12 +6,14 @@ import { requireActivePluginRegistry } from "./runtime.js";
export type PluginHttpRouteHandler = (
req: IncomingMessage,
res: ServerResponse,
) => Promise<void> | void;
) => Promise<boolean | void> | boolean | void;
export function registerPluginHttpRoute(params: {
path?: string | null;
fallbackPath?: string | null;
handler: PluginHttpRouteHandler;
auth?: PluginHttpRouteRegistration["auth"];
match?: PluginHttpRouteRegistration["match"];
pluginId?: string;
source?: string;
accountId?: string;
@@ -29,16 +31,23 @@ export function registerPluginHttpRoute(params: {
return () => {};
}
const existingIndex = routes.findIndex((entry) => entry.path === normalizedPath);
const routeMatch = params.match ?? "exact";
const existingIndex = routes.findIndex(
(entry) => entry.path === normalizedPath && entry.match === routeMatch,
);
if (existingIndex >= 0) {
const pluginHint = params.pluginId ? ` (${params.pluginId})` : "";
params.log?.(`plugin: replacing stale webhook path ${normalizedPath}${suffix}${pluginHint}`);
params.log?.(
`plugin: replacing stale webhook path ${normalizedPath} (${routeMatch})${suffix}${pluginHint}`,
);
routes.splice(existingIndex, 1);
}
const entry: PluginHttpRouteRegistration = {
path: normalizedPath,
handler: params.handler,
auth: params.auth ?? "gateway",
match: routeMatch,
pluginId: params.pluginId,
source: params.source,
};

View File

@@ -518,13 +518,18 @@ describe("loadOpenClawPlugins", () => {
expect(channel).toBeDefined();
});
it("registers http handlers", () => {
it("registers http routes with auth and match options", () => {
useNoBundledPlugins();
const plugin = writePlugin({
id: "http-demo",
filename: "http-demo.cjs",
body: `module.exports = { id: "http-demo", register(api) {
api.registerHttpHandler(async () => false);
api.registerHttpRoute({
path: "/webhook",
auth: "plugin",
match: "prefix",
handler: async () => false
});
} };`,
});
@@ -535,10 +540,13 @@ describe("loadOpenClawPlugins", () => {
},
});
const handler = registry.httpHandlers.find((entry) => entry.pluginId === "http-demo");
expect(handler).toBeDefined();
const route = registry.httpRoutes.find((entry) => entry.pluginId === "http-demo");
expect(route).toBeDefined();
expect(route?.path).toBe("/webhook");
expect(route?.auth).toBe("plugin");
expect(route?.match).toBe("prefix");
const httpPlugin = registry.plugins.find((entry) => entry.id === "http-demo");
expect(httpPlugin?.httpHandlers).toBe(1);
expect(httpPlugin?.httpRoutes).toBe(1);
});
it("registers http routes", () => {
@@ -561,8 +569,10 @@ describe("loadOpenClawPlugins", () => {
const route = registry.httpRoutes.find((entry) => entry.pluginId === "http-route-demo");
expect(route).toBeDefined();
expect(route?.path).toBe("/demo");
expect(route?.auth).toBe("gateway");
expect(route?.match).toBe("exact");
const httpPlugin = registry.plugins.find((entry) => entry.id === "http-route-demo");
expect(httpPlugin?.httpHandlers).toBe(1);
expect(httpPlugin?.httpRoutes).toBe(1);
});
it("respects explicit disable in config", () => {

View File

@@ -176,7 +176,7 @@ function createPluginRecord(params: {
cliCommands: [],
services: [],
commands: [],
httpHandlers: 0,
httpRoutes: 0,
hookCount: 0,
configSchema: params.configSchema,
configUiHints: undefined,

View File

@@ -17,8 +17,10 @@ import type {
OpenClawPluginChannelRegistration,
OpenClawPluginCliRegistrar,
OpenClawPluginCommandDefinition,
OpenClawPluginHttpHandler,
OpenClawPluginHttpRouteAuth,
OpenClawPluginHttpRouteMatch,
OpenClawPluginHttpRouteHandler,
OpenClawPluginHttpRouteParams,
OpenClawPluginHookOptions,
ProviderPlugin,
OpenClawPluginService,
@@ -49,16 +51,12 @@ export type PluginCliRegistration = {
source: string;
};
export type PluginHttpRegistration = {
pluginId: string;
handler: OpenClawPluginHttpHandler;
source: string;
};
export type PluginHttpRouteRegistration = {
pluginId?: string;
path: string;
handler: OpenClawPluginHttpRouteHandler;
auth: OpenClawPluginHttpRouteAuth;
match: OpenClawPluginHttpRouteMatch;
source?: string;
};
@@ -114,7 +112,7 @@ export type PluginRecord = {
cliCommands: string[];
services: string[];
commands: string[];
httpHandlers: number;
httpRoutes: number;
hookCount: number;
configSchema: boolean;
configUiHints?: Record<string, PluginConfigUiHint>;
@@ -129,7 +127,6 @@ export type PluginRegistry = {
channels: PluginChannelRegistration[];
providers: PluginProviderRegistration[];
gatewayHandlers: GatewayRequestHandlers;
httpHandlers: PluginHttpRegistration[];
httpRoutes: PluginHttpRouteRegistration[];
cliRegistrars: PluginCliRegistration[];
services: PluginServiceRegistration[];
@@ -152,7 +149,6 @@ export function createEmptyPluginRegistry(): PluginRegistry {
channels: [],
providers: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],
cliRegistrars: [],
services: [],
@@ -288,19 +284,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
record.gatewayMethods.push(trimmed);
};
const registerHttpHandler = (record: PluginRecord, handler: OpenClawPluginHttpHandler) => {
record.httpHandlers += 1;
registry.httpHandlers.push({
pluginId: record.id,
handler,
source: record.source,
});
};
const registerHttpRoute = (
record: PluginRecord,
params: { path: string; handler: OpenClawPluginHttpRouteHandler },
) => {
const registerHttpRoute = (record: PluginRecord, params: OpenClawPluginHttpRouteParams) => {
const normalizedPath = normalizePluginHttpPath(params.path);
if (!normalizedPath) {
pushDiagnostic({
@@ -311,20 +295,25 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
});
return;
}
if (registry.httpRoutes.some((entry) => entry.path === normalizedPath)) {
const match = params.match ?? "exact";
if (
registry.httpRoutes.some((entry) => entry.path === normalizedPath && entry.match === match)
) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: `http route already registered: ${normalizedPath}`,
message: `http route already registered: ${normalizedPath} (${match})`,
});
return;
}
record.httpHandlers += 1;
record.httpRoutes += 1;
registry.httpRoutes.push({
pluginId: record.id,
path: normalizedPath,
handler: params.handler,
auth: params.auth ?? "gateway",
match,
source: record.source,
});
};
@@ -489,7 +478,6 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
registerTool: (tool, opts) => registerTool(record, tool, opts),
registerHook: (events, handler, opts) =>
registerHook(record, events, handler, opts, params.config),
registerHttpHandler: (handler) => registerHttpHandler(record, handler),
registerHttpRoute: (params) => registerHttpRoute(record, params),
registerChannel: (registration) => registerChannel(record, registration),
registerProvider: (provider) => registerProvider(record, provider),

View File

@@ -194,15 +194,20 @@ export type OpenClawPluginCommandDefinition = {
handler: PluginCommandHandler;
};
export type OpenClawPluginHttpHandler = (
req: IncomingMessage,
res: ServerResponse,
) => Promise<boolean> | boolean;
export type OpenClawPluginHttpRouteAuth = "gateway" | "plugin";
export type OpenClawPluginHttpRouteMatch = "exact" | "prefix";
export type OpenClawPluginHttpRouteHandler = (
req: IncomingMessage,
res: ServerResponse,
) => Promise<void> | void;
) => Promise<boolean | void> | boolean | void;
export type OpenClawPluginHttpRouteParams = {
path: string;
handler: OpenClawPluginHttpRouteHandler;
auth?: OpenClawPluginHttpRouteAuth;
match?: OpenClawPluginHttpRouteMatch;
};
export type OpenClawPluginCliContext = {
program: Command;
@@ -265,8 +270,7 @@ export type OpenClawPluginApi = {
handler: InternalHookHandler,
opts?: OpenClawPluginHookOptions,
) => void;
registerHttpHandler: (handler: OpenClawPluginHttpHandler) => void;
registerHttpRoute: (params: { path: string; handler: OpenClawPluginHttpRouteHandler }) => void;
registerHttpRoute: (params: OpenClawPluginHttpRouteParams) => void;
registerChannel: (registration: OpenClawPluginChannelRegistration | ChannelPlugin) => void;
registerGatewayMethod: (method: string, handler: GatewayRequestHandler) => void;
registerCli: (registrar: OpenClawPluginCliRegistrar, opts?: { commands?: string[] }) => void;

View File

@@ -20,7 +20,6 @@ export const createTestRegistry = (channels: TestChannelRegistration[] = []): Pl
channels: channels as unknown as PluginRegistry["channels"],
providers: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],
cliRegistrars: [],
services: [],