From 6517c700de9bb0ee11b41ab625ef3b63d01b6083 Mon Sep 17 00:00:00 2001 From: Pavan Kumar Gondhi Date: Fri, 10 Apr 2026 16:38:41 +0530 Subject: [PATCH] fix(nostr): require operator.admin scope for profile mutation routes [AI] (#63553) * fix: address issue * fix: address review feedback * fix: address review feedback * fix: finalize issue changes * fix: address PR review feedback * fix: address review-pr skill feedback * fix: address PR review feedback * fix: address review-pr skill feedback * fix: address PR review feedback * fix: address PR review feedback * fix: address PR review feedback * fix: address PR review feedback * docs: add changelog entry for PR merge --- CHANGELOG.md | 1 + extensions/nostr/index.ts | 1 + extensions/nostr/src/config-schema.ts | 11 +- .../nostr/src/nostr-profile-http.test.ts | 105 ++++++++++++++- extensions/nostr/src/nostr-profile-http.ts | 24 ++++ src/gateway/server-http.ts | 4 + src/gateway/server.plugin-http-auth.test.ts | 62 +++++++++ .../plugin-route-runtime-scopes.test.ts | 46 +++++++ .../server/plugin-route-runtime-scopes.ts | 11 +- .../plugins-http.runtime-scopes.test.ts | 125 +++++++++++++++++- src/gateway/server/plugins-http.ts | 93 ++++++++----- src/plugin-sdk/nostr.ts | 1 + src/plugins/http-registry.ts | 4 + src/plugins/registry.ts | 13 +- src/plugins/types.ts | 2 + 15 files changed, 462 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6001135584..95ecd1c480f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/extensions/nostr/index.ts b/extensions/nostr/index.ts index eeeec9ca3e4..cb13331e698 100644 --- a/extensions/nostr/index.ts +++ b/extensions/nostr/index.ts @@ -87,6 +87,7 @@ export default defineBundledChannelEntry({ path: "/api/channels/nostr", auth: "gateway", match: "prefix", + gatewayRuntimeScopeSurface: "trusted-operator", handler: httpHandler, }); }, diff --git a/extensions/nostr/src/config-schema.ts b/extensions/nostr/src/config-schema.ts index 13329d6506a..f8c2d716810 100644 --- a/extensions/nostr/src/config-schema.ts +++ b/extensions/nostr/src/config-schema.ts @@ -55,7 +55,16 @@ export const NostrProfileSchema = z.object({ lud16: z.string().optional(), }); -export type NostrProfile = z.infer; +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 diff --git a/extensions/nostr/src/nostr-profile-http.test.ts b/extensions/nostr/src/nostr-profile-http.test.ts index e7b24e45818..443bb490836 100644 --- a/extensions/nostr/src/nostr-profile-http.test.ts +++ b/extensions/nostr/src/nostr-profile-http.test.ts @@ -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); +} 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; @@ -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", diff --git a/extensions/nostr/src/nostr-profile-http.ts b/extensions/nostr/src/nostr-profile-http.ts index e20b1f7d747..c02074256f2 100644 --- a/extensions/nostr/src/nostr-profile-http.ts +++ b/extensions/nostr/src/nostr-profile-http.ts @@ -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 { + 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 { + if (!enforceGatewayMutationScope(ctx, accountId, res)) { + return true; + } if (!enforceLoopbackMutationGuards(ctx, req, res)) { return true; } diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 36e0316c611..0b93b09d55e 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -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 ); diff --git a/src/gateway/server.plugin-http-auth.test.ts b/src/gateway/server.plugin-http-auth.test.ts index 5da20af3615..fbbdf81b2a1 100644 --- a/src/gateway/server.plugin-http-auth.test.ts +++ b/src/gateway/server.plugin-http-auth.test.ts @@ -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; diff --git a/src/gateway/server/plugin-route-runtime-scopes.test.ts b/src/gateway/server/plugin-route-runtime-scopes.test.ts index 4ca05f8f4e7..ba153da0a54 100644 --- a/src/gateway/server/plugin-route-runtime-scopes.test.ts +++ b/src/gateway/server/plugin-route-runtime-scopes.test.ts @@ -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"]); + }); }); diff --git a/src/gateway/server/plugin-route-runtime-scopes.ts b/src/gateway/server/plugin-route-runtime-scopes.ts index 8b56d397ad3..eea5a55b00d 100644 --- a/src/gateway/server/plugin-route-runtime-scopes.ts +++ b/src/gateway/server/plugin-route-runtime-scopes.ts @@ -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]; } diff --git a/src/gateway/server/plugins-http.runtime-scopes.test.ts b/src/gateway/server/plugins-http.runtime-scopes.test.ts index 35f103e3cb1..d9a64883de6 100644 --- a/src/gateway/server/plugins-http.runtime-scopes.test.ts +++ b/src/gateway/server/plugins-http.runtime-scopes.test.ts @@ -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; }) { 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, diff --git a/src/gateway/server/plugins-http.ts b/src/gateway/server/plugins-http.ts index 4ae5d63f9b8..ed8ac17ac07 100644 --- a/src/gateway/server/plugins-http.ts +++ b/src/gateway/server/plugins-http.ts @@ -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; }; } diff --git a/src/plugin-sdk/nostr.ts b/src/plugin-sdk/nostr.ts index 12378ca8a03..259d8ba16b0 100644 --- a/src/plugin-sdk/nostr.ts +++ b/src/plugin-sdk/nostr.ts @@ -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, diff --git a/src/plugins/http-registry.ts b/src/plugins/http-registry.ts index 4dc5bfd23b5..9c50aab1b6d 100644 --- a/src/plugins/http-registry.ts +++ b/src/plugins/http-registry.ts @@ -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, }; diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 0d55fe8831e..0b722da1ded 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -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 = { 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, }); }; diff --git a/src/plugins/types.ts b/src/plugins/types.ts index aae4ce62a15..4dc4e1e5d02 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -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; };