mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-12 09:41:11 +00:00
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
This commit is contained in:
committed by
GitHub
parent
0e54440ecc
commit
6517c700de
@@ -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.
|
||||
|
||||
@@ -87,6 +87,7 @@ export default defineBundledChannelEntry({
|
||||
path: "/api/channels/nostr",
|
||||
auth: "gateway",
|
||||
match: "prefix",
|
||||
gatewayRuntimeScopeSurface: "trusted-operator",
|
||||
handler: httpHandler,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user