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.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.3.31 | 2026.3.31 |
Affected products
1Patches
12a1db0c0f1fafix(gateway): narrow plugin route runtime scopes (#58167)
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- github.com/openclaw/openclaw/commit/2a1db0c0f1fa375004a95ba0ef030534790a6d47nvdPatchWEB
- github.com/advisories/GHSA-mhgq-xpfq-6r66ghsaADVISORY
- github.com/openclaw/openclaw/security/advisories/GHSA-mhgq-xpfq-6r66nvdVendor AdvisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-41394ghsaADVISORY
- www.vulncheck.com/advisories/openclaw-unauthorized-operator-scope-access-in-unauthenticated-plugin-auth-routesnvdThird Party AdvisoryWEB
News mentions
0No linked articles in our index yet.