VYPR
High severityNVD Advisory· Published Mar 19, 2026· Updated Mar 20, 2026

OpenClaw < 2026.3.2 - Authentication Bypass via Encoded Path in /api/channels Route

CVE-2026-32004

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.

PackageAffected versionsPatched versions
openclawnpm
< 2026.3.22026.3.2

Affected products

1

Patches

4
7a7eee920a17

refactor(gateway): harden plugin http route contracts

https://github.com/openclaw/openclawPeter SteinbergerMar 2, 2026via ghsa
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 = {
    
d74bc257d843

fix(line): mark webhook route as plugin-authenticated

https://github.com/openclaw/openclawPeter SteinbergerMar 2, 2026via ghsa
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),
    
2fd8264ab03b

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

https://github.com/openclaw/openclawPeter SteinbergerMar 2, 2026via ghsa
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: [],
    
93b072402579

fix(gateway): fail closed plugin auth path canonicalization

https://github.com/openclaw/openclawPeter SteinbergerMar 2, 2026via ghsa
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

News mentions

0

No linked articles in our index yet.