Skip to content

Commit 8694fe7

Browse files
fix(gateway): block internal HTTP session overrides
Reject HTTP session-key overrides that target reserved internal session namespaces while preserving normal explicit session keys. Co-authored-by: RichardCao <[email protected]>
1 parent 073343e commit 8694fe7

7 files changed

Lines changed: 114 additions & 4 deletions

File tree

docs/gateway/openai-http-api.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ Optional request headers:
9999

100100
- `x-openclaw-model: <provider/model-or-bare-id>` overrides the backend model for the selected agent. Shared-secret bearer callers can use this header. Identity-bearing callers, such as trusted-proxy or private no-auth ingress requests with `x-openclaw-scopes`, need `operator.admin`; write-only callers get `403 missing scope: operator.admin`.
101101
- `x-openclaw-agent-id: <agentId>` remains supported as a compatibility override.
102-
- `x-openclaw-session-key: <sessionKey>` fully controls session routing.
102+
- `x-openclaw-session-key: <sessionKey>` explicitly controls session routing. The value must not use reserved internal session namespaces such as `subagent:`, `cron:`, or `acp:`; those requests are rejected with `400 invalid_request_error`.
103103
- `x-openclaw-message-channel: <channel>` sets the synthetic ingress channel context for channel-aware prompts and policies.
104104

