VYPR
High severity8.2NVD Advisory· Published Apr 28, 2026· Updated Apr 30, 2026

CVE-2026-41394

CVE-2026-41394

Description

OpenClaw before 2026.3.31 contains an authentication bypass vulnerability where unauthenticated plugin-auth HTTP routes receive operator runtime write scopes. Attackers can access these routes without authentication to perform privileged runtime actions intended for authorized operators.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
< 2026.3.312026.3.31

Affected products

1
  • cpe:2.3:a:openclaw:openclaw:*:*:*:*:*:node.js:*:*
    Range: <2026.3.31

Patches

1
2a1db0c0f1fa

fix(gateway): narrow plugin route runtime scopes (#58167)

https://github.com/openclaw/openclawVincent KocMar 31, 2026via ghsa
4 files changed · +186 104
  • CHANGELOG.md+1 0 modified
    @@ -160,6 +160,7 @@ Docs: https://docs.openclaw.ai
     - Config/SecretRef + Control UI: harden SecretRef redaction round-trip restore, block unsafe raw fallback (force Form mode when raw is unavailable), and preflight submitted-config SecretRefs before config write RPC persistence. (#58044) Thanks @joshavant.
     - Config/Telegram: migrate removed `channels.telegram.groupMentionsOnly` into `channels.telegram.groups["*"].requireMention` on load so legacy configs no longer crash at startup. (#55336) thanks @jameslcowan.
     - Gateway/SecretRef: resolve restart token drift checks with merged service/runtime env sources and hard-fail unsupported mutable SecretRef plus OAuth-profile combinations so restart warnings and policy enforcement match runtime behavior. (#58141) Thanks @joshavant.
    +- Gateway/plugins: scope plugin-auth HTTP route runtime clients to read-only access and keep gateway-authenticated plugin routes on write scope, so plugin-owned webhook handlers do not inherit write-capable runtime access by default. Thanks @davidluzsilva and @vincentkoc.
     - Media/images: reject oversized decoded image inputs before metadata and resize backends run, so tiny compressed image bombs fail early instead of exhausting gateway memory. (#58226) Thanks @AntAISecurityLab and @vincentkoc.
     - Voice Call/media stream: cap inbound WebSocket frame size before `start` validation so oversized pre-start frames are dropped before JSON parsing. Thanks @Kazamayc and @vincentkoc.
     - Pairing: enforce pending request limits per account instead of per shared channel queue, so one account's outstanding pairing challenges no longer block new pairing on other accounts. Thanks @smaeljaish771 and @vincentkoc.
    
  • src/gateway/server/plugins-http.runtime-scopes.test.ts+163 0 added
    @@ -0,0 +1,163 @@
    +import type { IncomingMessage, ServerResponse } from "node:http";
    +import { afterEach, describe, expect, it, vi } from "vitest";
    +import type { SubsystemLogger } from "../../logging/subsystem.js";
    +import { createEmptyPluginRegistry } from "../../plugins/registry.js";
    +import {
    +  releasePinnedPluginHttpRouteRegistry,
    +  setActivePluginRegistry,
    +} from "../../plugins/runtime.js";
    +import { getPluginRuntimeGatewayRequestScope } from "../../plugins/runtime/gateway-request-scope.js";
    +import { authorizeOperatorScopesForMethod } from "../method-scopes.js";
    +import { makeMockHttpResponse } from "../test-http-response.js";
    +import { createTestRegistry } from "./__tests__/test-utils.js";
    +import { createGatewayPluginRequestHandler } from "./plugins-http.js";
    +
    +function createRoute(params: {
    +  path: string;
    +  auth: "gateway" | "plugin";
    +  handler?: (req: IncomingMessage, res: ServerResponse) => boolean | Promise<boolean>;
    +}) {
    +  return {
    +    pluginId: "route",
    +    path: params.path,
    +    auth: params.auth,
    +    match: "exact" as const,
    +    handler: params.handler ?? (() => true),
    +    source: "route",
    +  };
    +}
    +
    +function createMockLogger(): SubsystemLogger {
    +  const logger = {
    +    subsystem: "test/plugins-http-runtime-scopes",
    +    isEnabled: () => true,
    +    trace: vi.fn(),
    +    debug: vi.fn(),
    +    info: vi.fn(),
    +    warn: vi.fn(),
    +    error: vi.fn(),
    +    fatal: vi.fn(),
    +    raw: vi.fn(),
    +    child: vi.fn(),
    +  } satisfies Omit<SubsystemLogger, "child"> & { child: ReturnType<typeof vi.fn> };
    +  logger.child.mockImplementation(() => logger);
    +  return logger;
    +}
    +
    +function assertWriteHelperAllowed() {
    +  const scopes = getPluginRuntimeGatewayRequestScope()?.client?.connect?.scopes ?? [];
    +  const auth = authorizeOperatorScopesForMethod("agent", scopes);
    +  if (!auth.allowed) {
    +    throw new Error(`missing scope: ${auth.missingScope}`);
    +  }
    +}
    +
    +describe("plugin HTTP route runtime scopes", () => {
    +  afterEach(() => {
    +    releasePinnedPluginHttpRouteRegistry();
    +    setActivePluginRegistry(createEmptyPluginRegistry());
    +  });
    +
    +  async function invokeRoute(params: {
    +    path: string;
    +    auth: "gateway" | "plugin";
    +    gatewayAuthSatisfied: boolean;
    +  }) {
    +    const log = createMockLogger();
    +    const handler = createGatewayPluginRequestHandler({
    +      registry: createTestRegistry({
    +        httpRoutes: [
    +          createRoute({
    +            path: params.path,
    +            auth: params.auth,
    +            handler: async () => {
    +              assertWriteHelperAllowed();
    +              return true;
    +            },
    +          }),
    +        ],
    +      }),
    +      log,
    +    });
    +
    +    const response = makeMockHttpResponse();
    +    const handled = await handler(
    +      { url: params.path } as IncomingMessage,
    +      response.res,
    +      undefined,
    +      { gatewayAuthSatisfied: params.gatewayAuthSatisfied },
    +    );
    +    return { handled, log, ...response };
    +  }
    +
    +  it("keeps plugin-auth routes off write-capable runtime helpers", async () => {
    +    const { handled, res, setHeader, end, log } = await invokeRoute({
    +      path: "/hook",
    +      auth: "plugin",
    +      gatewayAuthSatisfied: false,
    +    });
    +
    +    expect(handled).toBe(true);
    +    expect(res.statusCode).toBe(500);
    +    expect(setHeader).toHaveBeenCalledWith("Content-Type", "text/plain; charset=utf-8");
    +    expect(end).toHaveBeenCalledWith("Internal Server Error");
    +    expect(log.warn).toHaveBeenCalledWith(expect.stringContaining("missing scope: operator.write"));
    +  });
    +
    +  it("preserves write-capable runtime helpers on gateway-auth routes", async () => {
    +    const { handled, res, log } = await invokeRoute({
    +      path: "/secure-hook",
    +      auth: "gateway",
    +      gatewayAuthSatisfied: true,
    +    });
    +
    +    expect(handled).toBe(true);
    +    expect(res.statusCode).toBe(200);
    +    expect(log.warn).not.toHaveBeenCalled();
    +  });
    +
    +  it.each([
    +    {
    +      auth: "plugin" as const,
    +      gatewayAuthSatisfied: false,
    +      path: "/hook",
    +      expectedScopes: [],
    +    },
    +    {
    +      auth: "gateway" as const,
    +      gatewayAuthSatisfied: true,
    +      path: "/secure-hook",
    +      expectedScopes: ["operator.write"],
    +    },
    +  ])(
    +    "maps $auth routes to $expectedScopes",
    +    async ({ auth, gatewayAuthSatisfied, path, expectedScopes }) => {
    +      let observedScopes: string[] | undefined;
    +      const handler = createGatewayPluginRequestHandler({
    +        registry: createTestRegistry({
    +          httpRoutes: [
    +            createRoute({
    +              path,
    +              auth,
    +              handler: vi.fn(async () => {
    +                observedScopes =
    +                  getPluginRuntimeGatewayRequestScope()?.client?.connect?.scopes?.slice() ?? [];
    +                return true;
    +              }),
    +            }),
    +          ],
    +        }),
    +        log: createMockLogger(),
    +      });
    +
    +      const { res } = makeMockHttpResponse();
    +      const handled = await handler({ url: path } as IncomingMessage, res, undefined, {
    +        gatewayAuthSatisfied,
    +      });
    +
    +      expect(handled).toBe(true);
    +      expect(res.statusCode).toBe(200);
    +      expect(observedScopes).toEqual(expectedScopes);
    +    },
    +  );
    +});
    
  • src/gateway/server/plugins-http.test.ts+17 99 modified
    @@ -7,8 +7,7 @@ import {
       releasePinnedPluginHttpRouteRegistry,
       setActivePluginRegistry,
     } from "../../plugins/runtime.js";
    -import type { PluginRuntime } from "../../plugins/runtime/types.js";
    -import type { GatewayRequestContext, GatewayRequestOptions } from "../server-methods/types.js";
    +import { getPluginRuntimeGatewayRequestScope } from "../../plugins/runtime/gateway-request-scope.js";
     import { makeMockHttpResponse } from "../test-http-response.js";
     import { createTestRegistry } from "./__tests__/test-utils.js";
     import {
    @@ -17,22 +16,6 @@ import {
       shouldEnforceGatewayAuthForPluginPath,
     } from "./plugins-http.js";
     
    -const loadOpenClawPlugins = vi.hoisted(() => vi.fn());
    -type HandleGatewayRequestOptions = GatewayRequestOptions & {
    -  extraHandlers?: Record<string, unknown>;
    -};
    -const handleGatewayRequest = vi.hoisted(() =>
    -  vi.fn(async (_opts: HandleGatewayRequestOptions) => {}),
    -);
    -
    -vi.mock("../../plugins/loader.js", () => ({
    -  loadOpenClawPlugins,
    -}));
    -
    -vi.mock("../server-methods.js", () => ({
    -  handleGatewayRequest,
    -}));
    -
     type PluginHandlerLog = Parameters<typeof createGatewayPluginRequestHandler>[0]["log"];
     
     function createPluginLog(): PluginHandlerLog {
    @@ -64,37 +47,6 @@ function buildRepeatedEncodedSlash(depth: number): string {
       return encodedSlash;
     }
     
    -function createSubagentRuntimeRegistry() {
    -  return createTestRegistry();
    -}
    -
    -async function createSubagentRuntime(): Promise<PluginRuntime["subagent"]> {
    -  const serverPlugins = await import("../server-plugins.js");
    -  const serverPluginBootstrap = await import("../server-plugin-bootstrap.js");
    -  const runtimeModule = await import("../../plugins/runtime/index.js");
    -  loadOpenClawPlugins.mockReturnValue(createSubagentRuntimeRegistry());
    -  serverPluginBootstrap.loadGatewayStartupPlugins({
    -    cfg: {},
    -    workspaceDir: "/tmp",
    -    log: {
    -      info: vi.fn(),
    -      warn: vi.fn(),
    -      error: vi.fn(),
    -      debug: vi.fn(),
    -    },
    -    coreGatewayHandlers: {},
    -    baseMethods: [],
    -  });
    -  serverPlugins.setFallbackGatewayContext({} as GatewayRequestContext);
    -  const call = loadOpenClawPlugins.mock.calls.at(-1)?.[0] as
    -    | { runtimeOptions?: { allowGatewaySubagentBinding?: boolean } }
    -    | undefined;
    -  if (call?.runtimeOptions?.allowGatewaySubagentBinding !== true) {
    -    throw new Error("Expected loadGatewayPlugins to opt into gateway subagent binding");
    -  }
    -  return runtimeModule.createPluginRuntime({ allowGatewaySubagentBinding: true }).subagent;
    -}
    -
     function createSecurePluginRouteHandler(params: {
       exactPluginHandler: () => boolean | Promise<boolean>;
       prefixGatewayHandler: () => boolean | Promise<boolean>;
    @@ -137,70 +89,34 @@ async function invokeSecureGatewayRoute(params: { gatewayAuthSatisfied: boolean
       return { handled, exactPluginHandler, prefixGatewayHandler };
     }
     
    -function mockOperatorAdminScopeFailure() {
    -  loadOpenClawPlugins.mockReset();
    -  handleGatewayRequest.mockReset();
    -  handleGatewayRequest.mockImplementation(async (opts: HandleGatewayRequestOptions) => {
    -    const scopes = opts.client?.connect.scopes ?? [];
    -    if (opts.req.method === "sessions.delete" && !scopes.includes("operator.admin")) {
    -      opts.respond(false, undefined, {
    -        code: "invalid_request",
    -        message: "missing scope: operator.admin",
    -      });
    -      return;
    -    }
    -    opts.respond(true, {});
    -  });
    -}
    -
    -async function invokeLeastPrivilegeDeleteRoute(params: {
    +async function invokeRouteAndCollectRuntimeScopes(params: {
       path: string;
       auth: "gateway" | "plugin";
       gatewayAuthSatisfied: boolean;
     }) {
    -  mockOperatorAdminScopeFailure();
    -
    -  const subagent = await createSubagentRuntime();
    -  const log = createPluginLog();
    +  let observedScopes: string[] | undefined;
       const handler = createGatewayPluginRequestHandler({
         registry: createTestRegistry({
           httpRoutes: [
             createRoute({
               path: params.path,
               auth: params.auth,
               handler: async () => {
    -            await subagent.deleteSession({ sessionKey: "agent:main:subagent:child" });
    +            observedScopes =
    +              getPluginRuntimeGatewayRequestScope()?.client?.connect?.scopes?.slice() ?? [];
                 return true;
               },
             }),
           ],
         }),
    -    log,
    +    log: createPluginLog(),
       });
     
       const response = makeMockHttpResponse();
       const handled = await handler({ url: params.path } as IncomingMessage, response.res, undefined, {
         gatewayAuthSatisfied: params.gatewayAuthSatisfied,
       });
    -  return { handled, log, ...response };
    -}
    -
    -function expectLeastPrivilegeDeleteRouteFailure(params: {
    -  handled: boolean;
    -  setHeader: ReturnType<typeof makeMockHttpResponse>["setHeader"];
    -  end: ReturnType<typeof makeMockHttpResponse>["end"];
    -  log: ReturnType<typeof createPluginLog>;
    -}) {
    -  expect(params.handled).toBe(true);
    -  expect(handleGatewayRequest).toHaveBeenCalledTimes(1);
    -  expect(handleGatewayRequest.mock.calls[0]?.[0]?.client?.connect.scopes).toEqual([
    -    "operator.write",
    -  ]);
    -  expect(params.setHeader).toHaveBeenCalledWith("Content-Type", "text/plain; charset=utf-8");
    -  expect(params.end).toHaveBeenCalledWith("Internal Server Error");
    -  expect(params.log.warn).toHaveBeenCalledWith(
    -    expect.stringContaining("missing scope: operator.admin"),
    -  );
    +  return { handled, observedScopes, ...response };
     }
     
     describe("createGatewayPluginRequestHandler", () => {
    @@ -209,26 +125,28 @@ describe("createGatewayPluginRequestHandler", () => {
         setActivePluginRegistry(createEmptyPluginRegistry());
       });
     
    -  it("caps unauthenticated plugin routes to non-admin subagent scopes", async () => {
    -    const { handled, res, setHeader, end, log } = await invokeLeastPrivilegeDeleteRoute({
    +  it("keeps unauthenticated plugin routes off operator runtime scopes", async () => {
    +    const { handled, observedScopes, res } = await invokeRouteAndCollectRuntimeScopes({
           path: "/hook",
           auth: "plugin",
           gatewayAuthSatisfied: false,
         });
     
    -    expect(res.statusCode).toBe(500);
    -    expectLeastPrivilegeDeleteRouteFailure({ handled, setHeader, end, log });
    +    expect(handled).toBe(true);
    +    expect(res.statusCode).toBe(200);
    +    expect(observedScopes).toEqual([]);
       });
     
    -  it("keeps gateway-authenticated plugin routes on least-privilege runtime scopes", async () => {
    -    const { handled, res, setHeader, end, log } = await invokeLeastPrivilegeDeleteRoute({
    +  it("keeps gateway-authenticated plugin routes on write runtime scopes", async () => {
    +    const { handled, observedScopes, res } = await invokeRouteAndCollectRuntimeScopes({
           path: "/secure-hook",
           auth: "gateway",
           gatewayAuthSatisfied: true,
         });
     
    -    expect(res.statusCode).toBe(500);
    -    expectLeastPrivilegeDeleteRouteFailure({ handled, setHeader, end, log });
    +    expect(handled).toBe(true);
    +    expect(res.statusCode).toBe(200);
    +    expect(observedScopes).toEqual(["operator.write"]);
       });
     
       it("returns false when no routes are registered", async () => {
    
  • src/gateway/server/plugins-http.ts+5 5 modified
    @@ -27,10 +27,10 @@ export { shouldEnforceGatewayAuthForPluginPath } from "./plugins-http/route-auth
     
     type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
     
    -function createPluginRouteRuntimeClient(): GatewayRequestOptions["client"] {
    -  // Plugin HTTP handlers only need the least-privilege runtime scope.
    -  // Gateway route auth controls request admission, not runtime admin elevation.
    -  const scopes = [WRITE_SCOPE];
    +function createPluginRouteRuntimeClient(
    +  requiresGatewayAuth: boolean,
    +): GatewayRequestOptions["client"] {
    +  const scopes = requiresGatewayAuth ? [WRITE_SCOPE] : [];
       return {
         connect: {
           minProtocol: PROTOCOL_VERSION,
    @@ -81,7 +81,7 @@ export function createGatewayPluginRequestHandler(params: {
           log.warn(`plugin http route blocked without gateway auth (${pathContext.canonicalPath})`);
           return false;
         }
    -    const runtimeClient = createPluginRouteRuntimeClient();
    +    const runtimeClient = createPluginRouteRuntimeClient(requiresGatewayAuth);
     
         return await withPluginRuntimeGatewayRequestScope(
           {
    

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

5

News mentions

0

No linked articles in our index yet.