diff --git a/src/gateway/http-utils.authorize-request.test.ts b/src/gateway/http-utils.authorize-request.test.ts index c6b5c901d09..7e339a72086 100644 --- a/src/gateway/http-utils.authorize-request.test.ts +++ b/src/gateway/http-utils.authorize-request.test.ts @@ -17,6 +17,7 @@ vi.mock("../config/config.js", () => ({ vi.mock("./http-common.js", () => ({ sendGatewayAuthFailure: vi.fn(), + sendJson: vi.fn(), })); const { authorizeHttpGatewayConnect } = await import("./auth.js"); diff --git a/src/gateway/http-utils.ts b/src/gateway/http-utils.ts index 55a2b661f11..ba03df32f4f 100644 --- a/src/gateway/http-utils.ts +++ b/src/gateway/http-utils.ts @@ -8,6 +8,7 @@ import { resolveDefaultModelForAgent, } from "../agents/model-selection.js"; import { loadConfig } from "../config/config.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; import { buildAgentMainSessionKey, normalizeAgentId } from "../routing/session-key.js"; import { normalizeLowercaseStringOrEmpty, @@ -20,8 +21,9 @@ import { type GatewayAuthResult, type ResolvedGatewayAuth, } from "./auth.js"; -import { sendGatewayAuthFailure } from "./http-common.js"; +import { sendGatewayAuthFailure, sendJson } from "./http-common.js"; import { ADMIN_SCOPE, CLI_DEFAULT_OPERATOR_SCOPES } from "./method-scopes.js"; +import { authorizeOperatorScopesForMethod } from "./method-scopes.js"; import { loadGatewayModelCatalog } from "./server-model-catalog.js"; export const OPENCLAW_MODEL_ID = "openclaw"; @@ -119,6 +121,48 @@ export async function authorizeGatewayHttpRequestOrReply(params: { }; } +export async function authorizeScopedGatewayHttpRequestOrReply(params: { + req: IncomingMessage; + res: ServerResponse; + auth: ResolvedGatewayAuth; + trustedProxies?: string[]; + allowRealIpFallback?: boolean; + rateLimiter?: AuthRateLimiter; + operatorMethod: string; + resolveOperatorScopes: ( + req: IncomingMessage, + requestAuth: AuthorizedGatewayHttpRequest, + ) => string[]; +}): Promise<{ cfg: OpenClawConfig; requestAuth: AuthorizedGatewayHttpRequest } | null> { + const cfg = loadConfig(); + const requestAuth = await authorizeGatewayHttpRequestOrReply({ + req: params.req, + res: params.res, + auth: params.auth, + trustedProxies: params.trustedProxies ?? cfg.gateway?.trustedProxies, + allowRealIpFallback: params.allowRealIpFallback ?? cfg.gateway?.allowRealIpFallback, + rateLimiter: params.rateLimiter, + }); + if (!requestAuth) { + return null; + } + + const requestedScopes = params.resolveOperatorScopes(params.req, requestAuth); + const scopeAuth = authorizeOperatorScopesForMethod(params.operatorMethod, requestedScopes); + if (!scopeAuth.allowed) { + sendJson(params.res, 403, { + ok: false, + error: { + type: "forbidden", + message: `missing scope: ${scopeAuth.missingScope}`, + }, + }); + return null; + } + + return { cfg, requestAuth }; +} + export function isGatewayBearerHttpRequest( req: IncomingMessage, auth?: SharedSecretGatewayAuth, diff --git a/src/gateway/sessions-history-http.ts b/src/gateway/sessions-history-http.ts index f6505b3bff6..60ba784acb0 100644 --- a/src/gateway/sessions-history-http.ts +++ b/src/gateway/sessions-history-http.ts @@ -1,7 +1,6 @@ import fs from "node:fs"; import type { IncomingMessage, ServerResponse } from "node:http"; import path from "node:path"; -import { loadConfig } from "../config/config.js"; import { loadSessionStore } from "../config/sessions.js"; import { onSessionTranscriptUpdate } from "../sessions/transcript-events.js"; import { @@ -17,11 +16,10 @@ import { setSseHeaders, } from "./http-common.js"; import { - authorizeGatewayHttpRequestOrReply, + authorizeScopedGatewayHttpRequestOrReply, getHeader, resolveTrustedHttpOperatorScopes, } from "./http-utils.js"; -import { authorizeOperatorScopesForMethod } from "./method-scopes.js"; import { DEFAULT_CHAT_HISTORY_TEXT_MAX_CHARS } from "./server-methods/chat.js"; import { buildSessionHistorySnapshot, SessionHistorySseState } from "./session-history-state.js"; import { @@ -108,33 +106,22 @@ export async function handleSessionHistoryHttpRequest( return true; } - const cfg = loadConfig(); - const requestAuth = await authorizeGatewayHttpRequestOrReply({ + // HTTP callers must declare the same least-privilege operator scopes they + // intend to use over WS so both transport surfaces enforce the same gate. + const authResult = await authorizeScopedGatewayHttpRequestOrReply({ req, res, auth: opts.auth, - trustedProxies: opts.trustedProxies ?? cfg.gateway?.trustedProxies, - allowRealIpFallback: opts.allowRealIpFallback ?? cfg.gateway?.allowRealIpFallback, + trustedProxies: opts.trustedProxies, + allowRealIpFallback: opts.allowRealIpFallback, rateLimiter: opts.rateLimiter, + operatorMethod: "chat.history", + resolveOperatorScopes: resolveTrustedHttpOperatorScopes, }); - if (!requestAuth) { - return true; - } - - // HTTP callers must declare the same least-privilege operator scopes they - // intend to use over WS so both transport surfaces enforce the same gate. - const requestedScopes = resolveTrustedHttpOperatorScopes(req, requestAuth); - const scopeAuth = authorizeOperatorScopesForMethod("chat.history", requestedScopes); - if (!scopeAuth.allowed) { - sendJson(res, 403, { - ok: false, - error: { - type: "forbidden", - message: `missing scope: ${scopeAuth.missingScope}`, - }, - }); + if (!authResult) { return true; } + const { cfg } = authResult; const target = resolveGatewaySessionStoreTarget({ cfg, key: sessionKey }); const store = loadSessionStore(target.storePath); diff --git a/src/gateway/tools-invoke-http.ts b/src/gateway/tools-invoke-http.ts index b5db77e718a..b46128484c2 100644 --- a/src/gateway/tools-invoke-http.ts +++ b/src/gateway/tools-invoke-http.ts @@ -4,7 +4,6 @@ import { resolveToolLoopDetectionConfig } from "../agents/pi-tools.js"; import { isKnownCoreToolId } from "../agents/tool-catalog.js"; import { applyOwnerOnlyToolPolicy } from "../agents/tool-policy.js"; import { ToolInputError, type AnyAgentTool } from "../agents/tools/common.js"; -import { loadConfig } from "../config/config.js"; import { resolveMainSessionKey } from "../config/sessions.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { logWarn } from "../logger.js"; @@ -23,12 +22,11 @@ import { sendMethodNotAllowed, } from "./http-common.js"; import { - authorizeGatewayHttpRequestOrReply, + authorizeScopedGatewayHttpRequestOrReply, getHeader, resolveOpenAiCompatibleHttpOperatorScopes, resolveOpenAiCompatibleHttpSenderIsOwner, } from "./http-utils.js"; -import { authorizeOperatorScopesForMethod } from "./method-scopes.js"; import { resolveGatewayScopedTools } from "./tool-resolution.js"; const DEFAULT_BODY_BYTES = 2 * 1024 * 1024; @@ -155,34 +153,23 @@ export async function handleToolsInvokeHttpRequest( return true; } - const cfg = loadConfig(); - const requestAuth = await authorizeGatewayHttpRequestOrReply({ - req, - res, - auth: opts.auth, - trustedProxies: opts.trustedProxies ?? cfg.gateway?.trustedProxies, - allowRealIpFallback: opts.allowRealIpFallback ?? cfg.gateway?.allowRealIpFallback, - rateLimiter: opts.rateLimiter, - }); - if (!requestAuth) { - return true; - } - // /tools/invoke intentionally uses the same shared-secret HTTP trust model as // the OpenAI-compatible APIs: token/password bearer auth is full operator // access for the gateway, not a narrower per-request scope boundary. - const requestedScopes = resolveOpenAiCompatibleHttpOperatorScopes(req, requestAuth); - const scopeAuth = authorizeOperatorScopesForMethod("agent", requestedScopes); - if (!scopeAuth.allowed) { - sendJson(res, 403, { - ok: false, - error: { - type: "forbidden", - message: `missing scope: ${scopeAuth.missingScope}`, - }, - }); + const authResult = await authorizeScopedGatewayHttpRequestOrReply({ + req, + res, + auth: opts.auth, + trustedProxies: opts.trustedProxies, + allowRealIpFallback: opts.allowRealIpFallback, + rateLimiter: opts.rateLimiter, + operatorMethod: "agent", + resolveOperatorScopes: resolveOpenAiCompatibleHttpOperatorScopes, + }); + if (!authResult) { return true; } + const { cfg, requestAuth } = authResult; const bodyUnknown = await readJsonBodyOrError(req, res, opts.maxBodyBytes ?? DEFAULT_BODY_BYTES); if (bodyUnknown === undefined) {