Skip to content

Commit 6b3998a

Browse files
committed
fix(gateway): ignore malformed host on session routes
1 parent 365c986 commit 6b3998a

7 files changed

Lines changed: 46 additions & 4 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai
3232
- Media store: reject malformed redirect `Location` headers as media-download failures instead of letting URL parsing escape the async response callback.
3333
- ClickClack: skip malformed realtime websocket frames instead of stopping the channel monitor on a single bad JSON event.
3434
- Browser tool: treat malformed node proxy `payloadJSON` responses as browser proxy failures instead of leaking raw JSON parser errors.
35+
- Gateway HTTP: match models, session kill, and session history route paths without trusting malformed Host headers, avoiding pre-auth 500s on those endpoints.
3536
- Models config/auth: stop inferring provider env-var markers from broad `^[A-Z_][A-Z0-9_]*$` strings, and resolve config-backed provider `apiKey` values only through structured env SecretRefs (`secrets.providers[id]` / `secrets.defaults`), so unrelated env vars cannot accidentally become provider credentials. Thanks @sallyom.
3637
- Media fetch: skip allocating and buffering the response body for bodyless media responses (HEAD probes and 204-style empty bodies), avoiding wasted heap on streams that carry no payload. Thanks @shakkernerd.
3738
- CLI/onboarding: forward provider-specific auth flags (e.g. `--openai-api-key`) through the onboarding wizard so they reach provider auth methods via `ctx.opts`, letting `--openai-api-key "$OPENAI_API_KEY"` skip the redundant "use existing env var?" prompt in non-interactive harnesses. (#81669) Thanks @sjf.

src/gateway/models-http.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,14 @@ describe("OpenAI-compatible models HTTP API (e2e)", () => {
8383
).toBe(true);
8484
});
8585

86+
it("serves /v1/models without trusting malformed Host headers", async () => {
87+
const res = await getModels("/v1/models", { Host: "[" });
88+
expect(res.status).toBe(200);
89+
const json = (await res.json()) as { object?: string; data?: Array<{ id?: string }> };
90+
expect(json.object).toBe("list");
91+
expect(json.data?.map((entry) => entry.id)).toContain("openclaw/default");
92+
});
93+
8694
it("serves /v1/models/{id}", async () => {
8795
const firstId = await expectFirstModelId();
8896
const res = await getModels(`/v1/models/${encodeURIComponent(firstId)}`);

src/gateway/models-http.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ function loadAgentModelIds(): string[] {
7171
}
7272

7373
function resolveRequestPath(req: IncomingMessage): string {
74-
return new URL(req.url ?? "/", `http://${req.headers.host || "localhost"}`).pathname;
74+
return new URL(req.url ?? "/", "http://localhost").pathname;
7575
}
7676

7777
export async function handleOpenAiModelsHttpRequest(

src/gateway/session-kill-http.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,23 @@ describe("POST /sessions/:sessionKey/kill", () => {
146146
expect(killSubagentRunAdminMock).not.toHaveBeenCalled();
147147
});
148148

149+
it("matches kill paths without trusting malformed Host headers", async () => {
150+
authMock.mockResolvedValueOnce({ ok: true, method: "trusted-proxy" });
151+
loadSessionEntryMock.mockReturnValue({ entry: undefined });
152+
153+
const response = await post(
154+
"/sessions/agent%3Amain%3Asubagent%3Aworker/kill",
155+
TEST_GATEWAY_TOKEN,
156+
{
157+
Host: "[",
158+
"x-openclaw-scopes": "operator.admin",
159+
},
160+
);
161+
expect(response.status).toBe(404);
162+
expectErrorResponse(await response.json(), { type: "not_found" });
163+
expect(loadSessionEntryMock).toHaveBeenCalled();
164+
});
165+
149166
it.each(["/sessions/%zz/kill", "/sessions/%20/kill"])(
150167
"rejects invalid encoded session key %s without falling through",
151168
async (pathname) => {

src/gateway/session-kill-http.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ export async function handleSessionKillHttpRequest(
5656
},
5757
): Promise<boolean> {
5858
const cfg = getRuntimeConfig();
59-
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
59+
const url = new URL(req.url ?? "/", "http://localhost");
6060
const sessionKeyResolution = resolveSessionKeyFromPath(url.pathname);
6161
if (!sessionKeyResolution.matched) {
6262
return false;

src/gateway/sessions-history-http.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,22 @@ describe("session history HTTP endpoints", () => {
317317
});
318318
});
319319

320+
test("matches direct REST history paths without trusting malformed Host headers", async () => {
321+
await seedSession({ text: "history with bad host" });
322+
await withGatewayHarness(async (harness) => {
323+
const res = await fetchSessionHistory(harness.port, "agent:main:main", {
324+
headers: { Host: "[" },
325+
});
326+
expect(res.status).toBe(200);
327+
const body = (await res.json()) as {
328+
sessionKey?: string;
329+
messages?: Array<{ content?: Array<{ text?: string }> }>;
330+
};
331+
expect(body.sessionKey).toBe("agent:main:main");
332+
expect(body.messages?.[0]?.content?.[0]?.text).toBe("history with bad host");
333+
});
334+
});
335+
320336
test("returns 404 for unknown sessions", async () => {
321337
await createSessionStoreFile();
322338
await withGatewayHarness(async (harness) => {

src/gateway/sessions-history-http.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ const log = createSubsystemLogger("gateway/sessions-history-sse");
4343
const MAX_SESSION_HISTORY_LIMIT = 1000;
4444

4545
function resolveSessionHistoryPath(req: IncomingMessage): string | null {
46-
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
46+
const url = new URL(req.url ?? "/", "http://localhost");
4747
const match = url.pathname.match(/^\/sessions\/([^/]+)\/history$/);
4848
if (!match) {
4949
return null;
@@ -61,7 +61,7 @@ function shouldStreamSse(req: IncomingMessage): boolean {
6161
}
6262

6363
function getRequestUrl(req: IncomingMessage): URL {
64-
return new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
64+
return new URL(req.url ?? "/", "http://localhost");
6565
}
6666

6767
function resolveLimit(req: IncomingMessage): number | undefined {

0 commit comments

Comments
 (0)