OpenClaw < 2026.3.2 - Authentication Bypass via Encoded Path in /api/channels Route
Description
OpenClaw versions prior to 2026.3.2 contain an authentication bypass vulnerability in the /api/channels route classification due to canonicalization depth mismatch between auth-path classification and route-path canonicalization. Attackers can bypass plugin route authentication checks by submitting deeply encoded slash variants such as multi-encoded %2f to access protected /api/channels endpoints.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.3.2 | 2026.3.2 |
Affected products
1Patches
47a7eee920a17refactor(gateway): harden plugin http route contracts
23 files changed · +642 −270
CHANGELOG.md+1 −0 modified@@ -56,6 +56,7 @@ Docs: https://docs.openclaw.ai - Zalouser/Pairing auth tests: add account-scoped DM pairing-store regression coverage (`monitor.account-scope.test.ts`) to prevent cross-account allowlist bleed in multi-account setups. (#26672) Thanks @bmendonca3. - Security/Web tools SSRF guard: keep DNS pinning for untrusted `web_fetch` and citation-redirect URL checks when proxy env vars are set, and require explicit dangerous opt-in before env-proxy routing can bypass pinned dispatch for trusted/operator-controlled endpoints. Thanks @tdjackey for reporting. - Gateway/Security canonicalization hardening: decode plugin route path variants to canonical fixpoint (with bounded depth), fail closed on canonicalization anomalies, and enforce gateway auth for deeply encoded `/api/channels/*` variants to prevent alternate-path auth bypass through plugin handlers. Thanks @tdjackey for reporting. +- Gateway/Plugin HTTP hardening: require explicit `auth` for plugin route registration, add route ownership guards for duplicate `path+match` registrations, centralize plugin path matching/auth logic into dedicated modules, and share webhook target-route lifecycle wiring across channel monitors to avoid stale or conflicting registrations. Thanks @tdjackey for reporting. - Agents/Sessions list transcript paths: handle missing/non-string/relative `sessions.list.path` values and per-agent `{agentId}` templates when deriving `transcriptPath`, so cross-agent session listings resolve to concrete agent session files instead of workspace-relative paths. (#24775) Thanks @martinfrancois. - Agents/Subagents `sessions_spawn`: reject malformed `agentId` inputs before normalization (for example error-message/path-like strings) to prevent unintended synthetic agent IDs and ghost workspace/session paths; includes strict validation regression coverage. (#31381) Thanks @openperf. - macOS/PeekabooBridge: add compatibility socket symlinks for legacy `clawdbot`, `clawdis`, and `moltbot` Application Support socket paths so pre-rename clients can still connect. (#6033) Thanks @lumpinif and @vincentkoc.
extensions/bluebubbles/src/monitor.ts+20 −29 modified@@ -4,8 +4,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk"; import { isRequestBodyLimitError, readRequestBodyWithLimit, - registerPluginHttpRoute, - registerWebhookTarget, + registerWebhookTargetWithPluginRoute, rejectNonPostWebhookRequest, requestBodyErrorToText, resolveSingleWebhookTarget, @@ -236,23 +235,25 @@ function removeDebouncer(target: WebhookTarget): void { } export function registerBlueBubblesWebhookTarget(target: WebhookTarget): () => void { - 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"); - } - }, - }), + const registered = registerWebhookTargetWithPluginRoute({ + targetsByPath: webhookTargets, + target, + route: { + auth: "plugin", + match: "exact", + 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(); @@ -530,20 +531,10 @@ 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(); };
extensions/googlechat/src/monitor.ts+20 −30 modified@@ -5,9 +5,7 @@ import { createScopedPairingAccess, createReplyPrefixOptions, readJsonBodyWithLimit, - registerPluginHttpRoute, - registerWebhookTarget, - registerPluginHttpRoute, + registerWebhookTargetWithPluginRoute, rejectNonPostWebhookRequest, isDangerousNameMatchingEnabled, resolveAllowlistProviderRuntimeGroupPolicy, @@ -102,23 +100,25 @@ function warnDeprecatedUsersEmailEntries( } export function registerGoogleChatWebhookTarget(target: WebhookTarget): () => void { - 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"); - } - }, - }), + return registerWebhookTargetWithPluginRoute({ + targetsByPath: webhookTargets, + target, + route: { + auth: "plugin", + match: "exact", + 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; } @@ -981,19 +981,9 @@ 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 () => { unregisterTarget(); - unregisterRoute(); }; }
extensions/phone-control/index.test.ts+0 −1 modified@@ -33,7 +33,6 @@ function createApi(params: { logger: { info() {}, warn() {}, error() {} }, registerTool() {}, registerHook() {}, - registerHttpHandler() {}, registerHttpRoute() {}, registerChannel() {}, registerGatewayMethod() {},
extensions/synology-chat/src/channel.ts+2 −0 modified@@ -295,6 +295,8 @@ export function createSynologyChatPlugin() { const unregister = registerPluginHttpRoute({ path: account.webhookPath, + auth: "plugin", + replaceExisting: true, pluginId: CHANNEL_ID, accountId: account.accountId, log: (msg: string) => log?.info?.(msg),
extensions/zalo/src/monitor.ts+16 −27 modified@@ -3,7 +3,6 @@ import type { MarkdownTableMode, OpenClawConfig, OutboundReplyPayload } from "op import { createScopedPairingAccess, createReplyPrefixOptions, - registerPluginHttpRoute, resolveDirectDmAuthorizationOutcome, resolveSenderCommandAuthorizationWithRuntime, resolveOutboundMediaUrls, @@ -77,22 +76,22 @@ function logVerbose(core: ZaloCoreRuntime, runtime: ZaloRuntimeEnv, message: str export function registerZaloWebhookTarget(target: ZaloWebhookTarget): () => void { 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"); - } - }, - }), + route: { + auth: "plugin", + match: "exact", + 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"); + } + }, + }, }); } @@ -653,17 +652,7 @@ 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", () => {
extensions/zalo/src/monitor.webhook.ts+13 −1 modified@@ -7,7 +7,9 @@ import { createWebhookAnomalyTracker, readJsonWebhookBodyOrReject, applyBasicWebhookRequestGuards, + registerWebhookTargetWithPluginRoute, type RegisterWebhookTargetOptions, + type RegisterWebhookPluginRouteOptions, registerWebhookTarget, resolveSingleWebhookTarget, resolveWebhookTargets, @@ -109,11 +111,21 @@ function recordWebhookStatus( export function registerZaloWebhookTarget( target: ZaloWebhookTarget, - opts?: Pick< + opts?: { + route?: RegisterWebhookPluginRouteOptions; + } & Pick< RegisterWebhookTargetOptions<ZaloWebhookTarget>, "onFirstPathTarget" | "onLastPathTargetRemoved" >, ): () => void { + if (opts?.route) { + return registerWebhookTargetWithPluginRoute({ + targetsByPath: webhookTargets, + target, + route: opts.route, + onLastPathTargetRemoved: opts.onLastPathTargetRemoved, + }).unregister; + } return registerWebhookTarget(webhookTargets, target, opts).unregister; }
src/gateway/server-http.test-harness.ts+1 −1 modified@@ -128,7 +128,7 @@ export async function sendRequest( authorization?: string; method?: string; }, -) { +): Promise<ReturnType<typeof createResponse>> { const response = createResponse(); await dispatchRequest(server, createRequest(params), response.res); return response;
src/gateway/server-http.ts+67 −34 modified@@ -170,6 +170,59 @@ async function runGatewayHttpRequestStages( return false; } +function buildPluginRequestStages(params: { + req: IncomingMessage; + res: ServerResponse; + requestPath: string; + pluginPathContext: PluginRoutePathContext | null; + handlePluginRequest?: PluginHttpRequestHandler; + shouldEnforcePluginGatewayAuth?: (pathContext: PluginRoutePathContext) => boolean; + resolvedAuth: ResolvedGatewayAuth; + trustedProxies: string[]; + allowRealIpFallback: boolean; + rateLimiter?: AuthRateLimiter; +}): GatewayHttpRequestStage[] { + if (!params.handlePluginRequest) { + return []; + } + return [ + { + name: "plugin-auth", + run: async () => { + const pathContext = + params.pluginPathContext ?? resolvePluginRoutePathContext(params.requestPath); + if ( + !(params.shouldEnforcePluginGatewayAuth ?? shouldEnforceDefaultPluginGatewayAuth)( + pathContext, + ) + ) { + return false; + } + const pluginAuthOk = await enforcePluginRouteGatewayAuth({ + req: params.req, + res: params.res, + auth: params.resolvedAuth, + trustedProxies: params.trustedProxies, + allowRealIpFallback: params.allowRealIpFallback, + rateLimiter: params.rateLimiter, + }); + if (!pluginAuthOk) { + return true; + } + return false; + }, + }, + { + name: "plugin-http", + run: () => { + const pathContext = + params.pluginPathContext ?? resolvePluginRoutePathContext(params.requestPath); + return params.handlePluginRequest?.(params.req, params.res, pathContext) ?? false; + }, + }, + ]; +} + export function createHooksRequestHandler( opts: { getHooksConfig: () => HooksConfigResolved | null; @@ -555,40 +608,20 @@ export function createGatewayHttpServer(opts: { } // Plugins run after built-in gateway routes so core surfaces keep // precedence on overlapping paths. - if (handlePluginRequest) { - requestStages.push({ - name: "plugin-auth", - run: async () => { - const pathContext = pluginPathContext ?? resolvePluginRoutePathContext(requestPath); - if ( - !(shouldEnforcePluginGatewayAuth ?? shouldEnforceDefaultPluginGatewayAuth)( - pathContext, - ) - ) { - 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: () => { - const pathContext = pluginPathContext ?? resolvePluginRoutePathContext(requestPath); - return handlePluginRequest(req, res, pathContext); - }, - }); - } + requestStages.push( + ...buildPluginRequestStages({ + req, + res, + requestPath, + pluginPathContext, + handlePluginRequest, + shouldEnforcePluginGatewayAuth, + resolvedAuth, + trustedProxies, + allowRealIpFallback, + rateLimiter, + }), + ); requestStages.push({ name: "gateway-probes",
src/gateway/server.plugin-http-auth.test.ts+29 −4 modified@@ -418,11 +418,36 @@ describe("gateway plugin HTTP auth boundary", () => { run: async (server) => { for (const variant of buildChannelPathFuzzCorpus()) { const response = await sendRequest(server, { path: variant.path }); - expect(response.res.statusCode, variant.label).not.toBe(200); - expect(response.getBody(), variant.label).not.toContain( - '"route":"channel-canonicalized"', - ); + expect(response.res.statusCode, variant.label).toBe(401); + expect(response.getBody(), variant.label).toContain("Unauthorized"); } + expect(handlePluginRequest).not.toHaveBeenCalled(); + }, + }); + }); + + test("enforces auth before plugin handlers on encoded protected-path variants", async () => { + const encodedVariants = buildChannelPathFuzzCorpus().filter((variant) => + variant.path.includes("%"), + ); + const handlePluginRequest = vi.fn(async (_req: IncomingMessage, res: ServerResponse) => { + res.statusCode = 200; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.end(JSON.stringify({ ok: true, route: "should-not-run" })); + return true; + }); + + await withGatewayServer({ + prefix: "openclaw-plugin-http-auth-encoded-order-test-", + resolvedAuth: AUTH_TOKEN, + overrides: { handlePluginRequest }, + run: async (server) => { + for (const variant of encodedVariants) { + const response = await sendRequest(server, { path: variant.path }); + expect(response.res.statusCode, variant.label).toBe(401); + expect(response.getBody(), variant.label).toContain("Unauthorized"); + } + expect(handlePluginRequest).not.toHaveBeenCalled(); }, }); });
src/gateway/server/plugins-http/path-context.ts+60 −0 added@@ -0,0 +1,60 @@ +import { + PROTECTED_PLUGIN_ROUTE_PREFIXES, + canonicalizePathForSecurity, +} from "../../security-path.js"; + +export type PluginRoutePathContext = { + pathname: string; + canonicalPath: string; + candidates: string[]; + malformedEncoding: boolean; + decodePassLimitReached: boolean; + rawNormalizedPath: string; +}; + +function normalizeProtectedPrefix(prefix: string): string { + const collapsed = prefix.toLowerCase().replace(/\/{2,}/g, "/"); + if (collapsed.length <= 1) { + return collapsed || "/"; + } + return collapsed.replace(/\/+$/, ""); +} + +export function prefixMatchPath(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) => + prefixMatchPath(candidate, prefix), + ), + ) + ) { + return true; + } + if (!context.malformedEncoding) { + return false; + } + return NORMALIZED_PROTECTED_PLUGIN_ROUTE_PREFIXES.some((prefix) => + prefixMatchPath(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, + }; +}
src/gateway/server/plugins-http/route-auth.ts+28 −0 added@@ -0,0 +1,28 @@ +import type { PluginRegistry } from "../../../plugins/registry.js"; +import { + isProtectedPluginRoutePathFromContext, + resolvePluginRoutePathContext, + type PluginRoutePathContext, +} from "./path-context.js"; +import { findMatchingPluginHttpRoutes } from "./route-match.js"; + +export function shouldEnforceGatewayAuthForPluginPath( + registry: PluginRegistry, + pathnameOrContext: string | PluginRoutePathContext, +): boolean { + 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"; +}
src/gateway/server/plugins-http/route-match.ts+60 −0 added@@ -0,0 +1,60 @@ +import type { PluginRegistry } from "../../../plugins/registry.js"; +import { canonicalizePathVariant } from "../../security-path.js"; +import { + prefixMatchPath, + resolvePluginRoutePathContext, + type PluginRoutePathContext, +} from "./path-context.js"; + +type PluginHttpRouteEntry = NonNullable<PluginRegistry["httpRoutes"]>[number]; + +export function doesPluginRouteMatchPath( + route: PluginHttpRouteEntry, + context: PluginRoutePathContext, +): boolean { + const routeCanonicalPath = canonicalizePathVariant(route.path); + if (route.match === "prefix") { + return context.candidates.some((candidate) => prefixMatchPath(candidate, routeCanonicalPath)); + } + return context.candidates.some((candidate) => candidate === routeCanonicalPath); +} + +export 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 (!doesPluginRouteMatchPath(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 pathContext = resolvePluginRoutePathContext(pathname); + return findMatchingPluginHttpRoutes(registry, pathContext)[0]; +} + +export function isRegisteredPluginHttpRoutePath( + registry: PluginRegistry, + pathname: string, +): boolean { + return findRegisteredPluginHttpRoute(registry, pathname) !== undefined; +}
src/gateway/server/plugins-http.ts+15 −129 modified@@ -2,144 +2,30 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import type { createSubsystemLogger } from "../../logging/subsystem.js"; import type { PluginRegistry } from "../../plugins/registry.js"; import { - PROTECTED_PLUGIN_ROUTE_PREFIXES, - canonicalizePathForSecurity, - canonicalizePathVariant, -} from "../security-path.js"; + resolvePluginRoutePathContext, + type PluginRoutePathContext, +} from "./plugins-http/path-context.js"; +import { findMatchingPluginHttpRoutes } from "./plugins-http/route-match.js"; + +export { + isProtectedPluginRoutePathFromContext, + resolvePluginRoutePathContext, + type PluginRoutePathContext, +} from "./plugins-http/path-context.js"; +export { + findRegisteredPluginHttpRoute, + isRegisteredPluginHttpRoutePath, +} from "./plugins-http/route-match.js"; +export { shouldEnforceGatewayAuthForPluginPath } from "./plugins-http/route-auth.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 pathContext = resolvePluginRoutePathContext(pathname); - return findMatchingPluginHttpRoutes(registry, pathContext)[0]; -} - -export function isRegisteredPluginHttpRoutePath( - registry: PluginRegistry, - pathname: string, -): boolean { - return findRegisteredPluginHttpRoute(registry, pathname) !== undefined; -} - -export function shouldEnforceGatewayAuthForPluginPath( - registry: PluginRegistry, - pathnameOrContext: string | PluginRoutePathContext, -): boolean { - 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: { registry: PluginRegistry; log: SubsystemLogger;
src/line/monitor.ts+1 −0 modified@@ -289,6 +289,7 @@ export async function monitorLineProvider( const unregisterHttp = registerPluginHttpRoute({ path: normalizedPath, auth: "plugin", + replaceExisting: true, pluginId: "line", accountId: resolvedAccountId, log: (msg) => logVerbose(msg),
src/plugin-sdk/index.ts+6 −1 modified@@ -123,12 +123,17 @@ export { acquireFileLock, withFileLock } from "./file-lock.js"; export { normalizeWebhookPath, resolveWebhookPath } from "./webhook-path.js"; export { registerWebhookTarget, + registerWebhookTargetWithPluginRoute, rejectNonPostWebhookRequest, resolveSingleWebhookTarget, resolveSingleWebhookTargetAsync, resolveWebhookTargets, } from "./webhook-targets.js"; -export type { RegisterWebhookTargetOptions, WebhookTargetMatchResult } from "./webhook-targets.js"; +export type { + RegisterWebhookPluginRouteOptions, + RegisterWebhookTargetOptions, + WebhookTargetMatchResult, +} from "./webhook-targets.js"; export { applyBasicWebhookRequestGuards, isJsonContentType,
src/plugin-sdk/webhook-targets.test.ts+51 −1 modified@@ -1,8 +1,11 @@ import { EventEmitter } from "node:events"; import type { IncomingMessage, ServerResponse } from "node:http"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createEmptyPluginRegistry } from "../plugins/registry.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; import { registerWebhookTarget, + registerWebhookTargetWithPluginRoute, rejectNonPostWebhookRequest, resolveSingleWebhookTarget, resolveSingleWebhookTargetAsync, @@ -17,6 +20,10 @@ function createRequest(method: string, url: string): IncomingMessage { return req; } +afterEach(() => { + setActivePluginRegistry(createEmptyPluginRegistry()); +}); + describe("registerWebhookTarget", () => { it("normalizes the path and unregisters cleanly", () => { const targets = new Map<string, Array<{ path: string; id: string }>>(); @@ -86,6 +93,49 @@ describe("registerWebhookTarget", () => { }); }); +describe("registerWebhookTargetWithPluginRoute", () => { + it("registers plugin route on first target and removes it on last target", () => { + const registry = createEmptyPluginRegistry(); + setActivePluginRegistry(registry); + const targets = new Map<string, Array<{ path: string; id: string }>>(); + + const registeredA = registerWebhookTargetWithPluginRoute({ + targetsByPath: targets, + target: { path: "/hook", id: "A" }, + route: { + auth: "plugin", + pluginId: "demo", + source: "demo-webhook", + handler: () => {}, + }, + }); + const registeredB = registerWebhookTargetWithPluginRoute({ + targetsByPath: targets, + target: { path: "/hook", id: "B" }, + route: { + auth: "plugin", + pluginId: "demo", + source: "demo-webhook", + handler: () => {}, + }, + }); + + expect(registry.httpRoutes).toHaveLength(1); + expect(registry.httpRoutes[0]).toEqual( + expect.objectContaining({ + pluginId: "demo", + path: "/hook", + source: "demo-webhook", + }), + ); + + registeredA.unregister(); + expect(registry.httpRoutes).toHaveLength(1); + registeredB.unregister(); + expect(registry.httpRoutes).toHaveLength(0); + }); +}); + describe("resolveWebhookTargets", () => { it("resolves normalized path targets", () => { const targets = new Map<string, Array<{ id: string }>>();
src/plugin-sdk/webhook-targets.ts+25 −0 modified@@ -1,4 +1,5 @@ import type { IncomingMessage, ServerResponse } from "node:http"; +import { registerPluginHttpRoute } from "../plugins/http-registry.js"; import { normalizeWebhookPath } from "./webhook-path.js"; export type RegisteredWebhookTarget<T> = { @@ -11,6 +12,30 @@ export type RegisterWebhookTargetOptions<T extends { path: string }> = { onLastPathTargetRemoved?: (params: { path: string }) => void; }; +type RegisterPluginHttpRouteParams = Parameters<typeof registerPluginHttpRoute>[0]; + +export type RegisterWebhookPluginRouteOptions = Omit< + RegisterPluginHttpRouteParams, + "path" | "fallbackPath" +>; + +export function registerWebhookTargetWithPluginRoute<T extends { path: string }>(params: { + targetsByPath: Map<string, T[]>; + target: T; + route: RegisterWebhookPluginRouteOptions; + onLastPathTargetRemoved?: RegisterWebhookTargetOptions<T>["onLastPathTargetRemoved"]; +}): RegisteredWebhookTarget<T> { + return registerWebhookTarget(params.targetsByPath, params.target, { + onFirstPathTarget: ({ path }) => + registerPluginHttpRoute({ + ...params.route, + path, + replaceExisting: params.route.replaceExisting ?? true, + }), + onLastPathTargetRemoved: params.onLastPathTargetRemoved, + }); +} + const pathTeardownByTargetMap = new WeakMap<Map<string, unknown[]>, Map<string, () => void>>(); function getPathTeardownMap<T>(targetsByPath: Map<string, T[]>): Map<string, () => void> {
src/plugins/http-registry.test.ts+70 −2 modified@@ -9,14 +9,15 @@ describe("registerPluginHttpRoute", () => { const unregister = registerPluginHttpRoute({ path: "/plugins/demo", + auth: "plugin", handler, registry, }); 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]?.auth).toBe("plugin"); expect(registry.httpRoutes[0]?.match).toBe("exact"); unregister(); @@ -28,6 +29,7 @@ describe("registerPluginHttpRoute", () => { const logs: string[] = []; const unregister = registerPluginHttpRoute({ path: "", + auth: "plugin", handler: vi.fn(), registry, accountId: "default", @@ -39,14 +41,15 @@ describe("registerPluginHttpRoute", () => { expect(() => unregister()).not.toThrow(); }); - it("replaces stale route on same path and keeps latest registration", () => { + it("replaces stale route on same path when replaceExisting=true", () => { const registry = createEmptyPluginRegistry(); const logs: string[] = []; const firstHandler = vi.fn(); const secondHandler = vi.fn(); const unregisterFirst = registerPluginHttpRoute({ path: "/plugins/synology", + auth: "plugin", handler: firstHandler, registry, accountId: "default", @@ -56,6 +59,8 @@ describe("registerPluginHttpRoute", () => { const unregisterSecond = registerPluginHttpRoute({ path: "/plugins/synology", + auth: "plugin", + replaceExisting: true, handler: secondHandler, registry, accountId: "default", @@ -77,4 +82,67 @@ describe("registerPluginHttpRoute", () => { unregisterSecond(); expect(registry.httpRoutes).toHaveLength(0); }); + + it("rejects conflicting route registrations without replaceExisting", () => { + const registry = createEmptyPluginRegistry(); + const logs: string[] = []; + + registerPluginHttpRoute({ + path: "/plugins/demo", + auth: "plugin", + handler: vi.fn(), + registry, + pluginId: "demo-a", + source: "demo-a-src", + log: (msg) => logs.push(msg), + }); + + const unregister = registerPluginHttpRoute({ + path: "/plugins/demo", + auth: "plugin", + handler: vi.fn(), + registry, + pluginId: "demo-b", + source: "demo-b-src", + log: (msg) => logs.push(msg), + }); + + expect(registry.httpRoutes).toHaveLength(1); + expect(logs.at(-1)).toContain("route conflict"); + + unregister(); + expect(registry.httpRoutes).toHaveLength(1); + }); + + it("rejects route replacement when a different plugin owns the route", () => { + const registry = createEmptyPluginRegistry(); + const logs: string[] = []; + + registerPluginHttpRoute({ + path: "/plugins/demo", + auth: "plugin", + handler: vi.fn(), + registry, + pluginId: "demo-a", + source: "demo-a-src", + log: (msg) => logs.push(msg), + }); + + const unregister = registerPluginHttpRoute({ + path: "/plugins/demo", + auth: "plugin", + replaceExisting: true, + handler: vi.fn(), + registry, + pluginId: "demo-b", + source: "demo-b-src", + log: (msg) => logs.push(msg), + }); + + expect(registry.httpRoutes).toHaveLength(1); + expect(logs.at(-1)).toContain("route replacement denied"); + + unregister(); + expect(registry.httpRoutes).toHaveLength(1); + }); });
src/plugins/http-registry.ts+19 −2 modified@@ -12,8 +12,9 @@ export function registerPluginHttpRoute(params: { path?: string | null; fallbackPath?: string | null; handler: PluginHttpRouteHandler; - auth?: PluginHttpRouteRegistration["auth"]; + auth: PluginHttpRouteRegistration["auth"]; match?: PluginHttpRouteRegistration["match"]; + replaceExisting?: boolean; pluginId?: string; source?: string; accountId?: string; @@ -36,6 +37,22 @@ export function registerPluginHttpRoute(params: { (entry) => entry.path === normalizedPath && entry.match === routeMatch, ); if (existingIndex >= 0) { + const existing = routes[existingIndex]; + if (!existing) { + return () => {}; + } + if (!params.replaceExisting) { + params.log?.( + `plugin: route conflict at ${normalizedPath} (${routeMatch})${suffix}; owned by ${existing.pluginId ?? "unknown-plugin"} (${existing.source ?? "unknown-source"})`, + ); + return () => {}; + } + if (existing.pluginId && params.pluginId && existing.pluginId !== params.pluginId) { + params.log?.( + `plugin: route replacement denied for ${normalizedPath} (${routeMatch})${suffix}; owned by ${existing.pluginId}`, + ); + return () => {}; + } const pluginHint = params.pluginId ? ` (${params.pluginId})` : ""; params.log?.( `plugin: replacing stale webhook path ${normalizedPath} (${routeMatch})${suffix}${pluginHint}`, @@ -46,7 +63,7 @@ export function registerPluginHttpRoute(params: { const entry: PluginHttpRouteRegistration = { path: normalizedPath, handler: params.handler, - auth: params.auth ?? "gateway", + auth: params.auth, match: routeMatch, pluginId: params.pluginId, source: params.source,
src/plugins/loader.test.ts+90 −1 modified@@ -548,7 +548,7 @@ describe("loadOpenClawPlugins", () => { id: "http-route-demo", filename: "http-route-demo.cjs", body: `module.exports = { id: "http-route-demo", register(api) { - api.registerHttpRoute({ path: "/demo", handler: async (_req, res) => { res.statusCode = 200; res.end("ok"); } }); + api.registerHttpRoute({ path: "/demo", auth: "gateway", handler: async (_req, res) => { res.statusCode = 200; res.end("ok"); } }); } };`, }); @@ -568,6 +568,95 @@ describe("loadOpenClawPlugins", () => { expect(httpPlugin?.httpRoutes).toBe(1); }); + it("rejects plugin http routes missing explicit auth", () => { + useNoBundledPlugins(); + const plugin = writePlugin({ + id: "http-route-missing-auth", + filename: "http-route-missing-auth.cjs", + body: `module.exports = { id: "http-route-missing-auth", register(api) { + api.registerHttpRoute({ path: "/demo", handler: async () => true }); +} };`, + }); + + const registry = loadRegistryFromSinglePlugin({ + plugin, + pluginConfig: { + allow: ["http-route-missing-auth"], + }, + }); + + expect(registry.httpRoutes.find((entry) => entry.pluginId === "http-route-missing-auth")).toBe( + undefined, + ); + expect( + registry.diagnostics.some((diag) => + String(diag.message).includes("http route registration missing or invalid auth"), + ), + ).toBe(true); + }); + + it("allows explicit replaceExisting for same-plugin http route overrides", () => { + useNoBundledPlugins(); + const plugin = writePlugin({ + id: "http-route-replace-self", + filename: "http-route-replace-self.cjs", + body: `module.exports = { id: "http-route-replace-self", register(api) { + api.registerHttpRoute({ path: "/demo", auth: "plugin", handler: async () => false }); + api.registerHttpRoute({ path: "/demo", auth: "plugin", replaceExisting: true, handler: async () => true }); +} };`, + }); + + const registry = loadRegistryFromSinglePlugin({ + plugin, + pluginConfig: { + allow: ["http-route-replace-self"], + }, + }); + + const routes = registry.httpRoutes.filter( + (entry) => entry.pluginId === "http-route-replace-self", + ); + expect(routes).toHaveLength(1); + expect(routes[0]?.path).toBe("/demo"); + expect(registry.diagnostics).toEqual([]); + }); + + it("rejects http route replacement when another plugin owns the route", () => { + useNoBundledPlugins(); + const first = writePlugin({ + id: "http-route-owner-a", + filename: "http-route-owner-a.cjs", + body: `module.exports = { id: "http-route-owner-a", register(api) { + api.registerHttpRoute({ path: "/demo", auth: "plugin", handler: async () => false }); +} };`, + }); + const second = writePlugin({ + id: "http-route-owner-b", + filename: "http-route-owner-b.cjs", + body: `module.exports = { id: "http-route-owner-b", register(api) { + api.registerHttpRoute({ path: "/demo", auth: "plugin", replaceExisting: true, handler: async () => true }); +} };`, + }); + + const registry = loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + load: { paths: [first.file, second.file] }, + allow: ["http-route-owner-a", "http-route-owner-b"], + }, + }, + }); + + const route = registry.httpRoutes.find((entry) => entry.path === "/demo"); + expect(route?.pluginId).toBe("http-route-owner-a"); + expect( + registry.diagnostics.some((diag) => + String(diag.message).includes("http route replacement rejected"), + ), + ).toBe(true); + }); + it("respects explicit disable in config", () => { process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; const plugin = writePlugin({
src/plugins/registry.ts+46 −6 modified@@ -284,6 +284,12 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { record.gatewayMethods.push(trimmed); }; + const describeHttpRouteOwner = (entry: PluginHttpRouteRegistration): string => { + const plugin = entry.pluginId?.trim() || "unknown-plugin"; + const source = entry.source?.trim() || "unknown-source"; + return `${plugin} (${source})`; + }; + const registerHttpRoute = (record: PluginRecord, params: OpenClawPluginHttpRouteParams) => { const normalizedPath = normalizePluginHttpPath(params.path); if (!normalizedPath) { @@ -295,24 +301,58 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { }); return; } - const match = params.match ?? "exact"; - if ( - registry.httpRoutes.some((entry) => entry.path === normalizedPath && entry.match === match) - ) { + if (params.auth !== "gateway" && params.auth !== "plugin") { pushDiagnostic({ level: "error", pluginId: record.id, source: record.source, - message: `http route already registered: ${normalizedPath} (${match})`, + message: `http route registration missing or invalid auth: ${normalizedPath}`, }); return; } + const match = params.match ?? "exact"; + const existingIndex = registry.httpRoutes.findIndex( + (entry) => entry.path === normalizedPath && entry.match === match, + ); + if (existingIndex >= 0) { + const existing = registry.httpRoutes[existingIndex]; + if (!existing) { + return; + } + if (!params.replaceExisting) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: `http route already registered: ${normalizedPath} (${match}) by ${describeHttpRouteOwner(existing)}`, + }); + return; + } + if (existing.pluginId && existing.pluginId !== record.id) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: `http route replacement rejected: ${normalizedPath} (${match}) owned by ${describeHttpRouteOwner(existing)}`, + }); + return; + } + registry.httpRoutes[existingIndex] = { + pluginId: record.id, + path: normalizedPath, + handler: params.handler, + auth: params.auth, + match, + source: record.source, + }; + return; + } record.httpRoutes += 1; registry.httpRoutes.push({ pluginId: record.id, path: normalizedPath, handler: params.handler, - auth: params.auth ?? "gateway", + auth: params.auth, match, source: record.source, });
src/plugins/types.ts+2 −1 modified@@ -205,8 +205,9 @@ export type OpenClawPluginHttpRouteHandler = ( export type OpenClawPluginHttpRouteParams = { path: string; handler: OpenClawPluginHttpRouteHandler; - auth?: OpenClawPluginHttpRouteAuth; + auth: OpenClawPluginHttpRouteAuth; match?: OpenClawPluginHttpRouteMatch; + replaceExisting?: boolean; }; export type OpenClawPluginCliContext = {
d74bc257d843fix(line): mark webhook route as plugin-authenticated
2 files changed · +4 −0
src/line/monitor.lifecycle.test.ts+3 −0 modified@@ -98,6 +98,9 @@ describe("monitorLineProvider lifecycle", () => { }); await vi.waitFor(() => expect(registerPluginHttpRouteMock).toHaveBeenCalledTimes(1)); + expect(registerPluginHttpRouteMock).toHaveBeenCalledWith( + expect.objectContaining({ auth: "plugin" }), + ); expect(resolved).toBe(false); abort.abort();
src/line/monitor.ts+1 −0 modified@@ -288,6 +288,7 @@ export async function monitorLineProvider( const normalizedPath = normalizePluginHttpPath(webhookPath, "/line/webhook") ?? "/line/webhook"; const unregisterHttp = registerPluginHttpRoute({ path: normalizedPath, + auth: "plugin", pluginId: "line", accountId: resolvedAccountId, log: (msg) => logVerbose(msg),
2fd8264ab03brefactor(gateway): hard-break plugin wildcard http handlers
31 files changed · +341 −168
CHANGELOG.md+1 −0 modified@@ -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
extensions/bluebubbles/index.ts+0 −2 modified@@ -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); }, };
extensions/bluebubbles/README.md+1 −1 modified@@ -10,7 +10,7 @@ If you’re 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`.
extensions/bluebubbles/src/monitor.ts+10 −0 modified@@ -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(); };
extensions/diffs/index.test.ts+13 −10 modified@@ -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,
extensions/diffs/index.ts+6 −3 modified@@ -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, }));
extensions/diffs/src/tool.test.ts+0 −1 modified@@ -434,7 +434,6 @@ function createApi(): OpenClawPluginApi { }, registerTool() {}, registerHook() {}, - registerHttpHandler() {}, registerHttpRoute() {}, registerChannel() {}, registerGatewayMethod() {},
extensions/googlechat/index.ts+0 −2 modified@@ -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); }, };
extensions/googlechat/src/monitor.ts+15 −2 modified@@ -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(
extensions/lobster/src/lobster-tool.test.ts+0 −1 modified@@ -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() {},
extensions/nostr/index.ts+6 −1 modified@@ -61,7 +61,12 @@ const plugin = { log: api.logger, }); - api.registerHttpHandler(httpHandler); + api.registerHttpRoute({ + path: "/api/channels/nostr", + auth: "gateway", + match: "prefix", + handler: httpHandler, + }); }, };
extensions/zalo/index.ts+0 −2 modified@@ -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); }, };
extensions/zalo/src/monitor.ts+10 −0 modified@@ -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", () => {
package.json+2 −1 modified@@ -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",
src/auto-reply/reply/route-reply.test.ts+0 −1 modified@@ -70,7 +70,6 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => channels, providers: [], gatewayHandlers: {}, - httpHandlers: [], httpRoutes: [], cliRegistrars: [], services: [],
src/gateway/server-http.ts+23 −10 modified@@ -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); + }, }); }
src/gateway/server.plugin-http-auth.test.ts+10 −6 modified@@ -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()) {
src/gateway/server/plugins-http.test.ts+57 −34 modified@@ -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,55 +51,72 @@ 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); + it("handles exact route matches", async () => { + const routeHandler = vi.fn(async (_req, res: ServerResponse) => { + res.statusCode = 200; + }); const handler = createGatewayPluginRequestHandler({ registry: createTestRegistry({ - httpHandlers: [ - { pluginId: "first", handler: first, source: "first" }, - { pluginId: "second", handler: second, source: "second" }, - ], + httpRoutes: [createRoute({ path: "/demo", handler: routeHandler })], }), log: createPluginLog(), }); const { res } = makeMockHttpResponse(); - const handled = await handler({} as IncomingMessage, res); + const handled = await handler({ url: "/demo" } as IncomingMessage, res); expect(handled).toBe(true); - expect(first).toHaveBeenCalledTimes(1); - expect(second).toHaveBeenCalledTimes(1); + expect(routeHandler).toHaveBeenCalledTimes(1); }); - it("handles registered http routes before generic handlers", async () => { - const routeHandler = vi.fn(async (_req, res: ServerResponse) => { + it("prefers exact matches before prefix matches", async () => { + const exactHandler = vi.fn(async (_req, res: ServerResponse) => { res.statusCode = 200; }); - const fallback = vi.fn(async () => true); + const prefixHandler = vi.fn(async () => true); const handler = createGatewayPluginRequestHandler({ registry: createTestRegistry({ - httpRoutes: [createRoute({ path: "/demo", handler: routeHandler })], - httpHandlers: [{ pluginId: "fallback", handler: fallback, source: "fallback" }], + 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: "/demo" } as IncomingMessage, res); + const handled = await handler({ url: "/api/demo" } as IncomingMessage, res); expect(handled).toBe(true); - expect(routeHandler).toHaveBeenCalledTimes(1); - expect(fallback).not.toHaveBeenCalled(); + expect(exactHandler).toHaveBeenCalledTimes(1); + expect(prefixHandler).not.toHaveBeenCalled(); }); - it("matches canonicalized route variants before generic handlers", async () => { + 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);
src/gateway/server/plugins-http.ts+126 −39 modified@@ -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,54 +121,55 @@ 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: { registry: PluginRegistry; 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");
src/gateway/server-plugins.test.ts+0 −1 modified@@ -18,7 +18,6 @@ const createRegistry = (diagnostics: PluginDiagnostic[]): PluginRegistry => ({ commands: [], providers: [], gatewayHandlers: {}, - httpHandlers: [], httpRoutes: [], cliRegistrars: [], services: [],
src/gateway/server-runtime-state.ts+3 −2 modified@@ -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);
src/gateway/server/__tests__/test-utils.ts+0 −1 modified@@ -5,7 +5,6 @@ export const createTestRegistry = (overrides: Partial<PluginRegistry> = {}): Plu return { ...merged, gatewayHandlers: merged.gatewayHandlers ?? {}, - httpHandlers: merged.httpHandlers ?? [], httpRoutes: merged.httpRoutes ?? [], }; };
src/gateway/test-helpers.mocks.ts+0 −1 modified@@ -146,7 +146,6 @@ const createStubPluginRegistry = (): PluginRegistry => ({ ], providers: [], gatewayHandlers: {}, - httpHandlers: [], httpRoutes: [], cliRegistrars: [], services: [],
src/plugins/hooks.test-helpers.ts+0 −1 modified@@ -13,7 +13,6 @@ export function createMockPluginRegistry( source: "test", })), tools: [], - httpHandlers: [], httpRoutes: [], channelRegistrations: [], gatewayHandlers: {},
src/plugins/http-registry.test.ts+3 −1 modified@@ -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.
src/plugins/http-registry.ts+12 −3 modified@@ -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, };
src/plugins/loader.test.ts+16 −6 modified@@ -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", () => {
src/plugins/loader.ts+1 −1 modified@@ -176,7 +176,7 @@ function createPluginRecord(params: { cliCommands: [], services: [], commands: [], - httpHandlers: 0, + httpRoutes: 0, hookCount: 0, configSchema: params.configSchema, configUiHints: undefined,
src/plugins/registry.ts+15 −27 modified@@ -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),
src/plugins/types.ts+11 −7 modified@@ -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;
src/test-utils/channel-plugins.ts+0 −1 modified@@ -20,7 +20,6 @@ export const createTestRegistry = (channels: TestChannelRegistration[] = []): Pl channels: channels as unknown as PluginRegistry["channels"], providers: [], gatewayHandlers: {}, - httpHandlers: [], httpRoutes: [], cliRegistrars: [], services: [],
93b072402579fix(gateway): fail closed plugin auth path canonicalization
7 files changed · +125 −13
CHANGELOG.md+1 −0 modified@@ -47,6 +47,7 @@ Docs: https://docs.openclaw.ai - Zalo/Pairing auth tests: add webhook regression coverage asserting DM pairing-store reads/writes remain account-scoped, preventing cross-account authorization bleed in multi-account setups. (#26121) Thanks @bmendonca3. - Zalouser/Pairing auth tests: add account-scoped DM pairing-store regression coverage (`monitor.account-scope.test.ts`) to prevent cross-account allowlist bleed in multi-account setups. (#26672) Thanks @bmendonca3. - Security/Web tools SSRF guard: keep DNS pinning for untrusted `web_fetch` and citation-redirect URL checks when proxy env vars are set, and require explicit dangerous opt-in before env-proxy routing can bypass pinned dispatch for trusted/operator-controlled endpoints. Thanks @tdjackey for reporting. +- Gateway/Security canonicalization hardening: decode plugin route path variants to canonical fixpoint (with bounded depth), fail closed on canonicalization anomalies, and enforce gateway auth for deeply encoded `/api/channels/*` variants to prevent alternate-path auth bypass through plugin handlers. Thanks @tdjackey for reporting. - Agents/Sessions list transcript paths: handle missing/non-string/relative `sessions.list.path` values and per-agent `{agentId}` templates when deriving `transcriptPath`, so cross-agent session listings resolve to concrete agent session files instead of workspace-relative paths. (#24775) Thanks @martinfrancois. - macOS/PeekabooBridge: add compatibility socket symlinks for legacy `clawdbot`, `clawdis`, and `moltbot` Application Support socket paths so pre-rename clients can still connect. (#6033) Thanks @lumpinif and @vincentkoc. - Webchat/Feishu session continuation: preserve routable `OriginatingChannel`/`OriginatingTo` metadata from session delivery context in `chat.send`, and prefer provider-normalized channel when deciding cross-channel route dispatch so Webchat replies continue on the selected Feishu session instead of falling back to main/internal session routing. (#31573)
src/gateway/security-path.test.ts+38 −6 modified@@ -1,23 +1,38 @@ import { describe, expect, it } from "vitest"; import { PROTECTED_PLUGIN_ROUTE_PREFIXES, + buildCanonicalPathCandidates, canonicalizePathForSecurity, isPathProtectedByPrefixes, isProtectedPluginRoutePath, } from "./security-path.js"; +function buildRepeatedEncodedSlashPath(depth: number): string { + let encodedSlash = "%2f"; + for (let i = 1; i < depth; i++) { + encodedSlash = encodedSlash.replace(/%/g, "%25"); + } + return `/api${encodedSlash}channels${encodedSlash}nostr${encodedSlash}default${encodedSlash}profile`; +} + describe("security-path canonicalization", () => { it("canonicalizes decoded case/slash variants", () => { - expect(canonicalizePathForSecurity("/API/channels//nostr/default/profile/")).toEqual({ - canonicalPath: "/api/channels/nostr/default/profile", - candidates: ["/api/channels/nostr/default/profile"], - malformedEncoding: false, - rawNormalizedPath: "/api/channels/nostr/default/profile", - }); + expect(canonicalizePathForSecurity("/API/channels//nostr/default/profile/")).toEqual( + expect.objectContaining({ + canonicalPath: "/api/channels/nostr/default/profile", + candidates: ["/api/channels/nostr/default/profile"], + malformedEncoding: false, + decodePasses: 0, + decodePassLimitReached: false, + rawNormalizedPath: "/api/channels/nostr/default/profile", + }), + ); const encoded = canonicalizePathForSecurity("/api/%63hannels%2Fnostr%2Fdefault%2Fprofile"); expect(encoded.canonicalPath).toBe("/api/channels/nostr/default/profile"); expect(encoded.candidates).toContain("/api/%63hannels%2fnostr%2fdefault%2fprofile"); expect(encoded.candidates).toContain("/api/channels/nostr/default/profile"); + expect(encoded.decodePasses).toBeGreaterThan(0); + expect(encoded.decodePassLimitReached).toBe(false); }); it("resolves traversal after repeated decoding", () => { @@ -34,6 +49,22 @@ describe("security-path canonicalization", () => { expect(canonicalizePathForSecurity("/api/channels%2").malformedEncoding).toBe(true); expect(canonicalizePathForSecurity("/api/channels%zz").malformedEncoding).toBe(true); }); + + it("resolves 4x encoded slash path variants to protected channel routes", () => { + const deeplyEncoded = "/api%2525252fchannels%2525252fnostr%2525252fdefault%2525252fprofile"; + const canonical = canonicalizePathForSecurity(deeplyEncoded); + expect(canonical.canonicalPath).toBe("/api/channels/nostr/default/profile"); + expect(canonical.decodePasses).toBeGreaterThanOrEqual(4); + expect(isProtectedPluginRoutePath(deeplyEncoded)).toBe(true); + }); + + it("flags decode depth overflow and fails closed for protected prefix checks", () => { + const excessiveDepthPath = buildRepeatedEncodedSlashPath(40); + const candidates = buildCanonicalPathCandidates(excessiveDepthPath, 32); + expect(candidates.decodePassLimitReached).toBe(true); + expect(candidates.malformedEncoding).toBe(false); + expect(isProtectedPluginRoutePath(excessiveDepthPath)).toBe(true); + }); }); describe("security-path protected-prefix matching", () => { @@ -44,6 +75,7 @@ describe("security-path protected-prefix matching", () => { "/api/foo/..%2fchannels/nostr/default/profile", "/api/foo/%2e%2e%2fchannels/nostr/default/profile", "/api/foo/%252e%252e%252fchannels/nostr/default/profile", + "/api%2525252fchannels%2525252fnostr%2525252fdefault%2525252fprofile", "/api/channels%2", "/api/channels%zz", ];
src/gateway/security-path.ts+38 −4 modified@@ -1,11 +1,13 @@ export type SecurityPathCanonicalization = { canonicalPath: string; candidates: string[]; + decodePasses: number; + decodePassLimitReached: boolean; malformedEncoding: boolean; rawNormalizedPath: string; }; -const MAX_PATH_DECODE_PASSES = 3; +const MAX_PATH_DECODE_PASSES = 32; function normalizePathSeparators(pathname: string): string { const collapsed = pathname.replace(/\/{2,}/g, "/"); @@ -43,13 +45,19 @@ function pushNormalizedCandidate(candidates: string[], seen: Set<string>, value: export function buildCanonicalPathCandidates( pathname: string, maxDecodePasses = MAX_PATH_DECODE_PASSES, -): { candidates: string[]; malformedEncoding: boolean } { +): { + candidates: string[]; + decodePasses: number; + decodePassLimitReached: boolean; + malformedEncoding: boolean; +} { const candidates: string[] = []; const seen = new Set<string>(); pushNormalizedCandidate(candidates, seen, pathname); let decoded = pathname; let malformedEncoding = false; + let decodePasses = 0; for (let pass = 0; pass < maxDecodePasses; pass++) { let nextDecoded = decoded; try { @@ -61,10 +69,24 @@ export function buildCanonicalPathCandidates( if (nextDecoded === decoded) { break; } + decodePasses += 1; decoded = nextDecoded; pushNormalizedCandidate(candidates, seen, decoded); } - return { candidates, malformedEncoding }; + let decodePassLimitReached = false; + if (!malformedEncoding) { + try { + decodePassLimitReached = decodeURIComponent(decoded) !== decoded; + } catch { + malformedEncoding = true; + } + } + return { + candidates, + decodePasses, + decodePassLimitReached, + malformedEncoding, + }; } export function canonicalizePathVariant(pathname: string): string { @@ -82,16 +104,24 @@ function prefixMatch(pathname: string, prefix: string): boolean { } export function canonicalizePathForSecurity(pathname: string): SecurityPathCanonicalization { - const { candidates, malformedEncoding } = buildCanonicalPathCandidates(pathname); + const { candidates, decodePasses, decodePassLimitReached, malformedEncoding } = + buildCanonicalPathCandidates(pathname); return { canonicalPath: candidates[candidates.length - 1] ?? "/", candidates, + decodePasses, + decodePassLimitReached, malformedEncoding, rawNormalizedPath: normalizePathSeparators(pathname.toLowerCase()) || "/", }; } +export function hasSecurityPathCanonicalizationAnomaly(pathname: string): boolean { + const canonical = canonicalizePathForSecurity(pathname); + return canonical.malformedEncoding || canonical.decodePassLimitReached; +} + const normalizedPrefixesCache = new WeakMap<readonly string[], readonly string[]>(); function getNormalizedPrefixes(prefixes: readonly string[]): readonly string[] { @@ -114,6 +144,10 @@ export function isPathProtectedByPrefixes(pathname: string, prefixes: readonly s ) { return true; } + // Fail closed when canonicalization depth cannot be fully resolved. + if (canonical.decodePassLimitReached) { + return true; + } if (!canonical.malformedEncoding) { return false; }
src/gateway/server-http.ts+8 −1 modified@@ -48,6 +48,7 @@ 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, @@ -80,6 +81,10 @@ const GATEWAY_PROBE_STATUS_BY_PATH = new Map<string, "live" | "ready">([ ["/readyz", "ready"], ]); +function shouldEnforceDefaultPluginGatewayAuth(pathname: string): boolean { + return hasSecurityPathCanonicalizationAnomaly(pathname) || isProtectedPluginRoutePath(pathname); +} + function handleGatewayProbeRequest( req: IncomingMessage, res: ServerResponse, @@ -511,7 +516,9 @@ export function createGatewayHttpServer(opts: { // Plugins run after built-in gateway routes so core surfaces keep // precedence on overlapping paths. if (handlePluginRequest) { - if ((shouldEnforcePluginGatewayAuth ?? isProtectedPluginRoutePath)(requestPath)) { + if ( + (shouldEnforcePluginGatewayAuth ?? shouldEnforceDefaultPluginGatewayAuth)(requestPath) + ) { const pluginAuthOk = await enforcePluginRouteGatewayAuth({ req, res,
src/gateway/server.plugin-http-auth.test.ts+22 −1 modified@@ -181,6 +181,10 @@ type RouteVariant = { 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" }, { @@ -199,6 +203,10 @@ const CANONICAL_UNAUTH_VARIANTS: RouteVariant[] = [ 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/" }, { @@ -221,6 +229,7 @@ function buildChannelPathFuzzCorpus(): RouteVariant[] { "/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", @@ -454,7 +463,7 @@ describe("gateway plugin HTTP auth boundary", () => { test("uses /api/channels auth by default while keeping wildcard handlers ungated with no predicate", async () => { const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => { const pathname = new URL(req.url ?? "/", "http://localhost").pathname; - if (pathname === "/api/channels/nostr/default/profile") { + if (canonicalizePluginPath(pathname) === "/api/channels/nostr/default/profile") { res.statusCode = 200; res.setHeader("Content-Type", "application/json; charset=utf-8"); res.end(JSON.stringify({ ok: true, route: "channel-default" })); @@ -483,6 +492,11 @@ describe("gateway plugin HTTP auth boundary", () => { }); expectUnauthorizedResponse(unauthenticatedChannel); + const unauthenticatedDeepEncodedChannel = await sendRequest(server, { + path: "/api%2525252fchannels%2525252fnostr%2525252fdefault%2525252fprofile", + }); + expectUnauthorizedResponse(unauthenticatedDeepEncodedChannel); + const authenticated = await sendRequest(server, { path: "/googlechat", authorization: "Bearer test-token", @@ -496,6 +510,13 @@ describe("gateway plugin HTTP auth boundary", () => { }); expect(authenticatedChannel.res.statusCode).toBe(200); expect(authenticatedChannel.getBody()).toContain('"route":"channel-default"'); + + const authenticatedDeepEncodedChannel = await sendRequest(server, { + path: "/api%2525252fchannels%2525252fnostr%2525252fdefault%2525252fprofile", + authorization: "Bearer test-token", + }); + expect(authenticatedDeepEncodedChannel.res.statusCode).toBe(200); + expect(authenticatedDeepEncodedChannel.getBody()).toContain('"route":"channel-default"'); }, }); });
src/gateway/server/plugins-http.test.ts+14 −0 modified@@ -27,6 +27,14 @@ function createRoute(params: { }; } +function buildRepeatedEncodedSlash(depth: number): string { + let encodedSlash = "%2f"; + for (let i = 1; i < depth; i++) { + encodedSlash = encodedSlash.replace(/%/g, "%25"); + } + return encodedSlash; +} + describe("createGatewayPluginRequestHandler", () => { it("returns false when no handlers are registered", async () => { const log = createPluginLog(); @@ -127,6 +135,10 @@ describe("createGatewayPluginRequestHandler", () => { }); describe("plugin HTTP registry helpers", () => { + const deeplyEncodedChannelPath = + "/api%2525252fchannels%2525252fnostr%2525252fdefault%2525252fprofile"; + const decodeOverflowPublicPath = `/googlechat${buildRepeatedEncodedSlash(40)}public`; + it("detects registered route paths", () => { const registry = createTestRegistry({ httpRoutes: [createRoute({ path: "/demo" })], @@ -150,6 +162,8 @@ describe("plugin HTTP registry helpers", () => { }); expect(shouldEnforceGatewayAuthForPluginPath(registry, "/api//demo")).toBe(true); expect(shouldEnforceGatewayAuthForPluginPath(registry, "/api/channels/status")).toBe(true); + expect(shouldEnforceGatewayAuthForPluginPath(registry, deeplyEncodedChannelPath)).toBe(true); + expect(shouldEnforceGatewayAuthForPluginPath(registry, decodeOverflowPublicPath)).toBe(true); expect(shouldEnforceGatewayAuthForPluginPath(registry, "/not-plugin")).toBe(false); }); });
src/gateway/server/plugins-http.ts+4 −1 modified@@ -2,6 +2,7 @@ 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"; type SubsystemLogger = ReturnType<typeof createSubsystemLogger>; @@ -37,7 +38,9 @@ export function shouldEnforceGatewayAuthForPluginPath( pathname: string, ): boolean { return ( - isProtectedPluginRoutePath(pathname) || isRegisteredPluginHttpRoutePath(registry, pathname) + hasSecurityPathCanonicalizationAnomaly(pathname) || + isProtectedPluginRoutePath(pathname) || + isRegisteredPluginHttpRoutePath(registry, pathname) ); }
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
8- github.com/openclaw/openclaw/commit/2fd8264ab03bd178e62a5f0c50d1c8556c17f12dghsapatchWEB
- github.com/openclaw/openclaw/commit/7a7eee920a176a0043398c6b37bf4cc6eb983eebghsapatchWEB
- github.com/openclaw/openclaw/commit/93b07240257919f770d1e263e1f22753937b80eaghsapatchWEB
- github.com/openclaw/openclaw/commit/d74bc257d8432f17e50b23ae713d7e0623a1fe0fghsapatchWEB
- github.com/advisories/GHSA-v865-p3gq-hw6mghsaADVISORY
- github.com/openclaw/openclaw/security/advisories/GHSA-v865-p3gq-hw6mghsathird-party-advisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-32004ghsaADVISORY
- www.vulncheck.com/advisories/openclaw-authentication-bypass-via-encoded-path-in-api-channels-routeghsathird-party-advisoryWEB
News mentions
0No linked articles in our index yet.