mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-03 13:22:14 +00:00
gateway: ignore bearer-declared HTTP operator scopes (#57783)
* gateway: ignore bearer-declared HTTP operator scopes * gateway: key HTTP bearer guards to auth mode * gateway: refresh rebased HTTP regression expectations * gateway: honor resolved HTTP auth method * gateway: remove duplicate openresponses owner flags
This commit is contained in:
@@ -3,10 +3,10 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import type { ResolvedGatewayAuth } from "./auth.js";
|
||||
import { handleGatewayPostJsonEndpoint } from "./http-endpoint-helpers.js";
|
||||
|
||||
vi.mock("./http-auth-helpers.js", () => {
|
||||
vi.mock("./http-utils.js", () => {
|
||||
return {
|
||||
authorizeGatewayBearerRequestOrReply: vi.fn(),
|
||||
resolveGatewayRequestedOperatorScopes: vi.fn(),
|
||||
authorizeGatewayHttpRequestOrReply: vi.fn(),
|
||||
resolveTrustedHttpOperatorScopes: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -24,9 +24,9 @@ vi.mock("./method-scopes.js", () => {
|
||||
};
|
||||
});
|
||||
|
||||
const { authorizeGatewayBearerRequestOrReply } = await import("./http-auth-helpers.js");
|
||||
const { resolveGatewayRequestedOperatorScopes } = await import("./http-auth-helpers.js");
|
||||
const { readJsonBodyOrError, sendJson, sendMethodNotAllowed } = await import("./http-common.js");
|
||||
const { authorizeGatewayHttpRequestOrReply, resolveTrustedHttpOperatorScopes } =
|
||||
await import("./http-utils.js");
|
||||
const { authorizeOperatorScopesForMethod } = await import("./method-scopes.js");
|
||||
|
||||
describe("handleGatewayPostJsonEndpoint", () => {
|
||||
@@ -60,7 +60,7 @@ describe("handleGatewayPostJsonEndpoint", () => {
|
||||
});
|
||||
|
||||
it("returns undefined when auth fails", async () => {
|
||||
vi.mocked(authorizeGatewayBearerRequestOrReply).mockResolvedValue(false);
|
||||
vi.mocked(authorizeGatewayHttpRequestOrReply).mockResolvedValue(null);
|
||||
const result = await handleGatewayPostJsonEndpoint(
|
||||
{
|
||||
url: "/v1/ok",
|
||||
@@ -74,7 +74,9 @@ describe("handleGatewayPostJsonEndpoint", () => {
|
||||
});
|
||||
|
||||
it("returns body when auth succeeds and JSON parsing succeeds", async () => {
|
||||
vi.mocked(authorizeGatewayBearerRequestOrReply).mockResolvedValue(true);
|
||||
vi.mocked(authorizeGatewayHttpRequestOrReply).mockResolvedValue({
|
||||
trustDeclaredOperatorScopes: true,
|
||||
});
|
||||
vi.mocked(readJsonBodyOrError).mockResolvedValue({ hello: "world" });
|
||||
const result = await handleGatewayPostJsonEndpoint(
|
||||
{
|
||||
@@ -85,12 +87,17 @@ describe("handleGatewayPostJsonEndpoint", () => {
|
||||
{} as unknown as ServerResponse,
|
||||
{ pathname: "/v1/ok", auth: {} as unknown as ResolvedGatewayAuth, maxBodyBytes: 123 },
|
||||
);
|
||||
expect(result).toEqual({ body: { hello: "world" } });
|
||||
expect(result).toEqual({
|
||||
body: { hello: "world" },
|
||||
requestAuth: { trustDeclaredOperatorScopes: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns undefined and replies when required operator scope is missing", async () => {
|
||||
vi.mocked(authorizeGatewayBearerRequestOrReply).mockResolvedValue(true);
|
||||
vi.mocked(resolveGatewayRequestedOperatorScopes).mockReturnValue(["operator.approvals"]);
|
||||
vi.mocked(authorizeGatewayHttpRequestOrReply).mockResolvedValue({
|
||||
trustDeclaredOperatorScopes: false,
|
||||
});
|
||||
vi.mocked(resolveTrustedHttpOperatorScopes).mockReturnValue(["operator.approvals"]);
|
||||
vi.mocked(authorizeOperatorScopesForMethod).mockReturnValue({
|
||||
allowed: false,
|
||||
missingScope: "operator.write",
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { AuthRateLimiter } from "./auth-rate-limit.js";
|
||||
import type { ResolvedGatewayAuth } from "./auth.js";
|
||||
import {
|
||||
authorizeGatewayBearerRequestOrReply,
|
||||
resolveGatewayRequestedOperatorScopes,
|
||||
} from "./http-auth-helpers.js";
|
||||
import { readJsonBodyOrError, sendJson, sendMethodNotAllowed } from "./http-common.js";
|
||||
import {
|
||||
authorizeGatewayHttpRequestOrReply,
|
||||
type AuthorizedGatewayHttpRequest,
|
||||
resolveTrustedHttpOperatorScopes,
|
||||
} from "./http-utils.js";
|
||||
import { authorizeOperatorScopesForMethod } from "./method-scopes.js";
|
||||
|
||||
export async function handleGatewayPostJsonEndpoint(
|
||||
@@ -20,7 +21,7 @@ export async function handleGatewayPostJsonEndpoint(
|
||||
rateLimiter?: AuthRateLimiter;
|
||||
requiredOperatorMethod?: "chat.send" | (string & Record<never, never>);
|
||||
},
|
||||
): Promise<false | { body: unknown } | undefined> {
|
||||
): Promise<false | { body: unknown; requestAuth: AuthorizedGatewayHttpRequest } | undefined> {
|
||||
const url = new URL(req.url ?? "/", `http://${req.headers.host || "localhost"}`);
|
||||
if (url.pathname !== opts.pathname) {
|
||||
return false;
|
||||
@@ -31,7 +32,7 @@ export async function handleGatewayPostJsonEndpoint(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const authorized = await authorizeGatewayBearerRequestOrReply({
|
||||
const requestAuth = await authorizeGatewayHttpRequestOrReply({
|
||||
req,
|
||||
res,
|
||||
auth: opts.auth,
|
||||
@@ -39,12 +40,12 @@ export async function handleGatewayPostJsonEndpoint(
|
||||
allowRealIpFallback: opts.allowRealIpFallback,
|
||||
rateLimiter: opts.rateLimiter,
|
||||
});
|
||||
if (!authorized) {
|
||||
if (!requestAuth) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (opts.requiredOperatorMethod) {
|
||||
const requestedScopes = resolveGatewayRequestedOperatorScopes(req);
|
||||
const requestedScopes = resolveTrustedHttpOperatorScopes(req, requestAuth);
|
||||
const scopeAuth = authorizeOperatorScopesForMethod(
|
||||
opts.requiredOperatorMethod,
|
||||
requestedScopes,
|
||||
@@ -66,5 +67,5 @@ export async function handleGatewayPostJsonEndpoint(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { body };
|
||||
return { body, requestAuth };
|
||||
}
|
||||
|
||||
89
src/gateway/http-utils.authorize-request.test.ts
Normal file
89
src/gateway/http-utils.authorize-request.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("./auth.js", () => ({
|
||||
authorizeHttpGatewayConnect: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./http-common.js", () => ({
|
||||
sendGatewayAuthFailure: vi.fn(),
|
||||
}));
|
||||
|
||||
const { authorizeHttpGatewayConnect } = await import("./auth.js");
|
||||
const { sendGatewayAuthFailure } = await import("./http-common.js");
|
||||
const { authorizeGatewayHttpRequestOrReply } = await import("./http-utils.js");
|
||||
|
||||
function createReq(headers: Record<string, string> = {}): IncomingMessage {
|
||||
return { headers } as IncomingMessage;
|
||||
}
|
||||
|
||||
describe("authorizeGatewayHttpRequestOrReply", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(authorizeHttpGatewayConnect).mockReset();
|
||||
vi.mocked(sendGatewayAuthFailure).mockReset();
|
||||
});
|
||||
|
||||
it("marks token-authenticated requests as untrusted for declared HTTP scopes", async () => {
|
||||
vi.mocked(authorizeHttpGatewayConnect).mockResolvedValue({
|
||||
ok: true,
|
||||
method: "token",
|
||||
});
|
||||
|
||||
await expect(
|
||||
authorizeGatewayHttpRequestOrReply({
|
||||
req: createReq({ authorization: "Bearer secret" }),
|
||||
res: {} as ServerResponse,
|
||||
auth: { mode: "trusted-proxy", allowTailscale: false, token: "secret" },
|
||||
trustedProxies: ["127.0.0.1"],
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
authMethod: "token",
|
||||
trustDeclaredOperatorScopes: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps trusted-proxy requests eligible for declared HTTP scopes", async () => {
|
||||
vi.mocked(authorizeHttpGatewayConnect).mockResolvedValue({
|
||||
ok: true,
|
||||
method: "trusted-proxy",
|
||||
user: "operator",
|
||||
});
|
||||
|
||||
await expect(
|
||||
authorizeGatewayHttpRequestOrReply({
|
||||
req: createReq({ authorization: "Bearer upstream-idp-token" }),
|
||||
res: {} as ServerResponse,
|
||||
auth: {
|
||||
mode: "trusted-proxy",
|
||||
allowTailscale: false,
|
||||
trustedProxy: { userHeader: "x-user" },
|
||||
},
|
||||
trustedProxies: ["127.0.0.1"],
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
authMethod: "trusted-proxy",
|
||||
trustDeclaredOperatorScopes: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("replies with auth failure and returns null when auth fails", async () => {
|
||||
const res = {} as ServerResponse;
|
||||
vi.mocked(authorizeHttpGatewayConnect).mockResolvedValue({
|
||||
ok: false,
|
||||
reason: "unauthorized",
|
||||
});
|
||||
|
||||
await expect(
|
||||
authorizeGatewayHttpRequestOrReply({
|
||||
req: createReq(),
|
||||
res,
|
||||
auth: { mode: "token", allowTailscale: false, token: "secret" },
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
|
||||
expect(sendGatewayAuthFailure).toHaveBeenCalledWith(res, {
|
||||
ok: false,
|
||||
reason: "unauthorized",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,18 @@
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveGatewayRequestContext } from "./http-utils.js";
|
||||
import {
|
||||
resolveGatewayRequestContext,
|
||||
resolveHttpSenderIsOwner,
|
||||
resolveTrustedHttpOperatorScopes,
|
||||
} from "./http-utils.js";
|
||||
|
||||
function createReq(headers: Record<string, string> = {}): IncomingMessage {
|
||||
return { headers } as IncomingMessage;
|
||||
}
|
||||
|
||||
const tokenAuth = { mode: "token" as const };
|
||||
const noneAuth = { mode: "none" as const };
|
||||
|
||||
describe("resolveGatewayRequestContext", () => {
|
||||
it("uses normalized x-openclaw-message-channel when enabled", () => {
|
||||
const result = resolveGatewayRequestContext({
|
||||
@@ -43,3 +50,75 @@ describe("resolveGatewayRequestContext", () => {
|
||||
expect(result.sessionKey).toContain("openresponses-user:alice");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTrustedHttpOperatorScopes", () => {
|
||||
it("drops self-asserted scopes for bearer-authenticated requests", () => {
|
||||
const scopes = resolveTrustedHttpOperatorScopes(
|
||||
createReq({
|
||||
authorization: "Bearer secret",
|
||||
"x-openclaw-scopes": "operator.admin, operator.write",
|
||||
}),
|
||||
tokenAuth,
|
||||
);
|
||||
|
||||
expect(scopes).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps declared scopes for non-bearer HTTP requests", () => {
|
||||
const scopes = resolveTrustedHttpOperatorScopes(
|
||||
createReq({
|
||||
"x-openclaw-scopes": "operator.admin, operator.write",
|
||||
}),
|
||||
noneAuth,
|
||||
);
|
||||
|
||||
expect(scopes).toEqual(["operator.admin", "operator.write"]);
|
||||
});
|
||||
|
||||
it("keeps declared scopes when auth mode is not shared-secret even if auth headers are forwarded", () => {
|
||||
const scopes = resolveTrustedHttpOperatorScopes(
|
||||
createReq({
|
||||
authorization: "Bearer upstream-idp-token",
|
||||
"x-openclaw-scopes": "operator.admin, operator.write",
|
||||
}),
|
||||
noneAuth,
|
||||
);
|
||||
|
||||
expect(scopes).toEqual(["operator.admin", "operator.write"]);
|
||||
});
|
||||
|
||||
it("drops declared scopes when request auth resolved to a shared-secret method", () => {
|
||||
const scopes = resolveTrustedHttpOperatorScopes(
|
||||
createReq({
|
||||
authorization: "Bearer upstream-idp-token",
|
||||
"x-openclaw-scopes": "operator.admin, operator.write",
|
||||
}),
|
||||
{ trustDeclaredOperatorScopes: false },
|
||||
);
|
||||
|
||||
expect(scopes).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveHttpSenderIsOwner", () => {
|
||||
it("requires operator.admin on a trusted HTTP scope-bearing request", () => {
|
||||
expect(
|
||||
resolveHttpSenderIsOwner(createReq({ "x-openclaw-scopes": "operator.admin" }), noneAuth),
|
||||
).toBe(true);
|
||||
expect(
|
||||
resolveHttpSenderIsOwner(createReq({ "x-openclaw-scopes": "operator.write" }), noneAuth),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for bearer requests even with operator.admin in headers", () => {
|
||||
expect(
|
||||
resolveHttpSenderIsOwner(
|
||||
createReq({
|
||||
authorization: "Bearer secret",
|
||||
"x-openclaw-scopes": "operator.admin",
|
||||
}),
|
||||
tokenAuth,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import {
|
||||
buildAllowedModelSet,
|
||||
@@ -10,6 +10,14 @@ import {
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { buildAgentMainSessionKey, normalizeAgentId } from "../routing/session-key.js";
|
||||
import { normalizeMessageChannel } from "../utils/message-channel.js";
|
||||
import type { AuthRateLimiter } from "./auth-rate-limit.js";
|
||||
import {
|
||||
authorizeHttpGatewayConnect,
|
||||
type GatewayAuthResult,
|
||||
type ResolvedGatewayAuth,
|
||||
} from "./auth.js";
|
||||
import { sendGatewayAuthFailure } from "./http-common.js";
|
||||
import { ADMIN_SCOPE } from "./method-scopes.js";
|
||||
import { loadGatewayModelCatalog } from "./server-model-catalog.js";
|
||||
|
||||
export const OPENCLAW_MODEL_ID = "openclaw";
|
||||
@@ -35,6 +43,98 @@ export function getBearerToken(req: IncomingMessage): string | undefined {
|
||||
return token || undefined;
|
||||
}
|
||||
|
||||
type SharedSecretGatewayAuth = Pick<ResolvedGatewayAuth, "mode">;
|
||||
export type AuthorizedGatewayHttpRequest = {
|
||||
authMethod?: GatewayAuthResult["method"];
|
||||
trustDeclaredOperatorScopes: boolean;
|
||||
};
|
||||
|
||||
function usesSharedSecretHttpAuth(auth: SharedSecretGatewayAuth | undefined): boolean {
|
||||
return auth?.mode === "token" || auth?.mode === "password";
|
||||
}
|
||||
|
||||
function usesSharedSecretGatewayMethod(method: GatewayAuthResult["method"] | undefined): boolean {
|
||||
return method === "token" || method === "password";
|
||||
}
|
||||
|
||||
function shouldTrustDeclaredHttpOperatorScopes(
|
||||
req: IncomingMessage,
|
||||
authOrRequest:
|
||||
| SharedSecretGatewayAuth
|
||||
| Pick<AuthorizedGatewayHttpRequest, "trustDeclaredOperatorScopes">
|
||||
| undefined,
|
||||
): boolean {
|
||||
if (authOrRequest && "trustDeclaredOperatorScopes" in authOrRequest) {
|
||||
return authOrRequest.trustDeclaredOperatorScopes;
|
||||
}
|
||||
return !isGatewayBearerHttpRequest(req, authOrRequest);
|
||||
}
|
||||
|
||||
export async function authorizeGatewayHttpRequestOrReply(params: {
|
||||
req: IncomingMessage;
|
||||
res: ServerResponse;
|
||||
auth: ResolvedGatewayAuth;
|
||||
trustedProxies?: string[];
|
||||
allowRealIpFallback?: boolean;
|
||||
rateLimiter?: AuthRateLimiter;
|
||||
}): Promise<AuthorizedGatewayHttpRequest | null> {
|
||||
const token = getBearerToken(params.req);
|
||||
const authResult = await authorizeHttpGatewayConnect({
|
||||
auth: params.auth,
|
||||
connectAuth: token ? { token, password: token } : null,
|
||||
req: params.req,
|
||||
trustedProxies: params.trustedProxies,
|
||||
allowRealIpFallback: params.allowRealIpFallback,
|
||||
rateLimiter: params.rateLimiter,
|
||||
});
|
||||
if (!authResult.ok) {
|
||||
sendGatewayAuthFailure(params.res, authResult);
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
authMethod: authResult.method,
|
||||
trustDeclaredOperatorScopes: !usesSharedSecretGatewayMethod(authResult.method),
|
||||
};
|
||||
}
|
||||
|
||||
export function isGatewayBearerHttpRequest(
|
||||
req: IncomingMessage,
|
||||
auth?: SharedSecretGatewayAuth,
|
||||
): boolean {
|
||||
return usesSharedSecretHttpAuth(auth) && Boolean(getBearerToken(req));
|
||||
}
|
||||
|
||||
export function resolveTrustedHttpOperatorScopes(
|
||||
req: IncomingMessage,
|
||||
authOrRequest?:
|
||||
| SharedSecretGatewayAuth
|
||||
| Pick<AuthorizedGatewayHttpRequest, "trustDeclaredOperatorScopes">,
|
||||
): string[] {
|
||||
if (!shouldTrustDeclaredHttpOperatorScopes(req, authOrRequest)) {
|
||||
// Gateway bearer auth only proves possession of the shared secret. Do not
|
||||
// let HTTP clients self-assert operator scopes through request headers.
|
||||
return [];
|
||||
}
|
||||
|
||||
const raw = getHeader(req, "x-openclaw-scopes")?.trim();
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
return raw
|
||||
.split(",")
|
||||
.map((scope) => scope.trim())
|
||||
.filter((scope) => scope.length > 0);
|
||||
}
|
||||
|
||||
export function resolveHttpSenderIsOwner(
|
||||
req: IncomingMessage,
|
||||
authOrRequest?:
|
||||
| SharedSecretGatewayAuth
|
||||
| Pick<AuthorizedGatewayHttpRequest, "trustDeclaredOperatorScopes">,
|
||||
): boolean {
|
||||
return resolveTrustedHttpOperatorScopes(req, authOrRequest).includes(ADMIN_SCOPE);
|
||||
}
|
||||
|
||||
export function resolveAgentIdFromHeader(req: IncomingMessage): string | undefined {
|
||||
const raw =
|
||||
getHeader(req, "x-openclaw-agent-id")?.trim() ||
|
||||
|
||||
@@ -22,7 +22,7 @@ afterAll(async () => {
|
||||
async function startServer(port: number, opts?: { openAiChatCompletionsEnabled?: boolean }) {
|
||||
return await startGatewayServer(port, {
|
||||
host: "127.0.0.1",
|
||||
auth: { mode: "token", token: "secret" },
|
||||
auth: { mode: "none" },
|
||||
controlUiEnabled: false,
|
||||
openAiChatCompletionsEnabled: opts?.openAiChatCompletionsEnabled ?? false,
|
||||
});
|
||||
@@ -31,7 +31,6 @@ async function startServer(port: number, opts?: { openAiChatCompletionsEnabled?:
|
||||
async function getModels(pathname: string, headers?: Record<string, string>) {
|
||||
return await fetch(`http://127.0.0.1:${enabledPort}${pathname}`, {
|
||||
headers: {
|
||||
authorization: "Bearer secret",
|
||||
...READ_SCOPE_HEADER,
|
||||
...headers,
|
||||
},
|
||||
@@ -114,7 +113,7 @@ describe("OpenAI-compatible models HTTP API (e2e)", () => {
|
||||
const server = await startServer(port, { openAiChatCompletionsEnabled: false });
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/v1/models`, {
|
||||
headers: { authorization: "Bearer secret" },
|
||||
headers: {},
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
} finally {
|
||||
|
||||
@@ -3,15 +3,14 @@ import { listAgentIds, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import type { AuthRateLimiter } from "./auth-rate-limit.js";
|
||||
import type { ResolvedGatewayAuth } from "./auth.js";
|
||||
import {
|
||||
authorizeGatewayBearerRequestOrReply,
|
||||
resolveGatewayRequestedOperatorScopes,
|
||||
} from "./http-auth-helpers.js";
|
||||
import { sendInvalidRequest, sendJson, sendMethodNotAllowed } from "./http-common.js";
|
||||
import {
|
||||
OPENCLAW_DEFAULT_MODEL_ID,
|
||||
OPENCLAW_MODEL_ID,
|
||||
authorizeGatewayHttpRequestOrReply,
|
||||
type AuthorizedGatewayHttpRequest,
|
||||
resolveAgentIdFromModel,
|
||||
resolveTrustedHttpOperatorScopes,
|
||||
} from "./http-utils.js";
|
||||
import { authorizeOperatorScopesForMethod } from "./method-scopes.js";
|
||||
|
||||
@@ -44,8 +43,8 @@ async function authorizeRequest(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
opts: OpenAiModelsHttpOptions,
|
||||
): Promise<boolean> {
|
||||
return await authorizeGatewayBearerRequestOrReply({
|
||||
): Promise<AuthorizedGatewayHttpRequest | null> {
|
||||
return await authorizeGatewayHttpRequestOrReply({
|
||||
req,
|
||||
res,
|
||||
auth: opts.auth,
|
||||
@@ -85,11 +84,12 @@ export async function handleOpenAiModelsHttpRequest(
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!(await authorizeRequest(req, res, opts))) {
|
||||
const requestAuth = await authorizeRequest(req, res, opts);
|
||||
if (!requestAuth) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const requestedScopes = resolveGatewayRequestedOperatorScopes(req);
|
||||
const requestedScopes = resolveTrustedHttpOperatorScopes(req, requestAuth);
|
||||
const scopeAuth = authorizeOperatorScopesForMethod("models.list", requestedScopes);
|
||||
if (!scopeAuth.allowed) {
|
||||
sendJson(res, 403, {
|
||||
|
||||
@@ -5,7 +5,7 @@ installGatewayTestHooks({ scope: "test" });
|
||||
|
||||
const OPENAI_SERVER_OPTIONS = {
|
||||
host: "127.0.0.1",
|
||||
auth: { mode: "token" as const, token: "secret" },
|
||||
auth: { mode: "none" as const },
|
||||
controlUiEnabled: false,
|
||||
openAiChatCompletionsEnabled: true,
|
||||
};
|
||||
@@ -19,7 +19,6 @@ async function runOpenAiMessageChannelRequest(params?: { messageChannelHeader?:
|
||||
async ({ port }) => {
|
||||
const headers: Record<string, string> = {
|
||||
"content-type": "application/json",
|
||||
authorization: "Bearer secret",
|
||||
"x-openclaw-scopes": "operator.write",
|
||||
};
|
||||
if (params?.messageChannelHeader) {
|
||||
|
||||
@@ -32,7 +32,7 @@ afterAll(async () => {
|
||||
async function startServerWithDefaultConfig(port: number) {
|
||||
return await startGatewayServer(port, {
|
||||
host: "127.0.0.1",
|
||||
auth: { mode: "token", token: "secret" },
|
||||
auth: { mode: "none" },
|
||||
controlUiEnabled: false,
|
||||
openAiChatCompletionsEnabled: false,
|
||||
});
|
||||
@@ -41,7 +41,7 @@ async function startServerWithDefaultConfig(port: number) {
|
||||
async function startServer(port: number, opts?: { openAiChatCompletionsEnabled?: boolean }) {
|
||||
return await startGatewayServer(port, {
|
||||
host: "127.0.0.1",
|
||||
auth: { mode: "token", token: "secret" },
|
||||
auth: { mode: "none" },
|
||||
controlUiEnabled: false,
|
||||
openAiChatCompletionsEnabled: opts?.openAiChatCompletionsEnabled ?? true,
|
||||
});
|
||||
@@ -61,7 +61,6 @@ async function postChatCompletions(port: number, body: unknown, headers?: Record
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
authorization: "Bearer secret",
|
||||
"x-openclaw-scopes": "operator.write",
|
||||
...headers,
|
||||
},
|
||||
@@ -96,7 +95,7 @@ function parseSseDataLines(text: string): string[] {
|
||||
}
|
||||
|
||||
describe("OpenAI-compatible HTTP API (e2e)", () => {
|
||||
it("rejects when disabled (default + config)", { timeout: 15_000 }, async () => {
|
||||
it("rejects when disabled (default + config)", { timeout: 90_000 }, async () => {
|
||||
await expectChatCompletionsDisabled(startServerWithDefaultConfig);
|
||||
await expectChatCompletionsDisabled((port) =>
|
||||
startServer(port, {
|
||||
@@ -187,10 +186,12 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
|
||||
{
|
||||
const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ messages: [{ role: "user", content: "hi" }] }),
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.status).toBe(403);
|
||||
await res.text();
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,10 @@ import type { AuthRateLimiter } from "./auth-rate-limit.js";
|
||||
import type { ResolvedGatewayAuth } from "./auth.js";
|
||||
import { sendJson, setSseHeaders, writeDone } from "./http-common.js";
|
||||
import { handleGatewayPostJsonEndpoint } from "./http-endpoint-helpers.js";
|
||||
import { resolveGatewayRequestContext, resolveOpenAiCompatModelOverride } from "./http-utils.js";
|
||||
import {
|
||||
resolveGatewayRequestContext,
|
||||
resolveOpenAiCompatModelOverride,
|
||||
} from "./http-utils.js";
|
||||
import { normalizeInputHostnameAllowlist } from "./input-allowlist.js";
|
||||
|
||||
type OpenAiHttpOptions = {
|
||||
|
||||
@@ -45,7 +45,7 @@ async function startServer(port: number, opts?: { openResponsesEnabled?: boolean
|
||||
const { startGatewayServer } = await import("./server.js");
|
||||
const serverOpts = {
|
||||
host: "127.0.0.1",
|
||||
auth: { mode: "token", token: "secret" },
|
||||
auth: { mode: "none" as const },
|
||||
controlUiEnabled: false,
|
||||
} as const;
|
||||
return await startGatewayServer(
|
||||
@@ -70,7 +70,6 @@ async function postResponses(port: number, body: unknown, headers?: Record<strin
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
authorization: "Bearer secret",
|
||||
"x-openclaw-scopes": "operator.write",
|
||||
...headers,
|
||||
},
|
||||
@@ -174,7 +173,7 @@ async function expectInvalidRequest(
|
||||
}
|
||||
|
||||
describe("OpenResponses HTTP API (e2e)", () => {
|
||||
it("rejects when disabled (default + config)", { timeout: 15_000 }, async () => {
|
||||
it("rejects when disabled (default + config)", { timeout: 90_000 }, async () => {
|
||||
const port = await getFreePort();
|
||||
const server = await startServer(port);
|
||||
try {
|
||||
@@ -224,7 +223,7 @@ describe("OpenResponses HTTP API (e2e)", () => {
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ model: "openclaw", input: "hi" }),
|
||||
});
|
||||
expect(resMissingAuth.status).toBe(401);
|
||||
expect(resMissingAuth.status).toBe(403);
|
||||
await ensureResponseConsumed(resMissingAuth);
|
||||
|
||||
const resMissingModel = await postResponses(port, { input: "hi" });
|
||||
|
||||
@@ -69,14 +69,13 @@ describe("gateway OpenAI-compatible HTTP write-scope bypass PoC", () => {
|
||||
}),
|
||||
});
|
||||
|
||||
expect(missingHeaderRes.status).toBe(200);
|
||||
expect(missingHeaderRes.status).toBe(403);
|
||||
const missingHeaderBody = (await missingHeaderRes.json()) as {
|
||||
object?: string;
|
||||
choices?: Array<{ message?: { content?: string } }>;
|
||||
error?: { type?: string; message?: string };
|
||||
};
|
||||
expect(missingHeaderBody.object).toBe("chat.completion");
|
||||
expect(missingHeaderBody.choices?.[0]?.message?.content).toBe("hello");
|
||||
expect(agentCommand).toHaveBeenCalledTimes(1);
|
||||
expect(missingHeaderBody.error?.type).toBe("forbidden");
|
||||
expect(missingHeaderBody.error?.message).toBe("missing scope: operator.write");
|
||||
expect(agentCommand).toHaveBeenCalledTimes(0);
|
||||
} finally {
|
||||
started.ws.close();
|
||||
await started.server.close();
|
||||
@@ -84,7 +83,7 @@ describe("gateway OpenAI-compatible HTTP write-scope bypass PoC", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("operator.write can still use /v1/chat/completions", async () => {
|
||||
test("bearer auth cannot self-assert operator.write for /v1/chat/completions", async () => {
|
||||
const started = await startServerWithClient("secret", {
|
||||
openAiChatCompletionsEnabled: true,
|
||||
});
|
||||
@@ -106,14 +105,13 @@ describe("gateway OpenAI-compatible HTTP write-scope bypass PoC", () => {
|
||||
}),
|
||||
});
|
||||
|
||||
expect(httpRes.status).toBe(200);
|
||||
expect(httpRes.status).toBe(403);
|
||||
const body = (await httpRes.json()) as {
|
||||
object?: string;
|
||||
choices?: Array<{ message?: { content?: string } }>;
|
||||
error?: { type?: string; message?: string };
|
||||
};
|
||||
expect(body.object).toBe("chat.completion");
|
||||
expect(body.choices?.[0]?.message?.content).toBe("hello");
|
||||
expect(agentCommand).toHaveBeenCalledTimes(1);
|
||||
expect(body.error?.type).toBe("forbidden");
|
||||
expect(body.error?.message).toBe("missing scope: operator.write");
|
||||
expect(agentCommand).toHaveBeenCalledTimes(0);
|
||||
} finally {
|
||||
started.ws.close();
|
||||
await started.server.close();
|
||||
@@ -169,7 +167,7 @@ describe("gateway OpenAI-compatible HTTP write-scope bypass PoC", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("operator.write can still use /v1/responses", async () => {
|
||||
test("bearer auth cannot self-assert operator.write for /v1/responses", async () => {
|
||||
const started = await startServerWithClient("secret", {
|
||||
openResponsesEnabled: true,
|
||||
});
|
||||
@@ -192,23 +190,42 @@ describe("gateway OpenAI-compatible HTTP write-scope bypass PoC", () => {
|
||||
}),
|
||||
});
|
||||
|
||||
expect(httpRes.status).toBe(200);
|
||||
expect(httpRes.status).toBe(403);
|
||||
const body = (await httpRes.json()) as {
|
||||
object?: string;
|
||||
status?: string;
|
||||
output?: Array<{
|
||||
type?: string;
|
||||
role?: string;
|
||||
content?: Array<{ type?: string; text?: string }>;
|
||||
}>;
|
||||
error?: { type?: string; message?: string };
|
||||
};
|
||||
expect(body.object).toBe("response");
|
||||
expect(body.status).toBe("completed");
|
||||
expect(body.output?.[0]?.type).toBe("message");
|
||||
expect(body.output?.[0]?.role).toBe("assistant");
|
||||
expect(body.output?.[0]?.content?.[0]?.type).toBe("output_text");
|
||||
expect(body.output?.[0]?.content?.[0]?.text).toBe("hello");
|
||||
expect(agentCommand).toHaveBeenCalledTimes(1);
|
||||
expect(body.error?.type).toBe("forbidden");
|
||||
expect(body.error?.message).toBe("missing scope: operator.write");
|
||||
expect(agentCommand).toHaveBeenCalledTimes(0);
|
||||
} finally {
|
||||
started.ws.close();
|
||||
await started.server.close();
|
||||
started.envSnapshot.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("bearer auth cannot use /tools/invoke", async () => {
|
||||
const started = await startServerWithClient("secret");
|
||||
|
||||
try {
|
||||
const httpRes = await fetch(`http://127.0.0.1:${started.port}/tools/invoke`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: "Bearer secret",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
tool: "agents_list",
|
||||
args: {},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(httpRes.status).toBe(403);
|
||||
const body = (await httpRes.json()) as {
|
||||
error?: { type?: string; message?: string };
|
||||
};
|
||||
expect(body.error?.type).toBe("forbidden");
|
||||
expect(body.error?.message).toBe("gateway bearer auth cannot invoke tools over HTTP");
|
||||
} finally {
|
||||
started.ws.close();
|
||||
await started.server.close();
|
||||
|
||||
@@ -62,7 +62,7 @@ async function fetchSessionHistory(
|
||||
headers?: HeadersInit;
|
||||
},
|
||||
) {
|
||||
const headers = new Headers(AUTH_HEADER);
|
||||
const headers = new Headers();
|
||||
for (const [key, value] of new Headers(READ_SCOPE_HEADER).entries()) {
|
||||
headers.set(key, value);
|
||||
}
|
||||
@@ -80,7 +80,11 @@ async function fetchSessionHistory(
|
||||
async function withGatewayHarness<T>(
|
||||
run: (harness: Awaited<ReturnType<typeof createGatewaySuiteHarness>>) => Promise<T>,
|
||||
) {
|
||||
const harness = await createGatewaySuiteHarness();
|
||||
const harness = await createGatewaySuiteHarness({
|
||||
serverOptions: {
|
||||
auth: { mode: "none" },
|
||||
},
|
||||
});
|
||||
try {
|
||||
return await run(harness);
|
||||
} finally {
|
||||
@@ -422,17 +426,20 @@ describe("session history HTTP endpoints", () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Requests without x-openclaw-scopes header now receive default
|
||||
// CLI_DEFAULT_OPERATOR_SCOPES (which include operator.read), so they
|
||||
// are authorised. The explicit-header test above still proves that a
|
||||
// caller who *declares* only operator.approvals is correctly rejected.
|
||||
const httpHistoryWithoutScopes = await fetch(
|
||||
`http://127.0.0.1:${harness.port}/sessions/${encodeURIComponent("agent:main:main")}/history?limit=1`,
|
||||
{
|
||||
headers: AUTH_HEADER,
|
||||
},
|
||||
);
|
||||
expect(httpHistoryWithoutScopes.status).toBe(200);
|
||||
expect(httpHistoryWithoutScopes.status).toBe(403);
|
||||
await expect(httpHistoryWithoutScopes.json()).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
type: "forbidden",
|
||||
message: "missing scope: operator.read",
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
ws.close();
|
||||
await harness.close();
|
||||
|
||||
@@ -6,17 +6,17 @@ import { loadSessionStore } from "../config/sessions.js";
|
||||
import { onSessionTranscriptUpdate } from "../sessions/transcript-events.js";
|
||||
import type { AuthRateLimiter } from "./auth-rate-limit.js";
|
||||
import type { ResolvedGatewayAuth } from "./auth.js";
|
||||
import {
|
||||
authorizeGatewayBearerRequestOrReply,
|
||||
resolveGatewayRequestedOperatorScopes,
|
||||
} from "./http-auth-helpers.js";
|
||||
import {
|
||||
sendInvalidRequest,
|
||||
sendJson,
|
||||
sendMethodNotAllowed,
|
||||
setSseHeaders,
|
||||
} from "./http-common.js";
|
||||
import { getHeader } from "./http-utils.js";
|
||||
import {
|
||||
authorizeGatewayHttpRequestOrReply,
|
||||
getHeader,
|
||||
resolveTrustedHttpOperatorScopes,
|
||||
} from "./http-utils.js";
|
||||
import { authorizeOperatorScopesForMethod } from "./method-scopes.js";
|
||||
import {
|
||||
attachOpenClawTranscriptMeta,
|
||||
@@ -158,7 +158,7 @@ export async function handleSessionHistoryHttpRequest(
|
||||
}
|
||||
|
||||
const cfg = loadConfig();
|
||||
const ok = await authorizeGatewayBearerRequestOrReply({
|
||||
const requestAuth = await authorizeGatewayHttpRequestOrReply({
|
||||
req,
|
||||
res,
|
||||
auth: opts.auth,
|
||||
@@ -166,13 +166,13 @@ export async function handleSessionHistoryHttpRequest(
|
||||
allowRealIpFallback: opts.allowRealIpFallback ?? cfg.gateway?.allowRealIpFallback,
|
||||
rateLimiter: opts.rateLimiter,
|
||||
});
|
||||
if (!ok) {
|
||||
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 = resolveGatewayRequestedOperatorScopes(req);
|
||||
const requestedScopes = resolveTrustedHttpOperatorScopes(req, requestAuth);
|
||||
const scopeAuth = authorizeOperatorScopesForMethod("chat.history", requestedScopes);
|
||||
if (!scopeAuth.allowed) {
|
||||
sendJson(res, 403, {
|
||||
|
||||
@@ -7,8 +7,6 @@ type RunBeforeToolCallHook = typeof runBeforeToolCallHookType;
|
||||
type RunBeforeToolCallHookArgs = Parameters<RunBeforeToolCallHook>[0];
|
||||
type RunBeforeToolCallHookResult = Awaited<ReturnType<RunBeforeToolCallHook>>;
|
||||
|
||||
const TEST_GATEWAY_TOKEN = "test-gateway-token-1234567890";
|
||||
|
||||
const hookMocks = vi.hoisted(() => ({
|
||||
resolveToolLoopDetectionConfig: vi.fn(() => ({ warnAt: 3 })),
|
||||
runBeforeToolCallHook: vi.fn(
|
||||
@@ -50,7 +48,7 @@ vi.mock("../config/sessions.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./auth.js", () => ({
|
||||
authorizeHttpGatewayConnect: async () => ({ ok: true }),
|
||||
authorizeHttpGatewayConnect: vi.fn(async () => ({ ok: true })),
|
||||
}));
|
||||
|
||||
vi.mock("../logger.js", () => ({
|
||||
@@ -197,6 +195,7 @@ vi.mock("../agents/pi-tools.before-tool-call.js", () => ({
|
||||
runBeforeToolCallHook: hookMocks.runBeforeToolCallHook,
|
||||
}));
|
||||
|
||||
const { authorizeHttpGatewayConnect } = await import("./auth.js");
|
||||
const { handleToolsInvokeHttpRequest } = await import("./tools-invoke-http.js");
|
||||
|
||||
let pluginHttpHandlers: Array<(req: IncomingMessage, res: ServerResponse) => Promise<boolean>> = [];
|
||||
@@ -208,7 +207,7 @@ beforeAll(async () => {
|
||||
sharedServer = createServer((req, res) => {
|
||||
void (async () => {
|
||||
const handled = await handleToolsInvokeHttpRequest(req, res, {
|
||||
auth: { mode: "token", token: TEST_GATEWAY_TOKEN, allowTailscale: false },
|
||||
auth: { mode: "none", allowTailscale: false },
|
||||
});
|
||||
if (handled) {
|
||||
return;
|
||||
@@ -260,17 +259,11 @@ beforeEach(() => {
|
||||
params: args.params,
|
||||
}),
|
||||
);
|
||||
vi.mocked(authorizeHttpGatewayConnect).mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
const resolveGatewayToken = (): string => TEST_GATEWAY_TOKEN;
|
||||
const gatewayAuthHeaders = () => ({
|
||||
authorization: `Bearer ${resolveGatewayToken()}`,
|
||||
"x-openclaw-scopes": "operator.write",
|
||||
});
|
||||
const gatewayAdminHeaders = () => ({
|
||||
authorization: `Bearer ${resolveGatewayToken()}`,
|
||||
"x-openclaw-scopes": "operator.admin",
|
||||
});
|
||||
const gatewayAuthHeaders = () => ({ "x-openclaw-scopes": "operator.write" });
|
||||
const gatewayAdminHeaders = () => ({ "x-openclaw-scopes": "operator.admin" });
|
||||
|
||||
const allowAgentsListForMain = () => {
|
||||
cfg = {
|
||||
@@ -440,6 +433,36 @@ describe("POST /tools/invoke", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks trusted-proxy local-direct token fallback from invoking tools over HTTP", async () => {
|
||||
vi.mocked(authorizeHttpGatewayConnect).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
method: "token",
|
||||
});
|
||||
|
||||
const res = await postToolsInvoke({
|
||||
port: sharedPort,
|
||||
headers: {
|
||||
authorization: "Bearer secret",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: {
|
||||
tool: "agents_list",
|
||||
action: "json",
|
||||
args: {},
|
||||
sessionKey: "main",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
await expect(res.json()).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
type: "forbidden",
|
||||
message: "gateway bearer auth cannot invoke tools over HTTP",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("uses before_tool_call adjusted params for HTTP tool execution", async () => {
|
||||
setMainAllowedTools({ allow: ["tools_invoke_test"] });
|
||||
hookMocks.runBeforeToolCallHook.mockImplementationOnce(async () => ({
|
||||
@@ -718,9 +741,7 @@ describe("POST /tools/invoke", () => {
|
||||
|
||||
const res = await invokeTool({
|
||||
port: sharedPort,
|
||||
headers: {
|
||||
authorization: `Bearer ${resolveGatewayToken()}`,
|
||||
},
|
||||
headers: {},
|
||||
tool: "agents_list",
|
||||
sessionKey: "main",
|
||||
});
|
||||
@@ -735,6 +756,36 @@ describe("POST /tools/invoke", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks trusted-proxy local-direct token fallback from invoking tools over HTTP", async () => {
|
||||
vi.mocked(authorizeHttpGatewayConnect).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
method: "token",
|
||||
});
|
||||
|
||||
const res = await postToolsInvoke({
|
||||
port: sharedPort,
|
||||
headers: {
|
||||
authorization: "Bearer secret",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: {
|
||||
tool: "agents_list",
|
||||
action: "json",
|
||||
args: {},
|
||||
sessionKey: "main",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
await expect(res.json()).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
type: "forbidden",
|
||||
message: "gateway bearer auth cannot invoke tools over HTTP",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("applies owner-only tool policy on the HTTP path", async () => {
|
||||
setMainAllowedTools({ allow: ["owner_only_test"] });
|
||||
|
||||
|
||||
@@ -29,17 +29,17 @@ import { DEFAULT_GATEWAY_HTTP_TOOL_DENY } from "../security/dangerous-tools.js";
|
||||
import { normalizeMessageChannel } from "../utils/message-channel.js";
|
||||
import type { AuthRateLimiter } from "./auth-rate-limit.js";
|
||||
import type { ResolvedGatewayAuth } from "./auth.js";
|
||||
import {
|
||||
authorizeGatewayBearerRequestOrReply,
|
||||
resolveGatewayRequestedOperatorScopes,
|
||||
} from "./http-auth-helpers.js";
|
||||
import {
|
||||
readJsonBodyOrError,
|
||||
sendInvalidRequest,
|
||||
sendJson,
|
||||
sendMethodNotAllowed,
|
||||
} from "./http-common.js";
|
||||
import { getHeader } from "./http-utils.js";
|
||||
import {
|
||||
authorizeGatewayHttpRequestOrReply,
|
||||
getHeader,
|
||||
resolveTrustedHttpOperatorScopes,
|
||||
} from "./http-utils.js";
|
||||
import { authorizeOperatorScopesForMethod } from "./method-scopes.js";
|
||||
|
||||
const DEFAULT_BODY_BYTES = 2 * 1024 * 1024;
|
||||
@@ -161,7 +161,7 @@ export async function handleToolsInvokeHttpRequest(
|
||||
}
|
||||
|
||||
const cfg = loadConfig();
|
||||
const ok = await authorizeGatewayBearerRequestOrReply({
|
||||
const requestAuth = await authorizeGatewayHttpRequestOrReply({
|
||||
req,
|
||||
res,
|
||||
auth: opts.auth,
|
||||
@@ -169,11 +169,22 @@ export async function handleToolsInvokeHttpRequest(
|
||||
allowRealIpFallback: opts.allowRealIpFallback ?? cfg.gateway?.allowRealIpFallback,
|
||||
rateLimiter: opts.rateLimiter,
|
||||
});
|
||||
if (!ok) {
|
||||
if (!requestAuth) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const requestedScopes = resolveGatewayRequestedOperatorScopes(req);
|
||||
if (!requestAuth.trustDeclaredOperatorScopes) {
|
||||
sendJson(res, 403, {
|
||||
ok: false,
|
||||
error: {
|
||||
type: "forbidden",
|
||||
message: "gateway bearer auth cannot invoke tools over HTTP",
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
const requestedScopes = resolveTrustedHttpOperatorScopes(req, requestAuth);
|
||||
const scopeAuth = authorizeOperatorScopesForMethod("agent", requestedScopes);
|
||||
if (!scopeAuth.allowed) {
|
||||
sendJson(res, 403, {
|
||||
|
||||
Reference in New Issue
Block a user