Fix HTTP OpenAI-compatible routes missing operator.write scope checks (#56618)

* Fix HTTP OpenAI-compatible routes missing operator.write scope checks

* Update src/gateway/http-endpoint-helpers.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Address Greptile feedback

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
Devin Robison 2026-03-28 14:22:21 -07:00 committed by GitHub
parent 17479ceb43
commit 703e68a749
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 297 additions and 3 deletions

View File

@ -6,18 +6,28 @@ import { handleGatewayPostJsonEndpoint } from "./http-endpoint-helpers.js";
vi.mock("./http-auth-helpers.js", () => {
return {
authorizeGatewayBearerRequestOrReply: vi.fn(),
resolveGatewayRequestedOperatorScopes: vi.fn(),
};
});
vi.mock("./http-common.js", () => {
return {
readJsonBodyOrError: vi.fn(),
sendJson: vi.fn(),
sendMethodNotAllowed: vi.fn(),
};
});
vi.mock("./method-scopes.js", () => {
return {
authorizeOperatorScopesForMethod: vi.fn(),
};
});
const { authorizeGatewayBearerRequestOrReply } = await import("./http-auth-helpers.js");
const { readJsonBodyOrError, sendMethodNotAllowed } = await import("./http-common.js");
const { resolveGatewayRequestedOperatorScopes } = await import("./http-auth-helpers.js");
const { readJsonBodyOrError, sendJson, sendMethodNotAllowed } = await import("./http-common.js");
const { authorizeOperatorScopesForMethod } = await import("./method-scopes.js");
describe("handleGatewayPostJsonEndpoint", () => {
it("returns false when path does not match", async () => {
@ -77,4 +87,48 @@ describe("handleGatewayPostJsonEndpoint", () => {
);
expect(result).toEqual({ body: { hello: "world" } });
});
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(authorizeOperatorScopesForMethod).mockReturnValue({
allowed: false,
missingScope: "operator.write",
});
const mockedSendJson = vi.mocked(sendJson);
mockedSendJson.mockClear();
vi.mocked(readJsonBodyOrError).mockClear();
const result = await handleGatewayPostJsonEndpoint(
{
url: "/v1/ok",
method: "POST",
headers: { host: "localhost" },
} as unknown as IncomingMessage,
{} as unknown as ServerResponse,
{
pathname: "/v1/ok",
auth: {} as unknown as ResolvedGatewayAuth,
maxBodyBytes: 123,
requiredOperatorMethod: "chat.send",
},
);
expect(result).toBeUndefined();
expect(vi.mocked(authorizeOperatorScopesForMethod)).toHaveBeenCalledWith("chat.send", [
"operator.approvals",
]);
expect(mockedSendJson).toHaveBeenCalledWith(
expect.anything(),
403,
expect.objectContaining({
ok: false,
error: expect.objectContaining({
type: "forbidden",
message: "missing scope: operator.write",
}),
}),
);
expect(vi.mocked(readJsonBodyOrError)).not.toHaveBeenCalled();
});
});

View File

@ -1,8 +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 } from "./http-auth-helpers.js";
import { readJsonBodyOrError, sendMethodNotAllowed } from "./http-common.js";
import {
authorizeGatewayBearerRequestOrReply,
resolveGatewayRequestedOperatorScopes,
} from "./http-auth-helpers.js";
import { readJsonBodyOrError, sendJson, sendMethodNotAllowed } from "./http-common.js";
import { authorizeOperatorScopesForMethod } from "./method-scopes.js";
export async function handleGatewayPostJsonEndpoint(
req: IncomingMessage,
@ -14,6 +18,7 @@ export async function handleGatewayPostJsonEndpoint(
trustedProxies?: string[];
allowRealIpFallback?: boolean;
rateLimiter?: AuthRateLimiter;
requiredOperatorMethod?: "chat.send" | (string & Record<never, never>);
},
): Promise<false | { body: unknown } | undefined> {
const url = new URL(req.url ?? "/", `http://${req.headers.host || "localhost"}`);
@ -38,6 +43,24 @@ export async function handleGatewayPostJsonEndpoint(
return undefined;
}
if (opts.requiredOperatorMethod) {
const requestedScopes = resolveGatewayRequestedOperatorScopes(req);
const scopeAuth = authorizeOperatorScopesForMethod(
opts.requiredOperatorMethod,
requestedScopes,
);
if (!scopeAuth.allowed) {
sendJson(res, 403, {
ok: false,
error: {
type: "forbidden",
message: `missing scope: ${scopeAuth.missingScope}`,
},
});
return undefined;
}
}
const body = await readJsonBodyOrError(req, res, opts.maxBodyBytes);
if (body === undefined) {
return undefined;

View File

@ -20,6 +20,7 @@ async function runOpenAiMessageChannelRequest(params?: { messageChannelHeader?:
const headers: Record<string, string> = {
"content-type": "application/json",
authorization: "Bearer secret",
"x-openclaw-scopes": "operator.write",
};
if (params?.messageChannelHeader) {
headers["x-openclaw-message-channel"] = params.messageChannelHeader;

View File

@ -62,6 +62,7 @@ async function postChatCompletions(port: number, body: unknown, headers?: Record
headers: {
"content-type": "application/json",
authorization: "Bearer secret",
"x-openclaw-scopes": "operator.write",
...headers,
},
body: JSON.stringify(body),

View File

@ -416,6 +416,7 @@ export async function handleOpenAiHttpRequest(
const limits = resolveOpenAiChatCompletionsLimits(opts.config);
const handled = await handleGatewayPostJsonEndpoint(req, res, {
pathname: "/v1/chat/completions",
requiredOperatorMethod: "chat.send",
auth: opts.auth,
trustedProxies: opts.trustedProxies,
allowRealIpFallback: opts.allowRealIpFallback,

View File

@ -71,6 +71,7 @@ async function postResponses(port: number, body: unknown, headers?: Record<strin
headers: {
"content-type": "application/json",
authorization: "Bearer secret",
"x-openclaw-scopes": "operator.write",
...headers,
},
body: JSON.stringify(body),

View File

@ -452,6 +452,7 @@ export async function handleOpenResponsesHttpRequest(
: Math.max(limits.maxBodyBytes, limits.files.maxBytes * 2, limits.images.maxBytes * 2));
const handled = await handleGatewayPostJsonEndpoint(req, res, {
pathname: "/v1/responses",
requiredOperatorMethod: "chat.send",
auth: opts.auth,
trustedProxies: opts.trustedProxies,
allowRealIpFallback: opts.allowRealIpFallback,

View File

@ -0,0 +1,212 @@
import { describe, expect, test } from "vitest";
import {
agentCommand,
connectReq,
installGatewayTestHooks,
rpcReq,
startServerWithClient,
} from "./test-helpers.js";
installGatewayTestHooks({ scope: "suite" });
describe("gateway OpenAI-compatible HTTP write-scope bypass PoC", () => {
test("operator.approvals is denied by chat.send and /v1/chat/completions without operator.write", async () => {
const started = await startServerWithClient("secret", {
openAiChatCompletionsEnabled: true,
});
try {
const connect = await connectReq(started.ws, {
token: "secret",
scopes: ["operator.approvals"],
});
expect(connect.ok).toBe(true);
const wsSend = await rpcReq(started.ws, "chat.send", {
sessionKey: "main",
message: "hi",
});
expect(wsSend.ok).toBe(false);
expect(wsSend.error?.message).toBe("missing scope: operator.write");
agentCommand.mockClear();
const httpRes = await fetch(`http://127.0.0.1:${started.port}/v1/chat/completions`, {
method: "POST",
headers: {
authorization: "Bearer secret",
"content-type": "application/json",
"x-openclaw-scopes": "operator.approvals",
},
body: JSON.stringify({
model: "openclaw",
messages: [{ role: "user", content: "hi" }],
}),
});
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("missing scope: operator.write");
expect(agentCommand).toHaveBeenCalledTimes(0);
agentCommand.mockClear();
const missingHeaderRes = await fetch(`http://127.0.0.1:${started.port}/v1/chat/completions`, {
method: "POST",
headers: {
authorization: "Bearer secret",
"content-type": "application/json",
},
body: JSON.stringify({
model: "openclaw",
messages: [{ role: "user", content: "hi" }],
}),
});
expect(missingHeaderRes.status).toBe(403);
const missingHeaderBody = (await missingHeaderRes.json()) as {
error?: { type?: string; message?: string };
};
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();
started.envSnapshot.restore();
}
});
test("operator.write can still use /v1/chat/completions", async () => {
const started = await startServerWithClient("secret", {
openAiChatCompletionsEnabled: true,
});
try {
agentCommand.mockClear();
agentCommand.mockResolvedValueOnce({ payloads: [{ text: "hello" }] } as never);
const httpRes = await fetch(`http://127.0.0.1:${started.port}/v1/chat/completions`, {
method: "POST",
headers: {
authorization: "Bearer secret",
"content-type": "application/json",
"x-openclaw-scopes": "operator.write",
},
body: JSON.stringify({
model: "openclaw",
messages: [{ role: "user", content: "hi" }],
}),
});
expect(httpRes.status).toBe(200);
const body = (await httpRes.json()) as {
object?: string;
choices?: Array<{ message?: { content?: string } }>;
};
expect(body.object).toBe("chat.completion");
expect(body.choices?.[0]?.message?.content).toBe("hello");
expect(agentCommand).toHaveBeenCalledTimes(1);
} finally {
started.ws.close();
await started.server.close();
started.envSnapshot.restore();
}
});
test("operator.approvals is denied by chat.send and /v1/responses without operator.write", async () => {
const started = await startServerWithClient("secret", {
openResponsesEnabled: true,
});
try {
const connect = await connectReq(started.ws, {
token: "secret",
scopes: ["operator.approvals"],
});
expect(connect.ok).toBe(true);
const wsSend = await rpcReq(started.ws, "chat.send", {
sessionKey: "main",
message: "hi",
});
expect(wsSend.ok).toBe(false);
expect(wsSend.error?.message).toBe("missing scope: operator.write");
agentCommand.mockClear();
const httpRes = await fetch(`http://127.0.0.1:${started.port}/v1/responses`, {
method: "POST",
headers: {
authorization: "Bearer secret",
"content-type": "application/json",
"x-openclaw-scopes": "operator.approvals",
},
body: JSON.stringify({
stream: false,
model: "openclaw",
input: "hi",
}),
});
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("missing scope: operator.write");
expect(agentCommand).toHaveBeenCalledTimes(0);
} finally {
started.ws.close();
await started.server.close();
started.envSnapshot.restore();
}
});
test("operator.write can still use /v1/responses", async () => {
const started = await startServerWithClient("secret", {
openResponsesEnabled: true,
});
try {
agentCommand.mockClear();
agentCommand.mockResolvedValueOnce({ payloads: [{ text: "hello" }] } as never);
const httpRes = await fetch(`http://127.0.0.1:${started.port}/v1/responses`, {
method: "POST",
headers: {
authorization: "Bearer secret",
"content-type": "application/json",
"x-openclaw-scopes": "operator.write",
},
body: JSON.stringify({
stream: false,
model: "openclaw",
input: "hi",
}),
});
expect(httpRes.status).toBe(200);
const body = (await httpRes.json()) as {
object?: string;
status?: string;
output?: Array<{
type?: string;
role?: string;
content?: Array<{ type?: string; text?: 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);
} finally {
started.ws.close();
await started.server.close();
started.envSnapshot.restore();
}
});
});