Skip to content

Commit 868a0c4

Browse files
committed
fix(gateway): block internal HTTP session overrides
1 parent bed0fb7 commit 868a0c4

6 files changed

Lines changed: 154 additions & 17 deletions

File tree

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

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,48 @@ describe("resolveGatewayRequestContext", () => {
5151

5252
expect(result.sessionKey).toContain("openresponses-user:alice");
5353
});
54+
55+
it("rejects explicit session keys that target internal session namespaces", () => {
56+
const cases = [
57+
{
58+
sessionKey: "subagent:worker",
59+
expectedPrefix: "subagent:",
60+
},
61+
{
62+
sessionKey: "agent:main:subagent:worker",
63+
expectedPrefix: "subagent:",
64+
},
65+
{
66+
sessionKey: "acp:session",
67+
expectedPrefix: "acp:",
68+
},
69+
{
70+
sessionKey: "agent:main:acp:session",
71+
expectedPrefix: "acp:",
72+
},
73+
{
74+
sessionKey: "cron:daily",
75+
expectedPrefix: "cron:",
76+
},
77+
{
78+
sessionKey: "agent:main:cron:daily",
79+
expectedPrefix: "cron:",
80+
},
81+
];
82+
83+
for (const testCase of cases) {
84+
expect(() =>
85+
resolveGatewayRequestContext({
86+
req: createReq({ "x-openclaw-session-key": testCase.sessionKey }),
87+
model: "openclaw",
88+
sessionPrefix: "openai",
89+
defaultMessageChannel: "webchat",
90+
}),
91+
).toThrow(
92+
`x-openclaw-session-key may not target internal session namespace ${testCase.expectedPrefix}`,
93+
);
94+
}
95+
});
5496
});
5597

