mirror of https://github.com/openclaw/openclaw.git
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:
parent
17479ceb43
commit
703e68a749
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue