mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-24 00:11:31 +00:00
* feat(gateway): add auth rate-limiting & brute-force protection Add a per-IP sliding-window rate limiter to Gateway authentication endpoints (HTTP, WebSocket upgrade, and WS message-level auth). When gateway.auth.rateLimit is configured, failed auth attempts are tracked per client IP. Once the threshold is exceeded within the sliding window, further attempts are blocked with HTTP 429 + Retry-After until the lockout period expires. Loopback addresses are exempt by default so local CLI sessions are never locked out. The limiter is only created when explicitly configured (undefined otherwise), keeping the feature fully opt-in and backward-compatible. * fix(gateway): isolate auth rate-limit scopes and normalize 429 responses --------- Co-authored-by: buerbaumer <buerbaumer@users.noreply.github.com> Co-authored-by: Peter Steinberger <steipete@gmail.com>
475 lines
16 KiB
TypeScript
475 lines
16 KiB
TypeScript
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { HISTORY_CONTEXT_MARKER } from "../auto-reply/reply/history.js";
|
|
import { CURRENT_MESSAGE_MARKER } from "../auto-reply/reply/mentions.js";
|
|
import { emitAgentEvent } from "../infra/agent-events.js";
|
|
import { agentCommand, getFreePort, installGatewayTestHooks, testState } from "./test-helpers.js";
|
|
|
|
installGatewayTestHooks({ scope: "suite" });
|
|
|
|
let enabledServer: Awaited<ReturnType<typeof startServer>>;
|
|
let enabledPort: number;
|
|
|
|
beforeAll(async () => {
|
|
enabledPort = await getFreePort();
|
|
enabledServer = await startServer(enabledPort);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await enabledServer.close({ reason: "openai http enabled suite done" });
|
|
});
|
|
|
|
async function startServerWithDefaultConfig(port: number) {
|
|
const { startGatewayServer } = await import("./server.js");
|
|
return await startGatewayServer(port, {
|
|
host: "127.0.0.1",
|
|
auth: { mode: "token", token: "secret" },
|
|
controlUiEnabled: false,
|
|
openAiChatCompletionsEnabled: false,
|
|
});
|
|
}
|
|
|
|
async function startServer(port: number, opts?: { openAiChatCompletionsEnabled?: boolean }) {
|
|
const { startGatewayServer } = await import("./server.js");
|
|
return await startGatewayServer(port, {
|
|
host: "127.0.0.1",
|
|
auth: { mode: "token", token: "secret" },
|
|
controlUiEnabled: false,
|
|
openAiChatCompletionsEnabled: opts?.openAiChatCompletionsEnabled ?? true,
|
|
});
|
|
}
|
|
|
|
async function postChatCompletions(port: number, body: unknown, headers?: Record<string, string>) {
|
|
const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
authorization: "Bearer secret",
|
|
...headers,
|
|
},
|
|
body: JSON.stringify(body),
|
|
});
|
|
return res;
|
|
}
|
|
|
|
function parseSseDataLines(text: string): string[] {
|
|
return text
|
|
.split("\n")
|
|
.map((line) => line.trim())
|
|
.filter((line) => line.startsWith("data: "))
|
|
.map((line) => line.slice("data: ".length));
|
|
}
|
|
|
|
describe("OpenAI-compatible HTTP API (e2e)", () => {
|
|
it("rejects when disabled (default + config)", { timeout: 120_000 }, async () => {
|
|
{
|
|
const port = await getFreePort();
|
|
const server = await startServerWithDefaultConfig(port);
|
|
try {
|
|
const res = await postChatCompletions(port, {
|
|
model: "openclaw",
|
|
messages: [{ role: "user", content: "hi" }],
|
|
});
|
|
expect(res.status).toBe(404);
|
|
} finally {
|
|
await server.close({ reason: "test done" });
|
|
}
|
|
}
|
|
|
|
{
|
|
const port = await getFreePort();
|
|
const server = await startServer(port, {
|
|
openAiChatCompletionsEnabled: false,
|
|
});
|
|
try {
|
|
const res = await postChatCompletions(port, {
|
|
model: "openclaw",
|
|
messages: [{ role: "user", content: "hi" }],
|
|
});
|
|
expect(res.status).toBe(404);
|
|
} finally {
|
|
await server.close({ reason: "test done" });
|
|
}
|
|
}
|
|
});
|
|
|
|
it("handles request validation and routing", async () => {
|
|
const port = enabledPort;
|
|
const mockAgentOnce = (payloads: Array<{ text: string }>) => {
|
|
agentCommand.mockReset();
|
|
agentCommand.mockResolvedValueOnce({ payloads } as never);
|
|
};
|
|
|
|
try {
|
|
{
|
|
const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
|
|
method: "GET",
|
|
headers: { authorization: "Bearer secret" },
|
|
});
|
|
expect(res.status).toBe(405);
|
|
await res.text();
|
|
}
|
|
|
|
{
|
|
const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ messages: [{ role: "user", content: "hi" }] }),
|
|
});
|
|
expect(res.status).toBe(401);
|
|
await res.text();
|
|
}
|
|
|
|
{
|
|
mockAgentOnce([{ text: "hello" }]);
|
|
const res = await postChatCompletions(
|
|
port,
|
|
{ model: "openclaw", messages: [{ role: "user", content: "hi" }] },
|
|
{ "x-openclaw-agent-id": "beta" },
|
|
);
|
|
expect(res.status).toBe(200);
|
|
|
|
expect(agentCommand).toHaveBeenCalledTimes(1);
|
|
const [opts] = agentCommand.mock.calls[0] ?? [];
|
|
expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch(
|
|
/^agent:beta:/,
|
|
);
|
|
await res.text();
|
|
}
|
|
|
|
{
|
|
mockAgentOnce([{ text: "hello" }]);
|
|
const res = await postChatCompletions(port, {
|
|
model: "openclaw:beta",
|
|
messages: [{ role: "user", content: "hi" }],
|
|
});
|
|
expect(res.status).toBe(200);
|
|
|
|
expect(agentCommand).toHaveBeenCalledTimes(1);
|
|
const [opts] = agentCommand.mock.calls[0] ?? [];
|
|
expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch(
|
|
/^agent:beta:/,
|
|
);
|
|
await res.text();
|
|
}
|
|
|
|
{
|
|
mockAgentOnce([{ text: "hello" }]);
|
|
const res = await postChatCompletions(
|
|
port,
|
|
{
|
|
model: "openclaw:beta",
|
|
messages: [{ role: "user", content: "hi" }],
|
|
},
|
|
{ "x-openclaw-agent-id": "alpha" },
|
|
);
|
|
expect(res.status).toBe(200);
|
|
|
|
expect(agentCommand).toHaveBeenCalledTimes(1);
|
|
const [opts] = agentCommand.mock.calls[0] ?? [];
|
|
expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch(
|
|
/^agent:alpha:/,
|
|
);
|
|
await res.text();
|
|
}
|
|
|
|
{
|
|
mockAgentOnce([{ text: "hello" }]);
|
|
const res = await postChatCompletions(
|
|
port,
|
|
{ model: "openclaw", messages: [{ role: "user", content: "hi" }] },
|
|
{
|
|
"x-openclaw-agent-id": "beta",
|
|
"x-openclaw-session-key": "agent:beta:openai:custom",
|
|
},
|
|
);
|
|
expect(res.status).toBe(200);
|
|
|
|
const [opts] = agentCommand.mock.calls[0] ?? [];
|
|
expect((opts as { sessionKey?: string } | undefined)?.sessionKey).toBe(
|
|
"agent:beta:openai:custom",
|
|
);
|
|
await res.text();
|
|
}
|
|
|
|
{
|
|
mockAgentOnce([{ text: "hello" }]);
|
|
const res = await postChatCompletions(port, {
|
|
user: "alice",
|
|
model: "openclaw",
|
|
messages: [{ role: "user", content: "hi" }],
|
|
});
|
|
expect(res.status).toBe(200);
|
|
|
|
const [opts] = agentCommand.mock.calls[0] ?? [];
|
|
expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toContain(
|
|
"openai-user:alice",
|
|
);
|
|
await res.text();
|
|
}
|
|
|
|
{
|
|
mockAgentOnce([{ text: "hello" }]);
|
|
const res = await postChatCompletions(port, {
|
|
model: "openclaw",
|
|
messages: [
|
|
{
|
|
role: "user",
|
|
content: [
|
|
{ type: "text", text: "hello" },
|
|
{ type: "input_text", text: "world" },
|
|
],
|
|
},
|
|
],
|
|
});
|
|
expect(res.status).toBe(200);
|
|
|
|
const [opts] = agentCommand.mock.calls[0] ?? [];
|
|
expect((opts as { message?: string } | undefined)?.message).toBe("hello\nworld");
|
|
await res.text();
|
|
}
|
|
|
|
{
|
|
mockAgentOnce([{ text: "I am Claude" }]);
|
|
const res = await postChatCompletions(port, {
|
|
model: "openclaw",
|
|
messages: [
|
|
{ role: "system", content: "You are a helpful assistant." },
|
|
{ role: "user", content: "Hello, who are you?" },
|
|
{ role: "assistant", content: "I am Claude." },
|
|
{ role: "user", content: "What did I just ask you?" },
|
|
],
|
|
});
|
|
expect(res.status).toBe(200);
|
|
|
|
const [opts] = agentCommand.mock.calls[0] ?? [];
|
|
const message = (opts as { message?: string } | undefined)?.message ?? "";
|
|
expect(message).toContain(HISTORY_CONTEXT_MARKER);
|
|
expect(message).toContain("User: Hello, who are you?");
|
|
expect(message).toContain("Assistant: I am Claude.");
|
|
expect(message).toContain(CURRENT_MESSAGE_MARKER);
|
|
expect(message).toContain("User: What did I just ask you?");
|
|
await res.text();
|
|
}
|
|
|
|
{
|
|
mockAgentOnce([{ text: "hello" }]);
|
|
const res = await postChatCompletions(port, {
|
|
model: "openclaw",
|
|
messages: [
|
|
{ role: "system", content: "You are a helpful assistant." },
|
|
{ role: "user", content: "Hello" },
|
|
],
|
|
});
|
|
expect(res.status).toBe(200);
|
|
|
|
const [opts] = agentCommand.mock.calls[0] ?? [];
|
|
const message = (opts as { message?: string } | undefined)?.message ?? "";
|
|
expect(message).not.toContain(HISTORY_CONTEXT_MARKER);
|
|
expect(message).not.toContain(CURRENT_MESSAGE_MARKER);
|
|
expect(message).toBe("Hello");
|
|
await res.text();
|
|
}
|
|
|
|
{
|
|
mockAgentOnce([{ text: "hello" }]);
|
|
const res = await postChatCompletions(port, {
|
|
model: "openclaw",
|
|
messages: [
|
|
{ role: "developer", content: "You are a helpful assistant." },
|
|
{ role: "user", content: "Hello" },
|
|
],
|
|
});
|
|
expect(res.status).toBe(200);
|
|
|
|
const [opts] = agentCommand.mock.calls[0] ?? [];
|
|
const extraSystemPrompt =
|
|
(opts as { extraSystemPrompt?: string } | undefined)?.extraSystemPrompt ?? "";
|
|
expect(extraSystemPrompt).toBe("You are a helpful assistant.");
|
|
await res.text();
|
|
}
|
|
|
|
{
|
|
mockAgentOnce([{ text: "ok" }]);
|
|
const res = await postChatCompletions(port, {
|
|
model: "openclaw",
|
|
messages: [
|
|
{ role: "system", content: "You are a helpful assistant." },
|
|
{ role: "user", content: "What's the weather?" },
|
|
{ role: "assistant", content: "Checking the weather." },
|
|
{ role: "tool", content: "Sunny, 70F." },
|
|
],
|
|
});
|
|
expect(res.status).toBe(200);
|
|
|
|
const [opts] = agentCommand.mock.calls[0] ?? [];
|
|
const message = (opts as { message?: string } | undefined)?.message ?? "";
|
|
expect(message).toContain(HISTORY_CONTEXT_MARKER);
|
|
expect(message).toContain("User: What's the weather?");
|
|
expect(message).toContain("Assistant: Checking the weather.");
|
|
expect(message).toContain(CURRENT_MESSAGE_MARKER);
|
|
expect(message).toContain("Tool: Sunny, 70F.");
|
|
await res.text();
|
|
}
|
|
|
|
{
|
|
mockAgentOnce([{ text: "hello" }]);
|
|
const res = await postChatCompletions(port, {
|
|
stream: false,
|
|
model: "openclaw",
|
|
messages: [{ role: "user", content: "hi" }],
|
|
});
|
|
expect(res.status).toBe(200);
|
|
const json = (await res.json()) as Record<string, unknown>;
|
|
expect(json.object).toBe("chat.completion");
|
|
expect(Array.isArray(json.choices)).toBe(true);
|
|
const choice0 = (json.choices as Array<Record<string, unknown>>)[0] ?? {};
|
|
const msg = (choice0.message as Record<string, unknown> | undefined) ?? {};
|
|
expect(msg.role).toBe("assistant");
|
|
expect(msg.content).toBe("hello");
|
|
}
|
|
|
|
{
|
|
const res = await postChatCompletions(port, {
|
|
model: "openclaw",
|
|
messages: [{ role: "system", content: "yo" }],
|
|
});
|
|
expect(res.status).toBe(400);
|
|
const missingUserJson = (await res.json()) as Record<string, unknown>;
|
|
expect((missingUserJson.error as Record<string, unknown> | undefined)?.type).toBe(
|
|
"invalid_request_error",
|
|
);
|
|
}
|
|
} finally {
|
|
// shared server
|
|
}
|
|
});
|
|
|
|
it("returns 429 for repeated failed auth when gateway.auth.rateLimit is configured", async () => {
|
|
const { startGatewayServer } = await import("./server.js");
|
|
testState.gatewayAuth = {
|
|
mode: "token",
|
|
token: "secret",
|
|
rateLimit: { maxAttempts: 1, windowMs: 60_000, lockoutMs: 60_000, exemptLoopback: false },
|
|
// oxlint-disable-next-line typescript/no-explicit-any
|
|
} as any;
|
|
const port = await getFreePort();
|
|
const server = await startGatewayServer(port, {
|
|
host: "127.0.0.1",
|
|
controlUiEnabled: false,
|
|
openAiChatCompletionsEnabled: true,
|
|
});
|
|
try {
|
|
const headers = {
|
|
"content-type": "application/json",
|
|
authorization: "Bearer wrong",
|
|
};
|
|
const body = {
|
|
model: "openclaw",
|
|
messages: [{ role: "user", content: "hi" }],
|
|
};
|
|
|
|
const first = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify(body),
|
|
});
|
|
expect(first.status).toBe(401);
|
|
|
|
const second = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify(body),
|
|
});
|
|
expect(second.status).toBe(429);
|
|
expect(second.headers.get("retry-after")).toBeTruthy();
|
|
} finally {
|
|
await server.close({ reason: "rate-limit auth test done" });
|
|
}
|
|
});
|
|
|
|
it("streams SSE chunks when stream=true", async () => {
|
|
const port = enabledPort;
|
|
try {
|
|
{
|
|
agentCommand.mockReset();
|
|
agentCommand.mockImplementationOnce(async (opts: unknown) => {
|
|
const runId = (opts as { runId?: string } | undefined)?.runId ?? "";
|
|
emitAgentEvent({ runId, stream: "assistant", data: { delta: "he" } });
|
|
emitAgentEvent({ runId, stream: "assistant", data: { delta: "llo" } });
|
|
return { payloads: [{ text: "hello" }] } as never;
|
|
});
|
|
|
|
const res = await postChatCompletions(port, {
|
|
stream: true,
|
|
model: "openclaw",
|
|
messages: [{ role: "user", content: "hi" }],
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get("content-type") ?? "").toContain("text/event-stream");
|
|
|
|
const text = await res.text();
|
|
const data = parseSseDataLines(text);
|
|
expect(data[data.length - 1]).toBe("[DONE]");
|
|
|
|
const jsonChunks = data
|
|
.filter((d) => d !== "[DONE]")
|
|
.map((d) => JSON.parse(d) as Record<string, unknown>);
|
|
expect(jsonChunks.some((c) => c.object === "chat.completion.chunk")).toBe(true);
|
|
const allContent = jsonChunks
|
|
.flatMap((c) => (c.choices as Array<Record<string, unknown>> | undefined) ?? [])
|
|
.map((choice) => (choice.delta as Record<string, unknown> | undefined)?.content)
|
|
.filter((v): v is string => typeof v === "string")
|
|
.join("");
|
|
expect(allContent).toBe("hello");
|
|
}
|
|
|
|
{
|
|
agentCommand.mockReset();
|
|
agentCommand.mockImplementationOnce(async (opts: unknown) => {
|
|
const runId = (opts as { runId?: string } | undefined)?.runId ?? "";
|
|
emitAgentEvent({ runId, stream: "assistant", data: { delta: "hi" } });
|
|
emitAgentEvent({ runId, stream: "assistant", data: { delta: "hi" } });
|
|
return { payloads: [{ text: "hihi" }] } as never;
|
|
});
|
|
|
|
const repeatedRes = await postChatCompletions(port, {
|
|
stream: true,
|
|
model: "openclaw",
|
|
messages: [{ role: "user", content: "hi" }],
|
|
});
|
|
expect(repeatedRes.status).toBe(200);
|
|
const repeatedText = await repeatedRes.text();
|
|
const repeatedData = parseSseDataLines(repeatedText);
|
|
const repeatedChunks = repeatedData
|
|
.filter((d) => d !== "[DONE]")
|
|
.map((d) => JSON.parse(d) as Record<string, unknown>);
|
|
const repeatedContent = repeatedChunks
|
|
.flatMap((c) => (c.choices as Array<Record<string, unknown>> | undefined) ?? [])
|
|
.map((choice) => (choice.delta as Record<string, unknown> | undefined)?.content)
|
|
.filter((v): v is string => typeof v === "string")
|
|
.join("");
|
|
expect(repeatedContent).toBe("hihi");
|
|
}
|
|
|
|
{
|
|
agentCommand.mockReset();
|
|
agentCommand.mockResolvedValueOnce({
|
|
payloads: [{ text: "hello" }],
|
|
} as never);
|
|
|
|
const fallbackRes = await postChatCompletions(port, {
|
|
stream: true,
|
|
model: "openclaw",
|
|
messages: [{ role: "user", content: "hi" }],
|
|
});
|
|
expect(fallbackRes.status).toBe(200);
|
|
const fallbackText = await fallbackRes.text();
|
|
expect(fallbackText).toContain("[DONE]");
|
|
expect(fallbackText).toContain("hello");
|
|
}
|
|
} finally {
|
|
// shared server
|
|
}
|
|
});
|
|
});
|