Skip to content

Commit f0af186

Browse files
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
1 parent 2a75416 commit f0af186

16 files changed

Lines changed: 476 additions & 113 deletions

src/gateway/http-endpoint-helpers.test.ts

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@ import { describe, expect, it, vi } from "vitest";
33
import type { ResolvedGatewayAuth } from "./auth.js";
44
import { handleGatewayPostJsonEndpoint } from "./http-endpoint-helpers.js";
55

6-
vi.mock("./http-auth-helpers.js", () => {
6+
vi.mock("./http-utils.js", () => {
77
return {
8-
authorizeGatewayBearerRequestOrReply: vi.fn(),
9-
resolveGatewayRequestedOperatorScopes: vi.fn(),
8+
authorizeGatewayHttpRequestOrReply: vi.fn(),
9+
resolveTrustedHttpOperatorScopes: vi.fn(),
1010
};
1111
});
1212

@@ -24,9 +24,9 @@ vi.mock("./method-scopes.js", () => {
2424
};
2525
});
2626

27-
const { authorizeGatewayBearerRequestOrReply } = await import("./http-auth-helpers.js");
28-
const { resolveGatewayRequestedOperatorScopes } = await import("./http-auth-helpers.js");
2927
const { readJsonBodyOrError, sendJson, sendMethodNotAllowed } = await import("./http-common.js");
28+
const { authorizeGatewayHttpRequestOrReply, resolveTrustedHttpOperatorScopes } =
29+
await import("./http-utils.js");
3030
const { authorizeOperatorScopesForMethod } = await import("./method-scopes.js");
3131

3232
describe("handleGatewayPostJsonEndpoint", () => {
@@ -60,7 +60,7 @@ describe("handleGatewayPostJsonEndpoint", () => {
6060
});
6161

6262
it("returns undefined when auth fails", async () => {
63-
vi.mocked(authorizeGatewayBearerRequestOrReply).mockResolvedValue(false);
63+
vi.mocked(authorizeGatewayHttpRequestOrReply).mockResolvedValue(null);
6464
const result = await handleGatewayPostJsonEndpoint(
6565
{
6666
url: "/v1/ok",
@@ -74,7 +74,9 @@ describe("handleGatewayPostJsonEndpoint", () => {
7474
});
7575

7676
it("returns body when auth succeeds and JSON parsing succeeds", async () => {
77-
vi.mocked(authorizeGatewayBearerRequestOrReply).mockResolvedValue(true);
77+
vi.mocked(authorizeGatewayHttpRequestOrReply).mockResolvedValue({
78+
trustDeclaredOperatorScopes: true,
79+
});
7880
vi.mocked(readJsonBodyOrError).mockResolvedValue({ hello: "world" });
7981
const result = await handleGatewayPostJsonEndpoint(
8082
{
@@ -85,12 +87,17 @@ describe("handleGatewayPostJsonEndpoint", () => {
8587
{} as unknown as ServerResponse,
8688
{ pathname: "/v1/ok", auth: {} as unknown as ResolvedGatewayAuth, maxBodyBytes: 123 },
8789
);
88-
expect(result).toEqual({ body: { hello: "world" } });
90+
expect(result).toEqual({
91+
body: { hello: "world" },
92+
requestAuth: { trustDeclaredOperatorScopes: true },
93+
});
8994
});
9095

9196
it("returns undefined and replies when required operator scope is missing", async () => {
92-
vi.mocked(authorizeGatewayBearerRequestOrReply).mockResolvedValue(true);
93-
vi.mocked(resolveGatewayRequestedOperatorScopes).mockReturnValue(["operator.approvals"]);
97+
vi.mocked(authorizeGatewayHttpRequestOrReply).mockResolvedValue({
98+
trustDeclaredOperatorScopes: false,
99+
});
100+
vi.mocked(resolveTrustedHttpOperatorScopes).mockReturnValue(["operator.approvals"]);
94101
vi.mocked(authorizeOperatorScopesForMethod).mockReturnValue({
95102
allowed: false,
96103
missingScope: "operator.write",

src/gateway/http-endpoint-helpers.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import type { IncomingMessage, ServerResponse } from "node:http";
22
import type { AuthRateLimiter } from "./auth-rate-limit.js";
33
import type { ResolvedGatewayAuth } from "./auth.js";
4-
import {
5-
authorizeGatewayBearerRequestOrReply,
6-
resolveGatewayRequestedOperatorScopes,
7-
} from "./http-auth-helpers.js";
84
import { readJsonBodyOrError, sendJson, sendMethodNotAllowed } from "./http-common.js";
5+
import {
6+
authorizeGatewayHttpRequestOrReply,
7+
type AuthorizedGatewayHttpRequest,
8+
resolveTrustedHttpOperatorScopes,
9+
} from "./http-utils.js";
910
import { authorizeOperatorScopesForMethod } from "./method-scopes.js";
1011

1112
export async function handleGatewayPostJsonEndpoint(
@@ -20,7 +21,7 @@ export async function handleGatewayPostJsonEndpoint(
2021
rateLimiter?: AuthRateLimiter;
2122
requiredOperatorMethod?: "chat.send" | (string & Record<never, never>);
2223
},
23-
): Promise<false | { body: unknown } | undefined> {
24+
): Promise<false | { body: unknown; requestAuth: AuthorizedGatewayHttpRequest } | undefined> {
2425
const url = new URL(req.url ?? "/", `http://${req.headers.host || "localhost"}`);
2526
if (url.pathname !== opts.pathname) {
2627
return false;
@@ -31,20 +32,20 @@ export async function handleGatewayPostJsonEndpoint(
3132
return undefined;
3233
}
3334

34-
const authorized = await authorizeGatewayBearerRequestOrReply({
35+
const requestAuth = await authorizeGatewayHttpRequestOrReply({
3536
req,
3637
res,
3738
auth: opts.auth,
3839
trustedProxies: opts.trustedProxies,
3940
allowRealIpFallback: opts.allowRealIpFallback,
4041
rateLimiter: opts.rateLimiter,
4142
});
42-
if (!authorized) {
43+
if (!requestAuth) {
4344
return undefined;
4445
}
4546

4647
if (opts.requiredOperatorMethod) {
47-
const requestedScopes = resolveGatewayRequestedOperatorScopes(req);
48+
const requestedScopes = resolveTrustedHttpOperatorScopes(req, requestAuth);
4849
const scopeAuth = authorizeOperatorScopesForMethod(
4950
opts.requiredOperatorMethod,
5051
requestedScopes,
@@ -66,5 +67,5 @@ export async function handleGatewayPostJsonEndpoint(
6667
return undefined;
6768
}
6869

69-
return { body };
70+
return { body, requestAuth };
7071
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import type { IncomingMessage, ServerResponse } from "node:http";
2+
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
4+
vi.mock("./auth.js", () => ({
5+
authorizeHttpGatewayConnect: vi.fn(),
6+
}));
7+
8+
vi.mock("./http-common.js", () => ({
9+
sendGatewayAuthFailure: vi.fn(),
10+
}));
11+
12+
const { authorizeHttpGatewayConnect } = await import("./auth.js");
13+
const { sendGatewayAuthFailure } = await import("./http-common.js");
14+
const { authorizeGatewayHttpRequestOrReply } = await import("./http-utils.js");
15+
16+
function createReq(headers: Record<string, string> = {}): IncomingMessage {
17+
return { headers } as IncomingMessage;
18+
}
19+
20+
describe("authorizeGatewayHttpRequestOrReply", () => {
21+
beforeEach(() => {
22+
vi.mocked(authorizeHttpGatewayConnect).mockReset();
23+
vi.mocked(sendGatewayAuthFailure).mockReset();
24+
});
25+
26+
it("marks token-authenticated requests as untrusted for declared HTTP scopes", async () => {
27+
vi.mocked(authorizeHttpGatewayConnect).mockResolvedValue({
28+
ok: true,
29+
method: "token",
30+
});
31+
32+
await expect(
33+
authorizeGatewayHttpRequestOrReply({
34+
req: createReq({ authorization: "Bearer secret" }),
35+
res: {} as ServerResponse,
36+
auth: { mode: "trusted-proxy", allowTailscale: false, token: "secret" },
37+
trustedProxies: ["127.0.0.1"],
38+
}),
39+
).resolves.toEqual({
40+
authMethod: "token",
41+
trustDeclaredOperatorScopes: false,
42+
});
43+
});
44+
45+
it("keeps trusted-proxy requests eligible for declared HTTP scopes", async () => {
46+
vi.mocked(authorizeHttpGatewayConnect).mockResolvedValue({
47+
ok: true,
48+
method: "trusted-proxy",
49+
user: "operator",
50+
});
51+
52+
await expect(
53+
authorizeGatewayHttpRequestOrReply({
54+
req: createReq({ authorization: "Bearer upstream-idp-token" }),
55+
res: {} as ServerResponse,
56+
auth: {
57+
mode: "trusted-proxy",
58+
allowTailscale: false,
59+
trustedProxy: { userHeader: "x-user" },
60+
},
61+
trustedProxies: ["127.0.0.1"],
62+
}),
63+
).resolves.toEqual({
64+
authMethod: "trusted-proxy",
65+
trustDeclaredOperatorScopes: true,
66+
});
67+
});
68+
69+
it("replies with auth failure and returns null when auth fails", async () => {
70+
const res = {} as ServerResponse;
71+
vi.mocked(authorizeHttpGatewayConnect).mockResolvedValue({
72+
ok: false,
73+
reason: "unauthorized",
74+
});
75+
76+
await expect(
77+
authorizeGatewayHttpRequestOrReply({
78+
req: createReq(),
79+
res,
80+
auth: { mode: "token", allowTailscale: false, token: "secret" },
81+
}),
82+
).resolves.toBeNull();
83+
84+
expect(sendGatewayAuthFailure).toHaveBeenCalledWith(res, {
85+
ok: false,
86+
reason: "unauthorized",
87+
});
88+
});
89+
});

src/gateway/http-utils.request-context.test.ts

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
11
import type { IncomingMessage } from "node:http";
22
import { describe, expect, it } from "vitest";
3-
import { resolveGatewayRequestContext } from "./http-utils.js";
3+
import {
4+
resolveGatewayRequestContext,
5+
resolveHttpSenderIsOwner,
6+
resolveTrustedHttpOperatorScopes,
7+
} from "./http-utils.js";
48

59
function createReq(headers: Record<string, string> = {}): IncomingMessage {
610
return { headers } as IncomingMessage;
711
}
812

13+
const tokenAuth = { mode: "token" as const };
14+
const noneAuth = { mode: "none" as const };
15+
916
describe("resolveGatewayRequestContext", () => {
1017
it("uses normalized x-openclaw-message-channel when enabled", () => {
1118
const result = resolveGatewayRequestContext({
@@ -43,3 +50,75 @@ describe("resolveGatewayRequestContext", () => {
4350
expect(result.sessionKey).toContain("openresponses-user:alice");
4451
});
4552
});
53+
54+
describe("resolveTrustedHttpOperatorScopes", () => {
55+
it("drops self-asserted scopes for bearer-authenticated requests", () => {
56+
const scopes = resolveTrustedHttpOperatorScopes(
57+
createReq({
58+
authorization: "Bearer secret",
59+
"x-openclaw-scopes": "operator.admin, operator.write",
60+
}),
61+
tokenAuth,
62+
);
63+
64+
expect(scopes).toEqual([]);
65+
});
66+
67+
it("keeps declared scopes for non-bearer HTTP requests", () => {
68+
const scopes = resolveTrustedHttpOperatorScopes(
69+
createReq({
70+
"x-openclaw-scopes": "operator.admin, operator.write",
71+
}),
72+
noneAuth,
73+
);
74+
75+
expect(scopes).toEqual(["operator.admin", "operator.write"]);
76+
});
77+
78+
it("keeps declared scopes when auth mode is not shared-secret even if auth headers are forwarded", () => {
79+
const scopes = resolveTrustedHttpOperatorScopes(
80+
createReq({
81+
authorization: "Bearer upstream-idp-token",
82+
"x-openclaw-scopes": "operator.admin, operator.write",
83+
}),
84+
noneAuth,
85+
);
86+
87+
expect(scopes).toEqual(["operator.admin", "operator.write"]);
88+
});
89+
90+
it("drops declared scopes when request auth resolved to a shared-secret method", () => {
91+
const scopes = resolveTrustedHttpOperatorScopes(
92+
createReq({
93+
authorization: "Bearer upstream-idp-token",
94+
"x-openclaw-scopes": "operator.admin, operator.write",
95+
}),
96+
{ trustDeclaredOperatorScopes: false },
97+
);
98+
99+
expect(scopes).toEqual([]);
100+
});
101+
});
102+
103+
describe("resolveHttpSenderIsOwner", () => {
104+
it("requires operator.admin on a trusted HTTP scope-bearing request", () => {
105+
expect(
106+
resolveHttpSenderIsOwner(createReq({ "x-openclaw-scopes": "operator.admin" }), noneAuth),
107+
).toBe(true);
108+
expect(
109+
resolveHttpSenderIsOwner(createReq({ "x-openclaw-scopes": "operator.write" }), noneAuth),
110+
).toBe(false);
111+
});
112+
113+
it("returns false for bearer requests even with operator.admin in headers", () => {
114+
expect(
115+
resolveHttpSenderIsOwner(
116+
createReq({
117+
authorization: "Bearer secret",
118+
"x-openclaw-scopes": "operator.admin",
119+
}),
120+
tokenAuth,
121+
),
122+
).toBe(false);
123+
});
124+
});

0 commit comments

Comments
 (0)