5698
describe("resolveTrustedHttpOperatorScopes", () => {

src/gateway/http-utils.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ import { modelKey, parseModelRef, resolveDefaultModelForAgent } from "../agents/
99
import { createModelVisibilityPolicy } from "../agents/model-visibility-policy.js";
1010
import { getRuntimeConfig } from "../config/io.js";
1111
import { loadManifestMetadataSnapshot } from "../plugins/manifest-contract-eligibility.js";
12-
import { buildAgentMainSessionKey, normalizeAgentId } from "../routing/session-key.js";
12+
import {
13+
buildAgentMainSessionKey,
14+
normalizeAgentId,
15+
parseAgentSessionKey,
16+
} from "../routing/session-key.js";
1317
import { normalizeMessageChannel } from "../utils/message-channel.js";
1418
import { getHeader } from "./http-auth-utils.js";
1519
import { loadGatewayModelCatalog } from "./server-model-catalog.js";
@@ -33,6 +37,14 @@ export {
3337

3438
export const OPENCLAW_MODEL_ID = "openclaw";
3539
export const OPENCLAW_DEFAULT_MODEL_ID = "openclaw/default";
40+
const RESERVED_SESSION_KEY_PREFIXES = ["subagent:", "acp:", "cron:"] as const;
41+
42+
export class GatewaySessionKeyOverrideError extends Error {
43+
constructor(message: string) {
44+
super(message);
45+
this.name = "GatewaySessionKeyOverrideError";
46+
}
47+
}
3648

3749
function resolveAgentIdFromHeader(req: IncomingMessage): string | undefined {
3850
const raw =
@@ -146,6 +158,12 @@ function resolveSessionKey(params: {
146158
}): string {
147159
const explicit = getHeader(params.req, "x-openclaw-session-key")?.trim();
148160
if (explicit) {
161+
const reservedPrefix = resolveReservedSessionKeyPrefix(explicit);
162+
if (reservedPrefix) {
163+
throw new GatewaySessionKeyOverrideError(
164+
`x-openclaw-session-key may not target internal session namespace ${reservedPrefix}`,
165+
);
166+
}
149167
return explicit;
150168
}
151169

@@ -154,6 +172,23 @@ function resolveSessionKey(params: {
154172
return buildAgentMainSessionKey({ agentId: params.agentId, mainKey });
155173
}
156174

175+
function resolveReservedSessionKeyPrefix(sessionKey: string): string | undefined {
176+
const normalized = normalizeReservedSessionKeyCandidate(sessionKey);
177+
if (!normalized) {
178+
return undefined;
179+
}
180+
return RESERVED_SESSION_KEY_PREFIXES.find((prefix) => normalized.startsWith(prefix));
181+
}
182+
183+
function normalizeReservedSessionKeyCandidate(sessionKey: string): string | undefined {
184+
const trimmed = sessionKey.trim();
185+
if (!trimmed) {
186+
return undefined;
187+
}
188+
const normalized = parseAgentSessionKey(trimmed)?.rest ?? trimmed;
189+
return normalized.trim().toLowerCase() || undefined;
190+
}
191+
157192
export function resolveGatewayRequestContext(params: {
158193
req: IncomingMessage;
159194
model: string | undefined;

src/gateway/openai-http.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,20 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
247247
await res.text();
248248
}
249249

250+
{
251+
agentCommand.mockClear();
252+
const res = await postChatCompletions(
253+
port,
254+
{ model: "openclaw", messages: [{ role: "user", content: "hi" }] },
255+
{ "x-openclaw-session-key": "agent:main:subagent:worker" },
256+
);
257+
expect(res.status).toBe(400);
258+
const json = (await res.json()) as { error?: { type?: string; message?: string } };
259+
expect(json.error?.type).toBe("invalid_request_error");
260+
expect(json.error?.message).toContain("internal session namespace subagent:");
261+
expect(agentCommand).toHaveBeenCalledTimes(0);
262+
}
263+
250264
{
251265
mockAgentOnce([{ text: "hello" }]);
252266
const res = await postChatCompletions(port, {

src/gateway/openai-http.ts

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import type { ResolvedGatewayAuth } from "./auth.js";
4343
import { sendJson, setSseHeaders, watchClientDisconnect, writeDone } from "./http-common.js";
4444
import { handleGatewayPostJsonEndpoint } from "./http-endpoint-helpers.js";
4545
import {
46+
GatewaySessionKeyOverrideError,
4647
resolveGatewayRequestContext,
4748
resolveOpenAiCompatModelOverride,
4849
resolveOpenAiCompatibleHttpOperatorScopes,
@@ -960,14 +961,30 @@ export async function handleOpenAiHttpRequest(
960961
}
961962
: undefined;
962963

963-
const { agentId, sessionKey, messageChannel } = resolveGatewayRequestContext({
964-
req,
965-
model,
966-
user,
967-
sessionPrefix: "openai",
968-
defaultMessageChannel: "webchat",
969-
useMessageChannelHeader: true,
970-
});
964+
let agentId: string;
965+
let sessionKey: string;
966+
let messageChannel: string;
967+
try {
968+
({ agentId, sessionKey, messageChannel } = resolveGatewayRequestContext({
969+
req,
970+
model,
971+
user,
972+
sessionPrefix: "openai",
973+
defaultMessageChannel: "webchat",
974+
useMessageChannelHeader: true,
975+
}));
976+
} catch (err) {
977+
if (!(err instanceof GatewaySessionKeyOverrideError)) {
978+
throw err;
979+
}
980+
sendJson(res, 400, {
981+
error: {
982+
message: err.message,
983+
type: "invalid_request_error",
984+
},
985+
});
986+
return true;
987+
}
971988
const { modelOverride, errorMessage: modelError } = await resolveOpenAiCompatModelOverride({
972989
req,
973990
agentId,

src/gateway/openresponses-http.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,20 @@ describe("OpenResponses HTTP API (e2e)", () => {
313313
);
314314
await ensureResponseConsumed(resHeader);
315315

316+
agentCommand.mockClear();
317+
const resInternalSession = await postResponses(
318+
port,
319+
{ model: "openclaw", input: "hi" },
320+
{ "x-openclaw-session-key": "agent:main:subagent:worker" },
321+
);
322+
expect(resInternalSession.status).toBe(400);
323+
const internalSessionJson = (await resInternalSession.json()) as {
324+
error?: { type?: string; message?: string };
325+
};
326+
expect(internalSessionJson.error?.type).toBe("invalid_request_error");
327+
expect(internalSessionJson.error?.message).toContain("internal session namespace subagent:");
328+
expect(agentCommand).toHaveBeenCalledTimes(0);
329+
316330
mockAgentOnce([{ text: "hello" }]);
317331
const resModel = await postResponses(port, { model: "openclaw/beta", input: "hi" });
318332
expect(resModel.status).toBe(200);

src/gateway/openresponses-http.ts

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import type { ResolvedGatewayAuth } from "./auth.js";
3939
import { sendJson, setSseHeaders, watchClientDisconnect, writeDone } from "./http-common.js";
4040
import { handleGatewayPostJsonEndpoint } from "./http-endpoint-helpers.js";
4141
import {
42+
GatewaySessionKeyOverrideError,
4243
getBearerToken,
4344
getHeader,
4445
resolveAgentIdForRequest,
@@ -624,14 +625,28 @@ export async function handleOpenResponsesHttpRequest(
624625
});
625626
return true;
626627
}
627-
const resolved = resolveGatewayRequestContext({
628-
req,
629-
model,
630-
user,
631-
sessionPrefix: "openresponses",
632-
defaultMessageChannel: "webchat",
633-
useMessageChannelHeader: true,
634-
});
628+
let resolved: ReturnType<typeof resolveGatewayRequestContext>;
629+
try {
630+
resolved = resolveGatewayRequestContext({
631+
req,
632+
model,
633+
user,
634+
sessionPrefix: "openresponses",
635+
defaultMessageChannel: "webchat",
636+
useMessageChannelHeader: true,
637+
});
638+
} catch (err) {
639+
if (!(err instanceof GatewaySessionKeyOverrideError)) {
640+
throw err;
641+
}
642+
sendJson(res, 400, {
643+
error: {
644+
message: err.message,
645+
type: "invalid_request_error",
646+
},
647+
});
648+
return true;
649+
}
635650
const responseSessionScope = createResponseSessionScope({
636651
req,
637652
auth: opts.auth,

0 commit comments

Comments
 (0)