105105
Compatibility aliases still accepted:
@@ -145,7 +145,7 @@ By default the endpoint is **stateless per request** (a new session key is gener
145145

146146
If the request includes an OpenAI `user` string, the Gateway derives a stable session key from it, so repeated calls can share an agent session.
147147

148-
For custom apps, the safest default is to reuse the same `user` value per conversation thread. Avoid account-level identifiers unless you explicitly want multiple conversations or devices to share one OpenClaw session. Use `x-openclaw-session-key` when you need explicit routing control across multiple clients or threads.
148+
For custom apps, the safest default is to reuse the same `user` value per conversation thread. Avoid account-level identifiers unless you explicitly want multiple conversations or devices to share one OpenClaw session. Use `x-openclaw-session-key` only when you need explicit routing control across multiple clients or threads, and choose application-owned keys that do not start with reserved internal namespaces such as `subagent:`, `cron:`, or `acp:`.
149149

150150
## Why this surface matters
151151

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { IncomingMessage } from "node:http";
55
import { describe, expect, it } from "vitest";
66
import {
77
authorizeOpenAiCompatibleHttpModelOverride,
8+
GatewaySessionKeyOverrideError,
89
resolveOpenAiCompatibleHttpOperatorScopes,
910
resolveOpenAiCompatibleHttpSenderIsOwner,
1011
resolveGatewayRequestContext,
@@ -56,6 +57,35 @@ describe("resolveGatewayRequestContext", () => {
5657
expect(result.sessionKey).toContain("openresponses-user:alice");
5758
});
5859

60+
it("preserves normal explicit session-key overrides", () => {
61+
const result = resolveGatewayRequestContext({
62+
req: createReq({ "x-openclaw-session-key": "customer-case-42" }),
63+
model: "openclaw",
64+
sessionPrefix: "openai",
65+
defaultMessageChannel: "webchat",
66+
});
67+
68+
expect(result.sessionKey).toBe("customer-case-42");
69+
});
70+
71+
it.each([
72+
"subagent:worker",
73+
"cron:daily",
74+
"acp:run-1",
75+
"agent:main:subagent:worker",
76+
"agent:main:cron:daily",
77+
"agent:main:acp:run-1",
78+
])("rejects reserved internal session-key override %s", (sessionKey) => {
79+
expect(() =>
80+
resolveGatewayRequestContext({
81+
req: createReq({ "x-openclaw-session-key": sessionKey }),
82+
model: "openclaw",
83+
sessionPrefix: "openai",
84+
defaultMessageChannel: "webchat",
85+
}),
86+
).toThrow(GatewaySessionKeyOverrideError);
87+
});
88+
5989
it("does not build session state for explicit unknown agent ids", () => {
6090
expect(() =>
6191
resolveGatewayRequestContext({

src/gateway/http-utils.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ import { getRuntimeConfig } from "../config/io.js";
1313
import { loadManifestMetadataSnapshot } from "../plugins/manifest-contract-eligibility.js";
1414
import {
1515
buildAgentMainSessionKey,
16+
isAcpSessionKey,
17+
isCronSessionKey,
18+
isSubagentSessionKey,
1619
isValidAgentId,
1720
normalizeAgentId,
1821
} from "../routing/session-key.js";
@@ -49,10 +52,23 @@ export class UnknownGatewayAgentError extends Error {
4952
}
5053
}
5154

55+
export class GatewaySessionKeyOverrideError extends Error {
56+
constructor() {
57+
super("`x-openclaw-session-key` cannot use reserved internal session namespaces.");
58+
this.name = "GatewaySessionKeyOverrideError";
59+
}
60+
}
61+
5262
export function isUnknownGatewayAgentError(err: unknown): err is UnknownGatewayAgentError {
5363
return err instanceof UnknownGatewayAgentError;
5464
}
5565

66+
export function isGatewaySessionKeyOverrideError(
67+
err: unknown,
68+
): err is GatewaySessionKeyOverrideError {
69+
return err instanceof GatewaySessionKeyOverrideError;
70+
}
71+
5672
function assertKnownAgentId(agentId: string, cfg = getRuntimeConfig()): void {
5773
if (!listAgentIds(cfg).includes(agentId)) {
5874
throw new UnknownGatewayAgentError(agentId);
@@ -185,6 +201,9 @@ function resolveSessionKey(params: {
185201
}): string {
186202
const explicit = getHeader(params.req, "x-openclaw-session-key")?.trim();
187203
if (explicit) {
204+
if (isReservedSessionKeyOverride(explicit)) {
205+
throw new GatewaySessionKeyOverrideError();
206+
}
188207
return explicit;
189208
}
190209

@@ -193,6 +212,18 @@ function resolveSessionKey(params: {
193212
return buildAgentMainSessionKey({ agentId: params.agentId, mainKey });
194213
}
195214

215+
function isReservedSessionKeyOverride(sessionKey: string): boolean {
216+
const lowered = normalizeLowercaseStringOrEmpty(sessionKey);
217+
return (
218+
lowered.startsWith("subagent:") ||
219+
lowered.startsWith("cron:") ||
220+
lowered.startsWith("acp:") ||
221+
isSubagentSessionKey(sessionKey) ||
222+
isCronSessionKey(sessionKey) ||
223+
isAcpSessionKey(sessionKey)
224+
);
225+
}
226+
196227
/** Resolves gateway agent/session/channel context for OpenAI-compatible handlers. */
197228
export function resolveGatewayRequestContext(params: {
198229
req: IncomingMessage;

src/gateway/openai-http.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,22 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
280280
await res.text();
281281
}
282282

283+
{
284+
agentCommand.mockClear();
285+
const res = await postChatCompletions(
286+
port,
287+
{ model: "openclaw", messages: [{ role: "user", content: "hi" }] },
288+
{ "x-openclaw-session-key": "agent:main:subagent:spoofed" },
289+
);
290+
expect(res.status).toBe(400);
291+
const json = (await res.json()) as { error?: { type?: string; message?: string } };
292+
expect(json.error?.type).toBe("invalid_request_error");
293+
expect(json.error?.message).toBe(
294+
"`x-openclaw-session-key` cannot use reserved internal session namespaces.",
295+
);
296+
expect(agentCommand).toHaveBeenCalledTimes(0);
297+
}
298+
283299
{
284300
mockAgentOnce([{ text: "hello" }]);
285301
const res = await postChatCompletions(port, {

src/gateway/openai-http.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import {
5353
import { handleGatewayPostJsonEndpoint } from "./http-endpoint-helpers.js";
5454
import {
5555
authorizeOpenAiCompatibleHttpModelOverride,
56+
isGatewaySessionKeyOverrideError,
5657
isUnknownGatewayAgentError,
5758
resolveGatewayRequestContext,
5859
resolveOpenAiCompatModelOverride,
@@ -988,7 +989,7 @@ export async function handleOpenAiHttpRequest(
988989
useMessageChannelHeader: true,
989990
}));
990991
} catch (err) {
991-
if (isUnknownGatewayAgentError(err)) {
992+
if (isUnknownGatewayAgentError(err) || isGatewaySessionKeyOverrideError(err)) {
992993
sendJson(res, 400, {
993994
error: { message: err.message, type: "invalid_request_error" },
994995
});

src/gateway/openresponses-http.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,37 @@ describe("OpenResponses HTTP API (e2e)", () => {
321321
);
322322
await ensureResponseConsumed(resHeader);
323323

324+
mockAgentOnce([{ text: "hello" }]);
325+
const resSessionOverride = await postResponses(
326+
port,
327+
{ model: "openclaw", input: "hi" },
328+
{
329+
"x-openclaw-agent-id": "beta",
330+
"x-openclaw-session-key": "agent:beta:openresponses:custom",
331+
},
332+
);
333+
expect(resSessionOverride.status).toBe(200);
334+
expect((firstAgentOpts() as { sessionKey?: string }).sessionKey).toBe(
335+
"agent:beta:openresponses:custom",
336+
);
337+
await ensureResponseConsumed(resSessionOverride);
338+
339+
agentCommand.mockClear();
340+
const resReservedSessionOverride = await postResponses(
341+
port,
342+
{ model: "openclaw", input: "hi" },
343+
{ "x-openclaw-session-key": "agent:main:subagent:spoofed" },
344+
);
345+
expect(resReservedSessionOverride.status).toBe(400);
346+
const reservedSessionJson = (await resReservedSessionOverride.json()) as {
347+
error?: { type?: string; message?: string };
348+
};
349+
expect(reservedSessionJson.error?.type).toBe("invalid_request_error");
350+
expect(reservedSessionJson.error?.message).toBe(
351+
"`x-openclaw-session-key` cannot use reserved internal session namespaces.",
352+
);
353+
expect(agentCommand).toHaveBeenCalledTimes(0);
354+
324355
mockAgentOnce([{ text: "hello" }]);
325356
const resModel = await postResponses(port, { model: "openclaw/beta", input: "hi" });
326357
expect(resModel.status).toBe(200);

src/gateway/openresponses-http.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import {
4848
authorizeOpenAiCompatibleHttpModelOverride,
4949
getBearerToken,
5050
getHeader,
51+
isGatewaySessionKeyOverrideError,
5152
isUnknownGatewayAgentError,
5253
resolveAgentIdForRequest,
5354
resolveGatewayRequestContext,
@@ -667,7 +668,7 @@ export async function handleOpenResponsesHttpRequest(
667668
useMessageChannelHeader: true,
668669
});
669670
} catch (err) {
670-
if (isUnknownGatewayAgentError(err)) {
671+
if (isUnknownGatewayAgentError(err) || isGatewaySessionKeyOverrideError(err)) {
671672
sendJson(res, 400, {
672673
error: { message: err.message, type: "invalid_request_error" },
673674
});

0 commit comments

Comments
 (0)