mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-21 14:11:26 +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 type { ResolvedGatewayAuth } from "./auth.js";
|
||||||
import { handleGatewayPostJsonEndpoint } from "./http-endpoint-helpers.js";
|
import { handleGatewayPostJsonEndpoint } from "./http-endpoint-helpers.js";
|
||||||
|
|
||||||
vi.mock("./http-auth-helpers.js", () => {
|
vi.mock("./http-utils.js", () => {
|
||||||
return {
|
return {
|
||||||
authorizeGatewayBearerRequestOrReply: vi.fn(),
|
authorizeGatewayHttpRequestOrReply: vi.fn(),
|
||||||
resolveGatewayRequestedOperatorScopes: 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 { readJsonBodyOrError, sendJson, sendMethodNotAllowed } = await import("./http-common.js");
|
||||||
|
const { authorizeGatewayHttpRequestOrReply, resolveTrustedHttpOperatorScopes } =
|
||||||
|
await import("./http-utils.js");
|
||||||
const { authorizeOperatorScopesForMethod } = await import("./method-scopes.js");
|
const { authorizeOperatorScopesForMethod } = await import("./method-scopes.js");
|
||||||
|
|
||||||
describe("handleGatewayPostJsonEndpoint", () => {
|
describe("handleGatewayPostJsonEndpoint", () => {
|
||||||
@@ -60,7 +60,7 @@ describe("handleGatewayPostJsonEndpoint", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns undefined when auth fails", async () => {
|
it("returns undefined when auth fails", async () => {
|
||||||
vi.mocked(authorizeGatewayBearerRequestOrReply).mockResolvedValue(false);
|
vi.mocked(authorizeGatewayHttpRequestOrReply).mockResolvedValue(null);
|
||||||
const result = await handleGatewayPostJsonEndpoint(
|
const result = await handleGatewayPostJsonEndpoint(
|
||||||
{
|
{
|
||||||
url: "/v1/ok",
|
url: "/v1/ok",
|
||||||
@@ -74,7 +74,9 @@ describe("handleGatewayPostJsonEndpoint", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns body when auth succeeds and JSON parsing succeeds", async () => {
|
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" });
|
vi.mocked(readJsonBodyOrError).mockResolvedValue({ hello: "world" });
|
||||||
const result = await handleGatewayPostJsonEndpoint(
|
const result = await handleGatewayPostJsonEndpoint(
|
||||||
{
|
{
|
||||||
@@ -85,12 +87,17 @@ describe("handleGatewayPostJsonEndpoint", () => {
|
|||||||
{} as unknown as ServerResponse,
|
{} as unknown as ServerResponse,
|
||||||
{ pathname: "/v1/ok", auth: {} as unknown as ResolvedGatewayAuth, maxBodyBytes: 123 },
|
{ 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 () => {
|
it("returns undefined and replies when required operator scope is missing", async () => {
|
||||||
vi.mocked(authorizeGatewayBearerRequestOrReply).mockResolvedValue(true);
|
vi.mocked(authorizeGatewayHttpRequestOrReply).mockResolvedValue({
|
||||||
vi.mocked(resolveGatewayRequestedOperatorScopes).mockReturnValue(["operator.approvals"]);
|
trustDeclaredOperatorScopes: false,
|
||||||
|
});
|
||||||
|
vi.mocked(resolveTrustedHttpOperatorScopes).mockReturnValue(["operator.approvals"]);
|
||||||
vi.mocked(authorizeOperatorScopesForMethod).mockReturnValue({
|
vi.mocked(authorizeOperatorScopesForMethod).mockReturnValue({
|
||||||
allowed: false,
|
allowed: false,
|
||||||
missingScope: "operator.write",
|
missingScope: "operator.write",
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
import type { AuthRateLimiter } from "./auth-rate-limit.js";
|
import type { AuthRateLimiter } from "./auth-rate-limit.js";
|
||||||
import type { ResolvedGatewayAuth } from "./auth.js";
|
import type { ResolvedGatewayAuth } from "./auth.js";
|
||||||
import {
|
|
||||||
authorizeGatewayBearerRequestOrReply,
|
|
||||||
resolveGatewayRequestedOperatorScopes,
|
|
||||||
} from "./http-auth-helpers.js";
|
|
||||||
import { readJsonBodyOrError, sendJson, sendMethodNotAllowed } from "./http-common.js";
|
import { readJsonBodyOrError, sendJson, sendMethodNotAllowed } from "./http-common.js";
|
||||||
|
import {
|
||||||
|
authorizeGatewayHttpRequestOrReply,
|
||||||
|
type AuthorizedGatewayHttpRequest,
|
||||||
|
resolveTrustedHttpOperatorScopes,
|
||||||
|
} from "./http-utils.js";
|
||||||
import { authorizeOperatorScopesForMethod } from "./method-scopes.js";
|
import { authorizeOperatorScopesForMethod } from "./method-scopes.js";
|
||||||
|
|
||||||
export async function handleGatewayPostJsonEndpoint(
|
export async function handleGatewayPostJsonEndpoint(
|
||||||
@@ -20,7 +21,7 @@ export async function handleGatewayPostJsonEndpoint(
|
|||||||
rateLimiter?: AuthRateLimiter;
|
rateLimiter?: AuthRateLimiter;
|
||||||
requiredOperatorMethod?: "chat.send" | (string & Record<never, never>);
|
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"}`);
|
const url = new URL(req.url ?? "/", `http://${req.headers.host || "localhost"}`);
|
||||||
if (url.pathname !== opts.pathname) {
|
if (url.pathname !== opts.pathname) {
|
||||||
return false;
|
return false;
|
||||||
@@ -31,7 +32,7 @@ export async function handleGatewayPostJsonEndpoint(
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const authorized = await authorizeGatewayBearerRequestOrReply({
|
const requestAuth = await authorizeGatewayHttpRequestOrReply({
|
||||||
req,
|
req,
|
||||||
res,
|
res,
|
||||||
auth: opts.auth,
|
auth: opts.auth,
|
||||||
@@ -39,12 +40,12 @@ export async function handleGatewayPostJsonEndpoint(
|
|||||||
allowRealIpFallback: opts.allowRealIpFallback,
|
allowRealIpFallback: opts.allowRealIpFallback,
|
||||||
rateLimiter: opts.rateLimiter,
|
rateLimiter: opts.rateLimiter,
|
||||||
});
|
});
|
||||||
if (!authorized) {
|
if (!requestAuth) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts.requiredOperatorMethod) {
|
if (opts.requiredOperatorMethod) {
|
||||||
const requestedScopes = resolveGatewayRequestedOperatorScopes(req);
|
const requestedScopes = resolveTrustedHttpOperatorScopes(req, requestAuth);
|
||||||
const scopeAuth = authorizeOperatorScopesForMethod(
|
const scopeAuth = authorizeOperatorScopesForMethod(
|
||||||
opts.requiredOperatorMethod,
|
opts.requiredOperatorMethod,
|
||||||
requestedScopes,
|
requestedScopes,
|
||||||
@@ -66,5 +67,5 @@ export async function handleGatewayPostJsonEndpoint(
|
|||||||
return undefined;
|
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 type { IncomingMessage } from "node:http";
|
||||||
import { describe, expect, it } from "vitest";
|
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 {
|
function createReq(headers: Record<string, string> = {}): IncomingMessage {
|
||||||
return { headers } as IncomingMessage;
|
return { headers } as IncomingMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tokenAuth = { mode: "token" as const };
|
||||||
|
const noneAuth = { mode: "none" as const };
|
||||||
|
|
||||||
describe("resolveGatewayRequestContext", () => {
|
describe("resolveGatewayRequestContext", () => {
|
||||||
it("uses normalized x-openclaw-message-channel when enabled", () => {
|
it("uses normalized x-openclaw-message-channel when enabled", () => {
|
||||||
const result = resolveGatewayRequestContext({
|
const result = resolveGatewayRequestContext({
|
||||||
@@ -43,3 +50,75 @@ describe("resolveGatewayRequestContext", () => {
|
|||||||
expect(result.sessionKey).toContain("openresponses-user:alice");
|
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 { 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 { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||||
import {
|
import {
|
||||||
buildAllowedModelSet,
|
buildAllowedModelSet,
|
||||||
@@ -10,6 +10,14 @@ import {
|
|||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
import { buildAgentMainSessionKey, normalizeAgentId } from "../routing/session-key.js";
|
import { buildAgentMainSessionKey, normalizeAgentId } from "../routing/session-key.js";
|
||||||
import { normalizeMessageChannel } from "../utils/message-channel.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";
|
import { loadGatewayModelCatalog } from "./server-model-catalog.js";
|
||||||
|
|
||||||
export const OPENCLAW_MODEL_ID = "openclaw";
|
export const OPENCLAW_MODEL_ID = "openclaw";
|
||||||
@@ -35,6 +43,98 @@ export function getBearerToken(req: IncomingMessage): string | undefined {
|
|||||||
return token || 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 {
|
export function resolveAgentIdFromHeader(req: IncomingMessage): string | undefined {
|
||||||
const raw =
|
const raw =
|
||||||
getHeader(req, "x-openclaw-agent-id")?.trim() ||
|
getHeader(req, "x-openclaw-agent-id")?.trim() ||
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ afterAll(async () => {
|
|||||||
async function startServer(port: number, opts?: { openAiChatCompletionsEnabled?: boolean }) {
|
async function startServer(port: number, opts?: { openAiChatCompletionsEnabled?: boolean }) {
|
||||||
return await startGatewayServer(port, {
|
return await startGatewayServer(port, {
|
||||||
host: "127.0.0.1",
|
host: "127.0.0.1",
|
||||||
auth: { mode: "token", token: "secret" },
|
auth: { mode: "none" },
|
||||||
controlUiEnabled: false,
|
controlUiEnabled: false,
|
||||||
openAiChatCompletionsEnabled: opts?.openAiChatCompletionsEnabled ?? 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>) {
|
async function getModels(pathname: string, headers?: Record<string, string>) {
|
||||||
return await fetch(`http://127.0.0.1:${enabledPort}${pathname}`, {
|
return await fetch(`http://127.0.0.1:${enabledPort}${pathname}`, {
|
||||||
headers: {
|
headers: {
|
||||||
authorization: "Bearer secret",
|
|
||||||
...READ_SCOPE_HEADER,
|
...READ_SCOPE_HEADER,
|
||||||
...headers,
|
...headers,
|
||||||
},
|
},
|
||||||
@@ -114,7 +113,7 @@ describe("OpenAI-compatible models HTTP API (e2e)", () => {
|
|||||||
const server = await startServer(port, { openAiChatCompletionsEnabled: false });
|
const server = await startServer(port, { openAiChatCompletionsEnabled: false });
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/v1/models`, {
|
const res = await fetch(`http://127.0.0.1:${port}/v1/models`, {
|
||||||
headers: { authorization: "Bearer secret" },
|
headers: {},
|
||||||
});
|
});
|
||||||
expect(res.status).toBe(404);
|
expect(res.status).toBe(404);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -3,15 +3,14 @@ import { listAgentIds, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
|||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
import type { AuthRateLimiter } from "./auth-rate-limit.js";
|
import type { AuthRateLimiter } from "./auth-rate-limit.js";
|
||||||
import type { ResolvedGatewayAuth } from "./auth.js";
|
import type { ResolvedGatewayAuth } from "./auth.js";
|
||||||
import {
|
|
||||||
authorizeGatewayBearerRequestOrReply,
|
|
||||||
resolveGatewayRequestedOperatorScopes,
|
|
||||||
} from "./http-auth-helpers.js";
|
|
||||||
import { sendInvalidRequest, sendJson, sendMethodNotAllowed } from "./http-common.js";
|
import { sendInvalidRequest, sendJson, sendMethodNotAllowed } from "./http-common.js";
|
||||||
import {
|
import {
|
||||||
OPENCLAW_DEFAULT_MODEL_ID,
|
OPENCLAW_DEFAULT_MODEL_ID,
|
||||||
OPENCLAW_MODEL_ID,
|
OPENCLAW_MODEL_ID,
|
||||||
|
authorizeGatewayHttpRequestOrReply,
|
||||||
|
type AuthorizedGatewayHttpRequest,
|
||||||
resolveAgentIdFromModel,
|
resolveAgentIdFromModel,
|
||||||
|
resolveTrustedHttpOperatorScopes,
|
||||||
} from "./http-utils.js";
|
} from "./http-utils.js";
|
||||||
import { authorizeOperatorScopesForMethod } from "./method-scopes.js";
|
import { authorizeOperatorScopesForMethod } from "./method-scopes.js";
|
||||||
|
|
||||||
@@ -44,8 +43,8 @@ async function authorizeRequest(
|
|||||||
req: IncomingMessage,
|
req: IncomingMessage,
|
||||||
res: ServerResponse,
|
res: ServerResponse,
|
||||||
opts: OpenAiModelsHttpOptions,
|
opts: OpenAiModelsHttpOptions,
|
||||||
): Promise<boolean> {
|
): Promise<AuthorizedGatewayHttpRequest | null> {
|
||||||
return await authorizeGatewayBearerRequestOrReply({
|
return await authorizeGatewayHttpRequestOrReply({
|
||||||
req,
|
req,
|
||||||
res,
|
res,
|
||||||
auth: opts.auth,
|
auth: opts.auth,
|
||||||
@@ -85,11 +84,12 @@ export async function handleOpenAiModelsHttpRequest(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(await authorizeRequest(req, res, opts))) {
|
const requestAuth = await authorizeRequest(req, res, opts);
|
||||||
|
if (!requestAuth) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestedScopes = resolveGatewayRequestedOperatorScopes(req);
|
const requestedScopes = resolveTrustedHttpOperatorScopes(req, requestAuth);
|
||||||
const scopeAuth = authorizeOperatorScopesForMethod("models.list", requestedScopes);
|
const scopeAuth = authorizeOperatorScopesForMethod("models.list", requestedScopes);
|
||||||
if (!scopeAuth.allowed) {
|
if (!scopeAuth.allowed) {
|
||||||
sendJson(res, 403, {
|
sendJson(res, 403, {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ installGatewayTestHooks({ scope: "test" });
|
|||||||
|
|
||||||
const OPENAI_SERVER_OPTIONS = {
|
const OPENAI_SERVER_OPTIONS = {
|
||||||
host: "127.0.0.1",
|
host: "127.0.0.1",
|
||||||
auth: { mode: "token" as const, token: "secret" },
|
auth: { mode: "none" as const },
|
||||||
controlUiEnabled: false,
|
controlUiEnabled: false,
|
||||||
openAiChatCompletionsEnabled: true,
|
openAiChatCompletionsEnabled: true,
|
||||||
};
|
};
|
||||||
@@ -19,7 +19,6 @@ async function runOpenAiMessageChannelRequest(params?: { messageChannelHeader?:
|
|||||||
async ({ port }) => {
|
async ({ port }) => {
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
"content-type": "application/json",
|
"content-type": "application/json",
|
||||||
authorization: "Bearer secret",
|
|
||||||
"x-openclaw-scopes": "operator.write",
|
"x-openclaw-scopes": "operator.write",
|
||||||
};
|
};
|
||||||
if (params?.messageChannelHeader) {
|
if (params?.messageChannelHeader) {
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ afterAll(async () => {
|
|||||||
async function startServerWithDefaultConfig(port: number) {
|
async function startServerWithDefaultConfig(port: number) {
|
||||||
return await startGatewayServer(port, {
|
return await startGatewayServer(port, {
|
||||||
host: "127.0.0.1",
|
host: "127.0.0.1",
|
||||||
auth: { mode: "token", token: "secret" },
|
auth: { mode: "none" },
|
||||||
controlUiEnabled: false,
|
controlUiEnabled: false,
|
||||||
openAiChatCompletionsEnabled: false,
|
openAiChatCompletionsEnabled: false,
|
||||||
});
|
});
|
||||||
@@ -41,7 +41,7 @@ async function startServerWithDefaultConfig(port: number) {
|
|||||||
async function startServer(port: number, opts?: { openAiChatCompletionsEnabled?: boolean }) {
|
async function startServer(port: number, opts?: { openAiChatCompletionsEnabled?: boolean }) {
|
||||||
return await startGatewayServer(port, {
|
return await startGatewayServer(port, {
|
||||||
host: "127.0.0.1",
|
host: "127.0.0.1",
|
||||||
auth: { mode: "token", token: "secret" },
|
auth: { mode: "none" },
|
||||||
controlUiEnabled: false,
|
controlUiEnabled: false,
|
||||||
openAiChatCompletionsEnabled: opts?.openAiChatCompletionsEnabled ?? true,
|
openAiChatCompletionsEnabled: opts?.openAiChatCompletionsEnabled ?? true,
|
||||||
});
|
});
|
||||||
@@ -61,7 +61,6 @@ async function postChatCompletions(port: number, body: unknown, headers?: Record
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"content-type": "application/json",
|
"content-type": "application/json",
|
||||||
authorization: "Bearer secret",
|
|
||||||
"x-openclaw-scopes": "operator.write",
|
"x-openclaw-scopes": "operator.write",
|
||||||
...headers,
|
...headers,
|
||||||
},
|
},
|
||||||
@@ -96,7 +95,7 @@ function parseSseDataLines(text: string): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("OpenAI-compatible HTTP API (e2e)", () => {
|
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(startServerWithDefaultConfig);
|
||||||
await expectChatCompletionsDisabled((port) =>
|
await expectChatCompletionsDisabled((port) =>
|
||||||
startServer(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`, {
|
const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
body: JSON.stringify({ messages: [{ role: "user", content: "hi" }] }),
|
body: JSON.stringify({ messages: [{ role: "user", content: "hi" }] }),
|
||||||
});
|
});
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(403);
|
||||||
await res.text();
|
await res.text();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,10 @@ import type { AuthRateLimiter } from "./auth-rate-limit.js";
|
|||||||
import type { ResolvedGatewayAuth } from "./auth.js";
|
import type { ResolvedGatewayAuth } from "./auth.js";
|
||||||
import { sendJson, setSseHeaders, writeDone } from "./http-common.js";
|
import { sendJson, setSseHeaders, writeDone } from "./http-common.js";
|
||||||
import { handleGatewayPostJsonEndpoint } from "./http-endpoint-helpers.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";
|
import { normalizeInputHostnameAllowlist } from "./input-allowlist.js";
|
||||||
|
|
||||||
type OpenAiHttpOptions = {
|
type OpenAiHttpOptions = {
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ async function startServer(port: number, opts?: { openResponsesEnabled?: boolean
|
|||||||
const { startGatewayServer } = await import("./server.js");
|
const { startGatewayServer } = await import("./server.js");
|
||||||
const serverOpts = {
|
const serverOpts = {
|
||||||
host: "127.0.0.1",
|
host: "127.0.0.1",
|
||||||
auth: { mode: "token", token: "secret" },
|
auth: { mode: "none" as const },
|
||||||
controlUiEnabled: false,
|
controlUiEnabled: false,
|
||||||
} as const;
|
} as const;
|
||||||
return await startGatewayServer(
|
return await startGatewayServer(
|
||||||
@@ -70,7 +70,6 @@ async function postResponses(port: number, body: unknown, headers?: Record<strin
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"content-type": "application/json",
|
"content-type": "application/json",
|
||||||
authorization: "Bearer secret",
|
|
||||||
"x-openclaw-scopes": "operator.write",
|
"x-openclaw-scopes": "operator.write",
|
||||||
...headers,
|
...headers,
|
||||||
},
|
},
|
||||||
@@ -174,7 +173,7 @@ async function expectInvalidRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("OpenResponses HTTP API (e2e)", () => {
|
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 port = await getFreePort();
|
||||||
const server = await startServer(port);
|
const server = await startServer(port);
|
||||||
try {
|
try {
|
||||||
@@ -224,7 +223,7 @@ describe("OpenResponses HTTP API (e2e)", () => {
|
|||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify({ model: "openclaw", input: "hi" }),
|
body: JSON.stringify({ model: "openclaw", input: "hi" }),
|
||||||
});
|
});
|
||||||
expect(resMissingAuth.status).toBe(401);
|
expect(resMissingAuth.status).toBe(403);
|
||||||
await ensureResponseConsumed(resMissingAuth);
|
await ensureResponseConsumed(resMissingAuth);
|
||||||
|
|
||||||
const resMissingModel = await postResponses(port, { input: "hi" });
|
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 {
|
const missingHeaderBody = (await missingHeaderRes.json()) as {
|
||||||
object?: string;
|
error?: { type?: string; message?: string };
|
||||||
choices?: Array<{ message?: { content?: string } }>;
|
|
||||||
};
|
};
|
||||||
expect(missingHeaderBody.object).toBe("chat.completion");
|
expect(missingHeaderBody.error?.type).toBe("forbidden");
|
||||||
expect(missingHeaderBody.choices?.[0]?.message?.content).toBe("hello");
|
expect(missingHeaderBody.error?.message).toBe("missing scope: operator.write");
|
||||||
expect(agentCommand).toHaveBeenCalledTimes(1);
|
expect(agentCommand).toHaveBeenCalledTimes(0);
|
||||||
} finally {
|
} finally {
|
||||||
started.ws.close();
|
started.ws.close();
|
||||||
await started.server.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", {
|
const started = await startServerWithClient("secret", {
|
||||||
openAiChatCompletionsEnabled: true,
|
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 {
|
const body = (await httpRes.json()) as {
|
||||||
object?: string;
|
error?: { type?: string; message?: string };
|
||||||
choices?: Array<{ message?: { content?: string } }>;
|
|
||||||
};
|
};
|
||||||
expect(body.object).toBe("chat.completion");
|
expect(body.error?.type).toBe("forbidden");
|
||||||
expect(body.choices?.[0]?.message?.content).toBe("hello");
|
expect(body.error?.message).toBe("missing scope: operator.write");
|
||||||
expect(agentCommand).toHaveBeenCalledTimes(1);
|
expect(agentCommand).toHaveBeenCalledTimes(0);
|
||||||
} finally {
|
} finally {
|
||||||
started.ws.close();
|
started.ws.close();
|
||||||
await started.server.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", {
|
const started = await startServerWithClient("secret", {
|
||||||
openResponsesEnabled: true,
|
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 {
|
const body = (await httpRes.json()) as {
|
||||||
object?: string;
|
error?: { type?: string; message?: string };
|
||||||
status?: string;
|
|
||||||
output?: Array<{
|
|
||||||
type?: string;
|
|
||||||
role?: string;
|
|
||||||
content?: Array<{ type?: string; text?: string }>;
|
|
||||||
}>;
|
|
||||||
};
|
};
|
||||||
expect(body.object).toBe("response");
|
expect(body.error?.type).toBe("forbidden");
|
||||||
expect(body.status).toBe("completed");
|
expect(body.error?.message).toBe("missing scope: operator.write");
|
||||||
expect(body.output?.[0]?.type).toBe("message");
|
expect(agentCommand).toHaveBeenCalledTimes(0);
|
||||||
expect(body.output?.[0]?.role).toBe("assistant");
|
} finally {
|
||||||
expect(body.output?.[0]?.content?.[0]?.type).toBe("output_text");
|
started.ws.close();
|
||||||
expect(body.output?.[0]?.content?.[0]?.text).toBe("hello");
|
await started.server.close();
|
||||||
expect(agentCommand).toHaveBeenCalledTimes(1);
|
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 {
|
} finally {
|
||||||
started.ws.close();
|
started.ws.close();
|
||||||
await started.server.close();
|
await started.server.close();
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ async function fetchSessionHistory(
|
|||||||
headers?: HeadersInit;
|
headers?: HeadersInit;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const headers = new Headers(AUTH_HEADER);
|
const headers = new Headers();
|
||||||
for (const [key, value] of new Headers(READ_SCOPE_HEADER).entries()) {
|
for (const [key, value] of new Headers(READ_SCOPE_HEADER).entries()) {
|
||||||
headers.set(key, value);
|
headers.set(key, value);
|
||||||
}
|
}
|
||||||
@@ -80,7 +80,11 @@ async function fetchSessionHistory(
|
|||||||
async function withGatewayHarness<T>(
|
async function withGatewayHarness<T>(
|
||||||
run: (harness: Awaited<ReturnType<typeof createGatewaySuiteHarness>>) => Promise<T>,
|
run: (harness: Awaited<ReturnType<typeof createGatewaySuiteHarness>>) => Promise<T>,
|
||||||
) {
|
) {
|
||||||
const harness = await createGatewaySuiteHarness();
|
const harness = await createGatewaySuiteHarness({
|
||||||
|
serverOptions: {
|
||||||
|
auth: { mode: "none" },
|
||||||
|
},
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
return await run(harness);
|
return await run(harness);
|
||||||
} finally {
|
} 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(
|
const httpHistoryWithoutScopes = await fetch(
|
||||||
`http://127.0.0.1:${harness.port}/sessions/${encodeURIComponent("agent:main:main")}/history?limit=1`,
|
`http://127.0.0.1:${harness.port}/sessions/${encodeURIComponent("agent:main:main")}/history?limit=1`,
|
||||||
{
|
{
|
||||||
headers: AUTH_HEADER,
|
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 {
|
} finally {
|
||||||
ws.close();
|
ws.close();
|
||||||
await harness.close();
|
await harness.close();
|
||||||
|
|||||||
@@ -6,17 +6,17 @@ import { loadSessionStore } from "../config/sessions.js";
|
|||||||
import { onSessionTranscriptUpdate } from "../sessions/transcript-events.js";
|
import { onSessionTranscriptUpdate } from "../sessions/transcript-events.js";
|
||||||
import type { AuthRateLimiter } from "./auth-rate-limit.js";
|
import type { AuthRateLimiter } from "./auth-rate-limit.js";
|
||||||
import type { ResolvedGatewayAuth } from "./auth.js";
|
import type { ResolvedGatewayAuth } from "./auth.js";
|
||||||
import {
|
|
||||||
authorizeGatewayBearerRequestOrReply,
|
|
||||||
resolveGatewayRequestedOperatorScopes,
|
|
||||||
} from "./http-auth-helpers.js";
|
|
||||||
import {
|
import {
|
||||||
sendInvalidRequest,
|
sendInvalidRequest,
|
||||||
sendJson,
|
sendJson,
|
||||||
sendMethodNotAllowed,
|
sendMethodNotAllowed,
|
||||||
setSseHeaders,
|
setSseHeaders,
|
||||||
} from "./http-common.js";
|
} 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 { authorizeOperatorScopesForMethod } from "./method-scopes.js";
|
||||||
import {
|
import {
|
||||||
attachOpenClawTranscriptMeta,
|
attachOpenClawTranscriptMeta,
|
||||||
@@ -158,7 +158,7 @@ export async function handleSessionHistoryHttpRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const ok = await authorizeGatewayBearerRequestOrReply({
|
const requestAuth = await authorizeGatewayHttpRequestOrReply({
|
||||||
req,
|
req,
|
||||||
res,
|
res,
|
||||||
auth: opts.auth,
|
auth: opts.auth,
|
||||||
@@ -166,13 +166,13 @@ export async function handleSessionHistoryHttpRequest(
|
|||||||
allowRealIpFallback: opts.allowRealIpFallback ?? cfg.gateway?.allowRealIpFallback,
|
allowRealIpFallback: opts.allowRealIpFallback ?? cfg.gateway?.allowRealIpFallback,
|
||||||
rateLimiter: opts.rateLimiter,
|
rateLimiter: opts.rateLimiter,
|
||||||
});
|
});
|
||||||
if (!ok) {
|
if (!requestAuth) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// HTTP callers must declare the same least-privilege operator scopes they
|
// HTTP callers must declare the same least-privilege operator scopes they
|
||||||
// intend to use over WS so both transport surfaces enforce the same gate.
|
// 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);
|
const scopeAuth = authorizeOperatorScopesForMethod("chat.history", requestedScopes);
|
||||||
if (!scopeAuth.allowed) {
|
if (!scopeAuth.allowed) {
|
||||||
sendJson(res, 403, {
|
sendJson(res, 403, {
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ type RunBeforeToolCallHook = typeof runBeforeToolCallHookType;
|
|||||||
type RunBeforeToolCallHookArgs = Parameters<RunBeforeToolCallHook>[0];
|
type RunBeforeToolCallHookArgs = Parameters<RunBeforeToolCallHook>[0];
|
||||||
type RunBeforeToolCallHookResult = Awaited<ReturnType<RunBeforeToolCallHook>>;
|
type RunBeforeToolCallHookResult = Awaited<ReturnType<RunBeforeToolCallHook>>;
|
||||||
|
|
||||||
const TEST_GATEWAY_TOKEN = "test-gateway-token-1234567890";
|
|
||||||
|
|
||||||
const hookMocks = vi.hoisted(() => ({
|
const hookMocks = vi.hoisted(() => ({
|
||||||
resolveToolLoopDetectionConfig: vi.fn(() => ({ warnAt: 3 })),
|
resolveToolLoopDetectionConfig: vi.fn(() => ({ warnAt: 3 })),
|
||||||
runBeforeToolCallHook: vi.fn(
|
runBeforeToolCallHook: vi.fn(
|
||||||
@@ -50,7 +48,7 @@ vi.mock("../config/sessions.js", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./auth.js", () => ({
|
vi.mock("./auth.js", () => ({
|
||||||
authorizeHttpGatewayConnect: async () => ({ ok: true }),
|
authorizeHttpGatewayConnect: vi.fn(async () => ({ ok: true })),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../logger.js", () => ({
|
vi.mock("../logger.js", () => ({
|
||||||
@@ -197,6 +195,7 @@ vi.mock("../agents/pi-tools.before-tool-call.js", () => ({
|
|||||||
runBeforeToolCallHook: hookMocks.runBeforeToolCallHook,
|
runBeforeToolCallHook: hookMocks.runBeforeToolCallHook,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const { authorizeHttpGatewayConnect } = await import("./auth.js");
|
||||||
const { handleToolsInvokeHttpRequest } = await import("./tools-invoke-http.js");
|
const { handleToolsInvokeHttpRequest } = await import("./tools-invoke-http.js");
|
||||||
|
|
||||||
let pluginHttpHandlers: Array<(req: IncomingMessage, res: ServerResponse) => Promise<boolean>> = [];
|
let pluginHttpHandlers: Array<(req: IncomingMessage, res: ServerResponse) => Promise<boolean>> = [];
|
||||||
@@ -208,7 +207,7 @@ beforeAll(async () => {
|
|||||||
sharedServer = createServer((req, res) => {
|
sharedServer = createServer((req, res) => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const handled = await handleToolsInvokeHttpRequest(req, res, {
|
const handled = await handleToolsInvokeHttpRequest(req, res, {
|
||||||
auth: { mode: "token", token: TEST_GATEWAY_TOKEN, allowTailscale: false },
|
auth: { mode: "none", allowTailscale: false },
|
||||||
});
|
});
|
||||||
if (handled) {
|
if (handled) {
|
||||||
return;
|
return;
|
||||||
@@ -260,17 +259,11 @@ beforeEach(() => {
|
|||||||
params: args.params,
|
params: args.params,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
vi.mocked(authorizeHttpGatewayConnect).mockResolvedValue({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
const resolveGatewayToken = (): string => TEST_GATEWAY_TOKEN;
|
const gatewayAuthHeaders = () => ({ "x-openclaw-scopes": "operator.write" });
|
||||||
const gatewayAuthHeaders = () => ({
|
const gatewayAdminHeaders = () => ({ "x-openclaw-scopes": "operator.admin" });
|
||||||
authorization: `Bearer ${resolveGatewayToken()}`,
|
|
||||||
"x-openclaw-scopes": "operator.write",
|
|
||||||
});
|
|
||||||
const gatewayAdminHeaders = () => ({
|
|
||||||
authorization: `Bearer ${resolveGatewayToken()}`,
|
|
||||||
"x-openclaw-scopes": "operator.admin",
|
|
||||||
});
|
|
||||||
|
|
||||||
const allowAgentsListForMain = () => {
|
const allowAgentsListForMain = () => {
|
||||||
cfg = {
|
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 () => {
|
it("uses before_tool_call adjusted params for HTTP tool execution", async () => {
|
||||||
setMainAllowedTools({ allow: ["tools_invoke_test"] });
|
setMainAllowedTools({ allow: ["tools_invoke_test"] });
|
||||||
hookMocks.runBeforeToolCallHook.mockImplementationOnce(async () => ({
|
hookMocks.runBeforeToolCallHook.mockImplementationOnce(async () => ({
|
||||||
@@ -718,9 +741,7 @@ describe("POST /tools/invoke", () => {
|
|||||||
|
|
||||||
const res = await invokeTool({
|
const res = await invokeTool({
|
||||||
port: sharedPort,
|
port: sharedPort,
|
||||||
headers: {
|
headers: {},
|
||||||
authorization: `Bearer ${resolveGatewayToken()}`,
|
|
||||||
},
|
|
||||||
tool: "agents_list",
|
tool: "agents_list",
|
||||||
sessionKey: "main",
|
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 () => {
|
it("applies owner-only tool policy on the HTTP path", async () => {
|
||||||
setMainAllowedTools({ allow: ["owner_only_test"] });
|
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 { normalizeMessageChannel } from "../utils/message-channel.js";
|
||||||
import type { AuthRateLimiter } from "./auth-rate-limit.js";
|
import type { AuthRateLimiter } from "./auth-rate-limit.js";
|
||||||
import type { ResolvedGatewayAuth } from "./auth.js";
|
import type { ResolvedGatewayAuth } from "./auth.js";
|
||||||
import {
|
|
||||||
authorizeGatewayBearerRequestOrReply,
|
|
||||||
resolveGatewayRequestedOperatorScopes,
|
|
||||||
} from "./http-auth-helpers.js";
|
|
||||||
import {
|
import {
|
||||||
readJsonBodyOrError,
|
readJsonBodyOrError,
|
||||||
sendInvalidRequest,
|
sendInvalidRequest,
|
||||||
sendJson,
|
sendJson,
|
||||||
sendMethodNotAllowed,
|
sendMethodNotAllowed,
|
||||||
} from "./http-common.js";
|
} 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 { authorizeOperatorScopesForMethod } from "./method-scopes.js";
|
||||||
|
|
||||||
const DEFAULT_BODY_BYTES = 2 * 1024 * 1024;
|
const DEFAULT_BODY_BYTES = 2 * 1024 * 1024;
|
||||||
@@ -161,7 +161,7 @@ export async function handleToolsInvokeHttpRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const ok = await authorizeGatewayBearerRequestOrReply({
|
const requestAuth = await authorizeGatewayHttpRequestOrReply({
|
||||||
req,
|
req,
|
||||||
res,
|
res,
|
||||||
auth: opts.auth,
|
auth: opts.auth,
|
||||||
@@ -169,11 +169,22 @@ export async function handleToolsInvokeHttpRequest(
|
|||||||
allowRealIpFallback: opts.allowRealIpFallback ?? cfg.gateway?.allowRealIpFallback,
|
allowRealIpFallback: opts.allowRealIpFallback ?? cfg.gateway?.allowRealIpFallback,
|
||||||
rateLimiter: opts.rateLimiter,
|
rateLimiter: opts.rateLimiter,
|
||||||
});
|
});
|
||||||
if (!ok) {
|
if (!requestAuth) {
|
||||||
return true;
|
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);
|
const scopeAuth = authorizeOperatorScopesForMethod("agent", requestedScopes);
|
||||||
if (!scopeAuth.allowed) {
|
if (!scopeAuth.allowed) {
|
||||||
sendJson(res, 403, {
|
sendJson(res, 403, {
|
||||||
|
|||||||
Reference in New Issue
Block a user