VYPR
Medium severity6.5NVD Advisory· Published May 6, 2026· Updated May 7, 2026

CVE-2026-43579

CVE-2026-43579

Description

OpenClaw before 2026.4.10 contains an insufficient access control vulnerability in Nostr plugin HTTP profile routes that allows operators with write permissions to persist profile configuration without requiring admin authority. Attackers with operator.write scope can modify Nostr profile settings through unprotected mutation endpoints to gain unauthorized configuration persistence.

Affected products

2
  • OpenClaw/Openclawinferred2 versions
    <2026.4.10+ 1 more
    • (no CPE)range: <2026.4.10
    • cpe:2.3:a:openclaw:openclaw:*:*:*:*:*:node.js:*:*range: <2026.4.10

Patches

1
6517c700de9b

fix(nostr): require operator.admin scope for profile mutation routes [AI] (#63553)

https://github.com/openclaw/openclawPavan Kumar GondhiApr 10, 2026via nvd-ref
15 files changed · +462 41
  • CHANGELOG.md+1 0 modified
    @@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai
     
     ### Fixes
     
    +- fix(nostr): require operator.admin scope for profile mutation routes [AI]. (#63553) Thanks @pgondhi987.
     - Gateway/startup: keep WebSocket RPC available while channels and plugin sidecars start, hold `chat.history` unavailable until startup sidecars finish so synchronous history reads cannot stall startup (reported in #63450), refresh advertised gateway methods after deferred plugin reloads, and enforce the pre-auth WebSocket upgrade budget before the no-handler 503 path so upgrade floods cannot bypass connection limits during that window. (#63480) Thanks @neeravmakwana.
     - Gateway/tailscale: start Tailscale exposure and the gateway update check before awaiting channel and plugin sidecar startup so remote operators are not locked out when startup sidecars stall.
     - WhatsApp: keep inbound replies, media, composing indicators, and queued outbound deliveries attached to the current socket across reconnect gaps, including fresh retry-eligible sends after the listener comes back. (#30806, #46299, #62892, #63916) Thanks @mcaxtr.
    
  • extensions/nostr/index.ts+1 0 modified
    @@ -87,6 +87,7 @@ export default defineBundledChannelEntry({
           path: "/api/channels/nostr",
           auth: "gateway",
           match: "prefix",
    +      gatewayRuntimeScopeSurface: "trusted-operator",
           handler: httpHandler,
         });
       },
    
  • extensions/nostr/src/config-schema.ts+10 1 modified
    @@ -55,7 +55,16 @@ export const NostrProfileSchema = z.object({
       lud16: z.string().optional(),
     });
     
    -export type NostrProfile = z.infer<typeof NostrProfileSchema>;
    +export interface NostrProfile {
    +  name?: string;
    +  displayName?: string;
    +  about?: string;
    +  picture?: string;
    +  banner?: string;
    +  website?: string;
    +  nip05?: string;
    +  lud16?: string;
    +}
     
     /**
      * Zod schema for channels.nostr.* configuration
    
  • extensions/nostr/src/nostr-profile-http.test.ts+101 4 modified
    @@ -4,7 +4,8 @@
     
     import { IncomingMessage, ServerResponse } from "node:http";
     import { Socket } from "node:net";
    -import { describe, it, expect, vi, beforeEach } from "vitest";
    +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
    +import * as runtimeApi from "../runtime-api.js";
     import {
       clearNostrProfileRateLimitStateForTest,
       createNostrProfileHttpHandler,
    @@ -34,6 +35,25 @@ import { TEST_HEX_PUBLIC_KEY, TEST_SETUP_RELAY_URLS } from "./test-fixtures.js";
     // ============================================================================
     
     const TEST_PROFILE_RELAY_URL = TEST_SETUP_RELAY_URLS[0];
    +const runtimeScopeSpy = vi.spyOn(runtimeApi, "getPluginRuntimeGatewayRequestScope");
    +
    +afterAll(() => {
    +  runtimeScopeSpy.mockRestore();
    +});
    +
    +function setGatewayRuntimeScopes(scopes: readonly string[] | undefined): void {
    +  if (!scopes) {
    +    runtimeScopeSpy.mockReturnValue(undefined);
    +    return;
    +  }
    +  runtimeScopeSpy.mockReturnValue({
    +    client: {
    +      connect: {
    +        scopes: [...scopes],
    +      },
    +    },
    +  } as unknown as ReturnType<typeof runtimeApi.getPluginRuntimeGatewayRequestScope>);
    +}
     
     function createMockRequest(
       method: string,
    @@ -94,10 +114,10 @@ function createMockResponse(): ServerResponse & {
         },
       });
     
    -  (res as unknown as { _getData: () => string })._getData = () => data;
    -  (res as unknown as { _getStatusCode: () => number })._getStatusCode = () => statusCode;
    +  res._getData = () => data;
    +  res._getStatusCode = () => statusCode;
     
    -  return res as ServerResponse & { _getData: () => string; _getStatusCode: () => number };
    +  return res;
     }
     
     type MockResponse = ReturnType<typeof createMockResponse>;
    @@ -173,6 +193,7 @@ describe("nostr-profile-http", () => {
       beforeEach(() => {
         vi.clearAllMocks();
         clearNostrProfileRateLimitStateForTest();
    +    setGatewayRuntimeScopes(["operator.admin"]);
       });
     
       describe("route matching", () => {
    @@ -323,6 +344,44 @@ describe("nostr-profile-http", () => {
           expect(res._getStatusCode()).toBe(403);
         });
     
    +    it("rejects profile mutation when gateway caller is missing operator.admin", async () => {
    +      setGatewayRuntimeScopes(["operator.read"]);
    +      const { ctx, res, run } = createProfileHttpHarness(
    +        "PUT",
    +        "/api/channels/nostr/default/profile",
    +        {
    +          body: { name: "attacker" },
    +        },
    +      );
    +
    +      await run();
    +
    +      expect(res._getStatusCode()).toBe(403);
    +      const data = JSON.parse(res._getData());
    +      expect(data.error).toBe("missing scope: operator.admin");
    +      expect(publishNostrProfile).not.toHaveBeenCalled();
    +      expect(ctx.updateConfigProfile).not.toHaveBeenCalled();
    +    });
    +
    +    it("rejects profile mutation when gateway scope context is missing", async () => {
    +      setGatewayRuntimeScopes(undefined);
    +      const { ctx, res, run } = createProfileHttpHarness(
    +        "PUT",
    +        "/api/channels/nostr/default/profile",
    +        {
    +          body: { name: "attacker" },
    +        },
    +      );
    +
    +      await run();
    +
    +      expect(res._getStatusCode()).toBe(403);
    +      const data = JSON.parse(res._getData());
    +      expect(data.error).toBe("missing scope: operator.admin");
    +      expect(publishNostrProfile).not.toHaveBeenCalled();
    +      expect(ctx.updateConfigProfile).not.toHaveBeenCalled();
    +    });
    +
         it("rejects private IP in picture URL (SSRF protection)", async () => {
           await expectPrivatePictureRejected("https://127.0.0.1/evil.jpg");
         });
    @@ -484,6 +543,44 @@ describe("nostr-profile-http", () => {
           expect(res._getStatusCode()).toBe(403);
         });
     
    +    it("rejects profile import when gateway caller is missing operator.admin", async () => {
    +      setGatewayRuntimeScopes(["operator.read"]);
    +      const { ctx, res, run } = createProfileHttpHarness(
    +        "POST",
    +        "/api/channels/nostr/default/profile/import",
    +        {
    +          body: { autoMerge: true },
    +        },
    +      );
    +
    +      await run();
    +
    +      expect(res._getStatusCode()).toBe(403);
    +      const data = JSON.parse(res._getData());
    +      expect(data.error).toBe("missing scope: operator.admin");
    +      expect(importProfileFromRelays).not.toHaveBeenCalled();
    +      expect(ctx.updateConfigProfile).not.toHaveBeenCalled();
    +    });
    +
    +    it("rejects profile import when gateway scope context is missing", async () => {
    +      setGatewayRuntimeScopes(undefined);
    +      const { ctx, res, run } = createProfileHttpHarness(
    +        "POST",
    +        "/api/channels/nostr/default/profile/import",
    +        {
    +          body: { autoMerge: true },
    +        },
    +      );
    +
    +      await run();
    +
    +      expect(res._getStatusCode()).toBe(403);
    +      const data = JSON.parse(res._getData());
    +      expect(data.error).toBe("missing scope: operator.admin");
    +      expect(importProfileFromRelays).not.toHaveBeenCalled();
    +      expect(ctx.updateConfigProfile).not.toHaveBeenCalled();
    +    });
    +
         it("auto-merges when requested", async () => {
           const { ctx, res, run } = createProfileHttpHarness(
             "POST",
    
  • extensions/nostr/src/nostr-profile-http.ts+24 0 modified
    @@ -16,6 +16,7 @@ import {
     import { z } from "openclaw/plugin-sdk/zod";
     import {
       createFixedWindowRateLimiter,
    +  getPluginRuntimeGatewayRequestScope,
       readJsonBodyWithLimit,
       requestBodyErrorToText,
     } from "../runtime-api.js";
    @@ -128,6 +129,8 @@ const ProfileUpdateSchema = NostrProfileSchema.extend({
       lud16: lud16FormatSchema,
     });
     
    +const PROFILE_MUTATION_SCOPE = "operator.admin";
    +
     // ============================================================================
     // Request Helpers
     // ============================================================================
    @@ -298,6 +301,21 @@ function enforceLoopbackMutationGuards(
       return true;
     }
     
    +function enforceGatewayMutationScope(
    +  ctx: NostrProfileHttpContext,
    +  accountId: string,
    +  res: ServerResponse,
    +): boolean {
    +  const runtimeScopes = getPluginRuntimeGatewayRequestScope()?.client?.connect?.scopes;
    +  const scopes = Array.isArray(runtimeScopes) ? runtimeScopes : [];
    +  if (scopes.includes(PROFILE_MUTATION_SCOPE)) {
    +    return true;
    +  }
    +  ctx.log?.warn?.(`[${accountId}] Rejected profile mutation missing ${PROFILE_MUTATION_SCOPE}`);
    +  sendJson(res, 403, { ok: false, error: `missing scope: ${PROFILE_MUTATION_SCOPE}` });
    +  return false;
    +}
    +
     // ============================================================================
     // HTTP Handler
     // ============================================================================
    @@ -380,6 +398,9 @@ async function handleUpdateProfile(
       req: IncomingMessage,
       res: ServerResponse,
     ): Promise<true> {
    +  if (!enforceGatewayMutationScope(ctx, accountId, res)) {
    +    return true;
    +  }
       if (!enforceLoopbackMutationGuards(ctx, req, res)) {
         return true;
       }
    @@ -483,6 +504,9 @@ async function handleImportProfile(
       req: IncomingMessage,
       res: ServerResponse,
     ): Promise<true> {
    +  if (!enforceGatewayMutationScope(ctx, accountId, res)) {
    +    return true;
    +  }
       if (!enforceLoopbackMutationGuards(ctx, req, res)) {
         return true;
       }
    
  • src/gateway/server-http.ts+4 0 modified
    @@ -59,6 +59,7 @@ import {
     } from "./hooks.js";
     import { sendGatewayAuthFailure, setDefaultSecurityHeaders } from "./http-common.js";
     import {
    +  type AuthorizedGatewayHttpRequest,
       authorizeGatewayHttpRequestOrReply,
       getBearerToken,
       resolveHttpBrowserOriginPolicy,
    @@ -322,6 +323,7 @@ function buildPluginRequestStages(params: {
         return [];
       }
       let pluginGatewayAuthSatisfied = false;
    +  let pluginGatewayRequestAuth: AuthorizedGatewayHttpRequest | undefined;
       let pluginRequestOperatorScopes: string[] | undefined;
       return [
         {
    @@ -351,6 +353,7 @@ function buildPluginRequestStages(params: {
               return true;
             }
             pluginGatewayAuthSatisfied = true;
    +        pluginGatewayRequestAuth = requestAuth;
             pluginRequestOperatorScopes = resolvePluginRouteRuntimeOperatorScopes(
               params.req,
               requestAuth,
    @@ -366,6 +369,7 @@ function buildPluginRequestStages(params: {
             return (
               params.handlePluginRequest?.(params.req, params.res, pathContext, {
                 gatewayAuthSatisfied: pluginGatewayAuthSatisfied,
    +            gatewayRequestAuth: pluginGatewayRequestAuth,
                 gatewayRequestOperatorScopes: pluginRequestOperatorScopes,
               }) ?? false
             );
    
  • src/gateway/server.plugin-http-auth.test.ts+62 0 modified
    @@ -382,6 +382,68 @@ describe("gateway plugin HTTP auth boundary", () => {
         expect(writeAllowedResults).toEqual([true]);
       });
     
    +  test("allows trusted-operator plugin routes to resolve admin-capable runtime scopes for shared-secret bearer auth without scope headers", async () => {
    +    const observedRuntimeScopes: string[][] = [];
    +    const adminAllowedResults: boolean[] = [];
    +    const handlePluginRequest = createGatewayPluginRequestHandler({
    +      registry: createTestRegistry({
    +        httpRoutes: [
    +          {
    +            pluginId: "runtime-scope-bearer-trusted-operator",
    +            source: "runtime-scope-bearer-trusted-operator",
    +            path: "/secure-admin-hook",
    +            auth: "gateway",
    +            gatewayRuntimeScopeSurface: "trusted-operator",
    +            match: "exact",
    +            handler: async (_req: IncomingMessage, res: ServerResponse) => {
    +              const runtimeScopes =
    +                getPluginRuntimeGatewayRequestScope()?.client?.connect?.scopes?.slice() ?? [];
    +              observedRuntimeScopes.push(runtimeScopes);
    +              const adminAuth = authorizeOperatorScopesForMethod("set-heartbeats", runtimeScopes);
    +              adminAllowedResults.push(adminAuth.allowed);
    +              res.statusCode = 200;
    +              res.end("ok");
    +              return true;
    +            },
    +          },
    +        ],
    +      }),
    +      log: { warn: vi.fn() } as unknown as Parameters<
    +        typeof createGatewayPluginRequestHandler
    +      >[0]["log"],
    +    });
    +
    +    await withGatewayServer({
    +      prefix: "openclaw-plugin-http-runtime-scope-bearer-trusted-operator-test-",
    +      resolvedAuth: AUTH_TOKEN,
    +      overrides: {
    +        handlePluginRequest,
    +        shouldEnforcePluginGatewayAuth: (pathContext) =>
    +          pathContext.pathname === "/secure-admin-hook",
    +      },
    +      run: async (server) => {
    +        const response = createResponse();
    +        await dispatchRequest(
    +          server,
    +          createRequest({
    +            path: "/secure-admin-hook",
    +            authorization: "Bearer test-token",
    +          }),
    +          response.res,
    +        );
    +
    +        expect(response.res.statusCode).toBe(200);
    +        expect(response.getBody()).toBe("ok");
    +      },
    +    });
    +
    +    expect(observedRuntimeScopes).toHaveLength(1);
    +    expect(observedRuntimeScopes[0]).toEqual(
    +      expect.arrayContaining(["operator.admin", "operator.read", "operator.write"]),
    +    );
    +    expect(adminAllowedResults).toEqual([true]);
    +  });
    +
       test("allows unauthenticated Mattermost slash callback routes while keeping other channel routes protected", async () => {
         const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => {
           const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
    
  • src/gateway/server/plugin-route-runtime-scopes.test.ts+46 0 modified
    @@ -45,4 +45,50 @@ describe("resolvePluginRouteRuntimeOperatorScopes", () => {
           ),
         ).toEqual(["operator.write"]);
       });
    +
    +  it("restores trusted default operator scopes for shared-secret bearer routes opting into trusted-operator surface", () => {
    +    expect(
    +      resolvePluginRouteRuntimeOperatorScopes(
    +        createReq({
    +          authorization: "Bearer secret",
    +        }),
    +        { authMethod: "token", trustDeclaredOperatorScopes: false },
    +        "trusted-operator",
    +      ),
    +    ).toEqual([
    +      "operator.admin",
    +      "operator.read",
    +      "operator.write",
    +      "operator.approvals",
    +      "operator.pairing",
    +      "operator.talk.secrets",
    +    ]);
    +  });
    +
    +  it("restores trusted default operator scopes for trusted-proxy routes opting into trusted-operator when scopes header is absent", () => {
    +    expect(
    +      resolvePluginRouteRuntimeOperatorScopes(
    +        createReq(),
    +        { authMethod: "trusted-proxy", trustDeclaredOperatorScopes: true },
    +        "trusted-operator",
    +      ),
    +    ).toEqual([
    +      "operator.admin",
    +      "operator.read",
    +      "operator.write",
    +      "operator.approvals",
    +      "operator.pairing",
    +      "operator.talk.secrets",
    +    ]);
    +  });
    +
    +  it("preserves trusted-proxy declared scopes for routes opting into trusted-operator surface", () => {
    +    expect(
    +      resolvePluginRouteRuntimeOperatorScopes(
    +        createReq({ "x-openclaw-scopes": "operator.admin,operator.write" }),
    +        { authMethod: "trusted-proxy", trustDeclaredOperatorScopes: true },
    +        "trusted-operator",
    +      ),
    +    ).toEqual(["operator.admin", "operator.write"]);
    +  });
     });
    
  • src/gateway/server/plugin-route-runtime-scopes.ts+10 1 modified
    @@ -4,12 +4,21 @@ import {
       resolveTrustedHttpOperatorScopes,
       type AuthorizedGatewayHttpRequest,
     } from "../http-utils.js";
    -import { WRITE_SCOPE } from "../method-scopes.js";
    +import { CLI_DEFAULT_OPERATOR_SCOPES, WRITE_SCOPE } from "../method-scopes.js";
    +
    +export type PluginRouteRuntimeScopeSurface = "write-default" | "trusted-operator";
     
     export function resolvePluginRouteRuntimeOperatorScopes(
       req: IncomingMessage,
       requestAuth: AuthorizedGatewayHttpRequest,
    +  surface: PluginRouteRuntimeScopeSurface = "write-default",
     ): string[] {
    +  if (surface === "trusted-operator") {
    +    if (!requestAuth.trustDeclaredOperatorScopes) {
    +      return [...CLI_DEFAULT_OPERATOR_SCOPES];
    +    }
    +    return resolveTrustedHttpOperatorScopes(req, requestAuth);
    +  }
       if (requestAuth.authMethod !== "trusted-proxy") {
         return [WRITE_SCOPE];
       }
    
  • src/gateway/server/plugins-http.runtime-scopes.test.ts+124 1 modified
    @@ -7,6 +7,7 @@ import {
       setActivePluginRegistry,
     } from "../../plugins/runtime.js";
     import { getPluginRuntimeGatewayRequestScope } from "../../plugins/runtime/gateway-request-scope.js";
    +import type { AuthorizedGatewayHttpRequest } from "../http-utils.js";
     import { authorizeOperatorScopesForMethod } from "../method-scopes.js";
     import { makeMockHttpResponse } from "../test-http-response.js";
     import { createTestRegistry } from "./__tests__/test-utils.js";
    @@ -15,13 +16,16 @@ import { createGatewayPluginRequestHandler } from "./plugins-http.js";
     function createRoute(params: {
       path: string;
       auth: "gateway" | "plugin";
    +  match?: "exact" | "prefix";
    +  gatewayRuntimeScopeSurface?: "write-default" | "trusted-operator";
       handler?: (req: IncomingMessage, res: ServerResponse) => boolean | Promise<boolean>;
     }) {
       return {
         pluginId: "route",
         path: params.path,
         auth: params.auth,
    -    match: "exact" as const,
    +    gatewayRuntimeScopeSurface: params.gatewayRuntimeScopeSurface,
    +    match: params.match ?? "exact",
         handler: params.handler ?? (() => true),
         source: "route",
       };
    @@ -53,6 +57,14 @@ function assertWriteHelperAllowed() {
       }
     }
     
    +function assertAdminHelperAllowed() {
    +  const scopes = getPluginRuntimeGatewayRequestScope()?.client?.connect?.scopes ?? [];
    +  const auth = authorizeOperatorScopesForMethod("set-heartbeats", scopes);
    +  if (!auth.allowed) {
    +    throw new Error(`missing scope: ${auth.missingScope}`);
    +  }
    +}
    +
     describe("plugin HTTP route runtime scopes", () => {
       afterEach(() => {
         releasePinnedPluginHttpRouteRegistry();
    @@ -62,7 +74,9 @@ describe("plugin HTTP route runtime scopes", () => {
       async function invokeRoute(params: {
         path: string;
         auth: "gateway" | "plugin";
    +    gatewayRuntimeScopeSurface?: "write-default" | "trusted-operator";
         gatewayAuthSatisfied: boolean;
    +    gatewayRequestAuth?: AuthorizedGatewayHttpRequest;
         gatewayRequestOperatorScopes?: readonly string[];
       }) {
         const log = createMockLogger();
    @@ -72,6 +86,7 @@ describe("plugin HTTP route runtime scopes", () => {
               createRoute({
                 path: params.path,
                 auth: params.auth,
    +            gatewayRuntimeScopeSurface: params.gatewayRuntimeScopeSurface,
                 handler: async () => {
                   assertWriteHelperAllowed();
                   return true;
    @@ -89,6 +104,7 @@ describe("plugin HTTP route runtime scopes", () => {
           undefined,
           {
             gatewayAuthSatisfied: params.gatewayAuthSatisfied,
    +        gatewayRequestAuth: params.gatewayRequestAuth,
             gatewayRequestOperatorScopes: params.gatewayRequestOperatorScopes,
           },
         );
    @@ -151,6 +167,113 @@ describe("plugin HTTP route runtime scopes", () => {
         expect(log.warn).toHaveBeenCalledWith(expect.stringContaining("missing scope: operator.write"));
       });
     
    +  it("restores trusted-operator defaults for routes opting into trusted surface", async () => {
    +    let observedScopes: string[] | undefined;
    +    const log = createMockLogger();
    +    const handler = createGatewayPluginRequestHandler({
    +      registry: createTestRegistry({
    +        httpRoutes: [
    +          createRoute({
    +            path: "/secure-admin-hook",
    +            auth: "gateway",
    +            gatewayRuntimeScopeSurface: "trusted-operator",
    +            handler: async () => {
    +              observedScopes =
    +                getPluginRuntimeGatewayRequestScope()?.client?.connect?.scopes?.slice() ?? [];
    +              assertAdminHelperAllowed();
    +              return true;
    +            },
    +          }),
    +        ],
    +      }),
    +      log,
    +    });
    +
    +    const response = makeMockHttpResponse();
    +    const handled = await handler(
    +      { url: "/secure-admin-hook" } as IncomingMessage,
    +      response.res,
    +      undefined,
    +      {
    +        gatewayAuthSatisfied: true,
    +        gatewayRequestAuth: { authMethod: "token", trustDeclaredOperatorScopes: false },
    +        gatewayRequestOperatorScopes: ["operator.write"],
    +      },
    +    );
    +
    +    expect(handled).toBe(true);
    +    expect(response.res.statusCode).toBe(200);
    +    expect(log.warn).not.toHaveBeenCalled();
    +    expect(observedScopes).toEqual(
    +      expect.arrayContaining(["operator.admin", "operator.read", "operator.write"]),
    +    );
    +  });
    +
    +  it("scopes runtime privileges per matched route for exact/prefix overlap", async () => {
    +    const observed: Array<{ route: "exact" | "prefix"; scopes: string[] }> = [];
    +    const log = createMockLogger();
    +    const handler = createGatewayPluginRequestHandler({
    +      registry: createTestRegistry({
    +        httpRoutes: [
    +          createRoute({
    +            path: "/secure/admin-hook",
    +            auth: "gateway",
    +            match: "exact",
    +            handler: async () => {
    +              observed.push({
    +                route: "exact",
    +                scopes:
    +                  getPluginRuntimeGatewayRequestScope()?.client?.connect?.scopes?.slice() ?? [],
    +              });
    +              return false;
    +            },
    +          }),
    +          createRoute({
    +            path: "/secure",
    +            auth: "gateway",
    +            match: "prefix",
    +            gatewayRuntimeScopeSurface: "trusted-operator",
    +            handler: async () => {
    +              observed.push({
    +                route: "prefix",
    +                scopes:
    +                  getPluginRuntimeGatewayRequestScope()?.client?.connect?.scopes?.slice() ?? [],
    +              });
    +              assertAdminHelperAllowed();
    +              return true;
    +            },
    +          }),
    +        ],
    +      }),
    +      log,
    +    });
    +
    +    const response = makeMockHttpResponse();
    +    const handled = await handler(
    +      { url: "/secure/admin-hook" } as IncomingMessage,
    +      response.res,
    +      undefined,
    +      {
    +        gatewayAuthSatisfied: true,
    +        gatewayRequestAuth: { authMethod: "token", trustDeclaredOperatorScopes: false },
    +        gatewayRequestOperatorScopes: ["operator.write"],
    +      },
    +    );
    +
    +    expect(handled).toBe(true);
    +    expect(response.res.statusCode).toBe(200);
    +    expect(log.warn).not.toHaveBeenCalled();
    +    expect(observed).toHaveLength(2);
    +    expect(observed[0]).toEqual({
    +      route: "exact",
    +      scopes: ["operator.write"],
    +    });
    +    expect(observed[1]?.route).toBe("prefix");
    +    expect(observed[1]?.scopes).toEqual(
    +      expect.arrayContaining(["operator.admin", "operator.read", "operator.write"]),
    +    );
    +  });
    +
       it.each([
         {
           auth: "plugin" as const,
    
  • src/gateway/server/plugins-http.ts+61 32 modified
    @@ -3,9 +3,11 @@ import type { createSubsystemLogger } from "../../logging/subsystem.js";
     import type { PluginRegistry } from "../../plugins/registry.js";
     import { resolveActivePluginHttpRouteRegistry } from "../../plugins/runtime.js";
     import { withPluginRuntimeGatewayRequestScope } from "../../plugins/runtime/gateway-request-scope.js";
    +import type { AuthorizedGatewayHttpRequest } from "../http-utils.js";
     import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../protocol/client-info.js";
     import { PROTOCOL_VERSION } from "../protocol/index.js";
     import type { GatewayRequestOptions } from "../server-methods/types.js";
    +import { resolvePluginRouteRuntimeOperatorScopes } from "./plugin-route-runtime-scopes.js";
     import {
       resolvePluginRoutePathContext,
       type PluginRoutePathContext,
    @@ -47,6 +49,7 @@ function createPluginRouteRuntimeClient(
     
     export type PluginRouteDispatchContext = {
       gatewayAuthSatisfied?: boolean;
    +  gatewayRequestAuth?: AuthorizedGatewayHttpRequest;
       gatewayRequestOperatorScopes?: readonly string[];
     };
     
    @@ -80,46 +83,72 @@ export function createGatewayPluginRequestHandler(params: {
           return false;
         }
         const requiresGatewayAuth = matchedPluginRoutesRequireGatewayAuth(matchedRoutes);
    -    let runtimeScopes: readonly string[] = [];
    -    if (requiresGatewayAuth) {
    -      if (dispatchContext?.gatewayAuthSatisfied !== true) {
    -        log.warn(`plugin http route blocked without gateway auth (${pathContext.canonicalPath})`);
    -        return false;
    +    if (requiresGatewayAuth && dispatchContext?.gatewayAuthSatisfied !== true) {
    +      log.warn(`plugin http route blocked without gateway auth (${pathContext.canonicalPath})`);
    +      return false;
    +    }
    +    const gatewayRequestAuth = dispatchContext?.gatewayRequestAuth;
    +    const gatewayRequestOperatorScopes = dispatchContext?.gatewayRequestOperatorScopes;
    +
    +    // Fail closed before invoking any handlers when matched gateway routes are
    +    // missing the runtime auth/scope context they require.
    +    for (const route of matchedRoutes) {
    +      if (route.auth !== "gateway") {
    +        continue;
           }
    -      if (dispatchContext.gatewayRequestOperatorScopes === undefined) {
    +      if (route.gatewayRuntimeScopeSurface === "trusted-operator") {
    +        if (!gatewayRequestAuth) {
    +          log.warn(
    +            `plugin http route blocked without caller auth context (${pathContext.canonicalPath})`,
    +          );
    +          return false;
    +        }
    +        continue;
    +      }
    +      if (gatewayRequestOperatorScopes === undefined) {
             log.warn(
               `plugin http route blocked without caller scope context (${pathContext.canonicalPath})`,
             );
             return false;
           }
    -      runtimeScopes = dispatchContext.gatewayRequestOperatorScopes;
         }
    -    const runtimeClient = createPluginRouteRuntimeClient(runtimeScopes);
     
    -    return await withPluginRuntimeGatewayRequestScope(
    -      {
    -        client: runtimeClient,
    -        isWebchatConnect: () => false,
    -      },
    -      async () => {
    -        for (const route of matchedRoutes) {
    -          try {
    -            const handled = await route.handler(req, res);
    -            if (handled !== false) {
    -              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;
    -          }
    +    for (const route of matchedRoutes) {
    +      let runtimeScopes: readonly string[] = [];
    +      if (route.auth === "gateway") {
    +        if (route.gatewayRuntimeScopeSurface === "trusted-operator") {
    +          runtimeScopes = resolvePluginRouteRuntimeOperatorScopes(
    +            req,
    +            gatewayRequestAuth!,
    +            "trusted-operator",
    +          );
    +        } else {
    +          runtimeScopes = gatewayRequestOperatorScopes!;
             }
    -        return false;
    -      },
    -    );
    +      }
    +
    +      const runtimeClient = createPluginRouteRuntimeClient(runtimeScopes);
    +      try {
    +        const handled = await withPluginRuntimeGatewayRequestScope(
    +          {
    +            client: runtimeClient,
    +            isWebchatConnect: () => false,
    +          },
    +          async () => route.handler(req, res),
    +        );
    +        if (handled !== false) {
    +          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;
    +      }
    +    }
    +    return false;
       };
     }
    
  • src/plugin-sdk/nostr.ts+1 0 modified
    @@ -7,6 +7,7 @@ export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js";
     export type { ChannelSetupAdapter } from "../channels/plugins/types.adapters.js";
     export { formatPairingApproveHint } from "../channels/plugins/helpers.js";
     export type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
    +export { getPluginRuntimeGatewayRequestScope } from "../plugins/runtime/gateway-request-scope.js";
     export { createChannelReplyPipeline } from "./channel-reply-pipeline.js";
     export {
       createDirectDmPreCryptoGuardPolicy,
    
  • src/plugins/http-registry.ts+4 0 modified
    @@ -15,6 +15,7 @@ export function registerPluginHttpRoute(params: {
       handler: PluginHttpRouteHandler;
       auth: PluginHttpRouteRegistration["auth"];
       match?: PluginHttpRouteRegistration["match"];
    +  gatewayRuntimeScopeSurface?: PluginHttpRouteRegistration["gatewayRuntimeScopeSurface"];
       replaceExisting?: boolean;
       pluginId?: string;
       source?: string;
    @@ -78,6 +79,9 @@ export function registerPluginHttpRoute(params: {
         handler: params.handler,
         auth: params.auth,
         match: routeMatch,
    +    ...(params.gatewayRuntimeScopeSurface
    +      ? { gatewayRuntimeScopeSurface: params.gatewayRuntimeScopeSurface }
    +      : {}),
         pluginId: params.pluginId,
         source: params.source,
       };
    
  • src/plugins/registry.ts+11 2 modified
    @@ -46,7 +46,7 @@ import type {
       PluginCommandRegistration,
       PluginConversationBindingResolvedHandlerRegistration,
       PluginHookRegistration,
    -  PluginHttpRouteRegistration,
    +  PluginHttpRouteRegistration as RegistryTypesPluginHttpRouteRegistration,
       PluginMemoryEmbeddingProviderRegistration,
       PluginNodeHostCommandRegistration,
       PluginProviderRegistration,
    @@ -75,6 +75,7 @@ import type {
       OpenClawPluginCliRegistrar,
       OpenClawPluginCommandDefinition,
       PluginConversationBindingResolvedEvent,
    +  OpenClawPluginGatewayRuntimeScopeSurface,
       OpenClawPluginHttpRouteParams,
       OpenClawPluginHookOptions,
       OpenClawPluginNodeHostCommand,
    @@ -99,6 +100,9 @@ import type {
       WebSearchProviderPlugin,
     } from "./types.js";
     
    +export type PluginHttpRouteRegistration = RegistryTypesPluginHttpRouteRegistration & {
    +  gatewayRuntimeScopeSurface?: OpenClawPluginGatewayRuntimeScopeSurface;
    +};
     type PluginOwnedProviderRegistration<T extends { id: string }> = {
       pluginId: string;
       pluginName?: string;
    @@ -115,7 +119,6 @@ export type {
       PluginCommandRegistration,
       PluginConversationBindingResolvedHandlerRegistration,
       PluginHookRegistration,
    -  PluginHttpRouteRegistration,
       PluginMemoryEmbeddingProviderRegistration,
       PluginNodeHostCommandRegistration,
       PluginProviderRegistration,
    @@ -390,6 +393,9 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
             handler: params.handler,
             auth: params.auth,
             match,
    +        ...(params.gatewayRuntimeScopeSurface
    +          ? { gatewayRuntimeScopeSurface: params.gatewayRuntimeScopeSurface }
    +          : {}),
             source: record.source,
           };
           return;
    @@ -401,6 +407,9 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
           handler: params.handler,
           auth: params.auth,
           match,
    +      ...(params.gatewayRuntimeScopeSurface
    +        ? { gatewayRuntimeScopeSurface: params.gatewayRuntimeScopeSurface }
    +        : {}),
           source: record.source,
         });
       };
    
  • src/plugins/types.ts+2 0 modified
    @@ -1960,6 +1960,7 @@ export type PluginInteractiveHandlerRegistration = PluginInteractiveRegistration
     
     export type OpenClawPluginHttpRouteAuth = "gateway" | "plugin";
     export type OpenClawPluginHttpRouteMatch = "exact" | "prefix";
    +export type OpenClawPluginGatewayRuntimeScopeSurface = "write-default" | "trusted-operator";
     
     export type OpenClawPluginHttpRouteHandler = (
       req: IncomingMessage,
    @@ -1971,6 +1972,7 @@ export type OpenClawPluginHttpRouteParams = {
       handler: OpenClawPluginHttpRouteHandler;
       auth: OpenClawPluginHttpRouteAuth;
       match?: OpenClawPluginHttpRouteMatch;
    +  gatewayRuntimeScopeSurface?: OpenClawPluginGatewayRuntimeScopeSurface;
       replaceExisting?: boolean;
     };
     
    

Vulnerability mechanics

AI mechanics synthesis has not run for this CVE yet.

References

3

News mentions

9