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:
Jacob Tomlinson
2026-03-30 12:04:33 -07:00
committed by GitHub
parent 2a75416634
commit f0af186726
16 changed files with 476 additions and 113 deletions

View File

@@ -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",

View File

@@ -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 };
}

View 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",
});
});
});

View File

@@ -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);
});
});

View File

@@ -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() ||

View File

@@ -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 {

View File

@@ -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, {

View File

@@ -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) {

View File

@@ -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();
}

View File

@@ -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 = {

View File

@@ -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" });

View File

@@ -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();

View File

@@ -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();

View File

@@ -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, {

View File

@@ -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"] });

View File

@@ -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, {