Skip to content

Commit 1c331b9

Browse files
ZOOWHsteipete
andauthored
fix(clawrouter): sanitize attribution headers to ByteString (#106454)
* fix(clawrouter): sanitize bounded header ids Co-authored-by: ZOOWH <[email protected]> * fix(clawrouter): keep sanitized ids collision-resistant --------- Co-authored-by: Peter Steinberger <[email protected]>
1 parent 9a30b60 commit 1c331b9

2 files changed

Lines changed: 135 additions & 28 deletions

File tree

extensions/clawrouter/index.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,13 +171,92 @@ describe("ClawRouter plugin", () => {
171171
Authorization: "Bearer runtime-proxy-key",
172172
});
173173
expect(calls[0]?.headers?.["X-ClawRouter-Session-Id"]).toHaveLength(256);
174+
expect(calls[0]?.headers?.["X-ClawRouter-Session-Id"]).toMatch(/~[a-f0-9]{16}$/u);
174175
expect(calls[0]?.headers?.["X-Request-ID"]).toHaveLength(128);
175176
expect(calls[0]?.headers?.["X-Request-ID"]).toMatch(/~[a-f0-9]{16}:model:1$/u);
176177
expect(calls[1]?.headers?.["X-Request-ID"]).toMatch(/~[a-f0-9]{16}:model:2$/u);
177178
expect(calls[1]?.headers?.["X-Request-ID"]).not.toBe(calls[0]?.headers?.["X-Request-ID"]);
178179
expect(calls[2]?.headers?.["X-Request-ID"]).toMatch(/^turn-_~[a-f0-9]{16}:model:3$/u);
179180
});
180181

182+
it("sanitizes Unicode attribution to distinct printable ASCII ByteStrings", () => {
183+
const captured: Array<Record<string, string>> = [];
184+
const baseStreamFn: StreamFn = (model) => {
185+
captured.push(model.headers ?? {});
186+
return {} as ReturnType<StreamFn>;
187+
};
188+
189+
for (const [agentId, sessionId] of [
190+
["agent-😀中文-id", "session-🚀测试-id"],
191+
["agent-🚀中文-id", "session-😀测试-id"],
192+
] as const) {
193+
const wrapped = wrapClawRouterProviderStream({
194+
provider: "clawrouter",
195+
modelId: "openai/gpt-5.5",
196+
agentId,
197+
streamFn: baseStreamFn,
198+
} as never);
199+
200+
void wrapped?.(
201+
{
202+
provider: "clawrouter",
203+
api: "openai-responses",
204+
id: "openai/gpt-5.5",
205+
} as never,
206+
{} as never,
207+
{ sessionId } as never,
208+
);
209+
}
210+
211+
expect(captured).toHaveLength(2);
212+
for (const headers of captured) {
213+
expect(headers["X-ClawRouter-Agent-Id"]).toMatch(/^agent-___-id~[a-f0-9]{16}$/u);
214+
expect(headers["X-ClawRouter-Session-Id"]).toMatch(/^session-___-id~[a-f0-9]{16}$/u);
215+
expect(() => new Headers(headers)).not.toThrow();
216+
}
217+
expect(captured[0]?.["X-ClawRouter-Agent-Id"]).not.toBe(captured[1]?.["X-ClawRouter-Agent-Id"]);
218+
expect(captured[0]?.["X-ClawRouter-Session-Id"]).not.toBe(
219+
captured[1]?.["X-ClawRouter-Session-Id"],
220+
);
221+
});
222+
223+
it("keeps encoded and lone-surrogate attribution ids distinct", () => {
224+
const captured: string[] = [];
225+
const captureAgentId = (agentId: string) => {
226+
const wrapped = wrapClawRouterProviderStream({
227+
provider: "clawrouter",
228+
modelId: "openai/gpt-5.5",
229+
agentId,
230+
streamFn: ((model) => {
231+
captured.push(model.headers?.["X-ClawRouter-Agent-Id"] ?? "");
232+
return {} as ReturnType<StreamFn>;
233+
}) satisfies StreamFn,
234+
} as never);
235+
void wrapped?.(
236+
{
237+
provider: "clawrouter",
238+
api: "openai-responses",
239+
id: "openai/gpt-5.5",
240+
} as never,
241+
{} as never,
242+
{} as never,
243+
);
244+
};
245+
246+
captureAgentId("agent-😀");
247+
captureAgentId(captured[0]!);
248+
captureAgentId("agent-\uD800");
249+
captureAgentId("agent-\uD801");
250+
251+
expect(captured).toHaveLength(4);
252+
expect(captured[1]).not.toBe(captured[0]);
253+
expect(captured[2]).not.toBe(captured[3]);
254+
for (const value of captured) {
255+
expect(value).toMatch(/^[\x20-\x7E]+$/u);
256+
expect(() => new Headers({ "X-ClawRouter-Agent-Id": value })).not.toThrow();
257+
}
258+
});
259+
181260
it("keeps an explicit per-request header ahead of the automatic model-call id", () => {
182261
const calls: Array<{
183262
model: Parameters<StreamFn>[0];

extensions/clawrouter/stream.ts

Lines changed: 56 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Buffer } from "node:buffer";
12
import { createHash } from "node:crypto";
23
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
34
import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry";
@@ -10,9 +11,36 @@ const CLIENT_HEADER = "X-ClawRouter-Client";
1011
const AGENT_HEADER = "X-ClawRouter-Agent-Id";
1112
const SESSION_HEADER = "X-ClawRouter-Session-Id";
1213
const REQUEST_ID_HEADER = "X-Request-ID";
13-
const REQUEST_ID_HASH_LENGTH = 16;
14+
const ID_HASH_LENGTH = 16;
1415
const REQUEST_ID_PATTERN = /^[A-Za-z0-9._~:/+@=-]+$/u;
1516
const REQUEST_ID_UNSAFE_CHARACTER_PATTERN = /[^A-Za-z0-9._~:/+@=-]/gu;
17+
const ATTRIBUTION_PRINTABLE_ASCII_PATTERN = /^[\x20-\x7E]+$/u;
18+
const ATTRIBUTION_NON_PRINTABLE_ASCII_PATTERN = /[^\x20-\x7E]/gu;
19+
const ATTRIBUTION_ENCODED_SUFFIX_PATTERN = /~[a-f0-9]{16}$/u;
20+
const REQUEST_ID_SUFFIX_PATTERN = /:model:\d+$/u;
21+
const REQUEST_ID_ENCODED_SUFFIX_PATTERN = /~[a-f0-9]{16}(?::model:\d+)?$/u;
22+
23+
type BoundedIdPolicy = {
24+
encodedSuffixPattern: RegExp;
25+
maxLength: number;
26+
safePattern: RegExp;
27+
unsafeCharacterPattern: RegExp;
28+
preservedSuffixPattern?: RegExp;
29+
};
30+
31+
const ATTRIBUTION_ID_POLICY: BoundedIdPolicy = {
32+
encodedSuffixPattern: ATTRIBUTION_ENCODED_SUFFIX_PATTERN,
33+
maxLength: ATTRIBUTION_VALUE_MAX_LENGTH,
34+
safePattern: ATTRIBUTION_PRINTABLE_ASCII_PATTERN,
35+
unsafeCharacterPattern: ATTRIBUTION_NON_PRINTABLE_ASCII_PATTERN,
36+
};
37+
const REQUEST_ID_POLICY: BoundedIdPolicy = {
38+
encodedSuffixPattern: REQUEST_ID_ENCODED_SUFFIX_PATTERN,
39+
maxLength: REQUEST_ID_MAX_LENGTH,
40+
safePattern: REQUEST_ID_PATTERN,
41+
unsafeCharacterPattern: REQUEST_ID_UNSAFE_CHARACTER_PATTERN,
42+
preservedSuffixPattern: REQUEST_ID_SUFFIX_PATTERN,
43+
};
1644

1745
function hasControlCharacter(value: string): boolean {
1846
for (let index = 0; index < value.length; index += 1) {
@@ -24,44 +52,40 @@ function hasControlCharacter(value: string): boolean {
2452
return false;
2553
}
2654

27-
function normalizeAttributionValue(value: string | undefined): string | undefined {
55+
function normalizeHeaderId(value: string | undefined): string | undefined {
2856
const normalized = value?.trim();
2957
if (!normalized || hasControlCharacter(normalized)) {
3058
return undefined;
3159
}
3260
return normalized;
3361
}
3462

35-
function sanitizeAttributionValue(value: string | undefined): string | undefined {
36-
const normalized = normalizeAttributionValue(value);
63+
function sanitizeBoundedId(value: string | undefined, policy: BoundedIdPolicy): string | undefined {
64+
const normalized = normalizeHeaderId(value);
3765
if (!normalized) {
3866
return undefined;
3967
}
40-
return normalized.slice(0, ATTRIBUTION_VALUE_MAX_LENGTH);
41-
}
42-
43-
function sanitizeRequestId(value: string | undefined): string | undefined {
44-
const normalized = normalizeAttributionValue(value);
45-
if (!normalized) {
46-
return normalized;
47-
}
48-
if (normalized.length <= REQUEST_ID_MAX_LENGTH && REQUEST_ID_PATTERN.test(normalized)) {
68+
if (
69+
normalized.length <= policy.maxLength &&
70+
policy.safePattern.test(normalized) &&
71+
!policy.encodedSuffixPattern.test(normalized)
72+
) {
4973
return normalized;
5074
}
51-
// Retain the per-call suffix while bounding long run ids; the hash keeps
52-
// distinct long prefixes from collapsing onto the same audit identifier.
75+
// Hash UTF-16 code units losslessly: UTF-8 string hashing replaces lone
76+
// surrogates with U+FFFD. The reserved encoded suffix keeps a rewritten ID
77+
// from aliasing an otherwise safe input that already looks encoded.
5378
const hash = createHash("sha256")
54-
.update(normalized)
79+
.update(Buffer.from(normalized, "utf16le"))
5580
.digest("hex")
56-
.slice(0, REQUEST_ID_HASH_LENGTH);
57-
const modelSuffix = normalized.match(/:model:\d+$/u)?.[0] ?? "";
58-
const rawPrefix = modelSuffix ? normalized.slice(0, -modelSuffix.length) : normalized;
59-
const safePrefix = rawPrefix.replace(REQUEST_ID_UNSAFE_CHARACTER_PATTERN, "_");
60-
const boundedSuffix = `~${hash}${modelSuffix}`;
61-
if (boundedSuffix.length >= REQUEST_ID_MAX_LENGTH) {
62-
return `${safePrefix.slice(0, REQUEST_ID_MAX_LENGTH - hash.length - 1)}~${hash}`;
63-
}
64-
return `${safePrefix.slice(0, REQUEST_ID_MAX_LENGTH - boundedSuffix.length)}${boundedSuffix}`;
81+
.slice(0, ID_HASH_LENGTH);
82+
const preservedSuffix = policy.preservedSuffixPattern?.exec(normalized)?.[0] ?? "";
83+
const rawPrefix = preservedSuffix ? normalized.slice(0, -preservedSuffix.length) : normalized;
84+
const safePrefix = rawPrefix.replace(policy.unsafeCharacterPattern, "_");
85+
const hashSuffix = `~${hash}`;
86+
const boundedSuffix = `${hashSuffix}${preservedSuffix}`;
87+
const suffix = boundedSuffix.length < policy.maxLength ? boundedSuffix : hashSuffix;
88+
return `${safePrefix.slice(0, policy.maxLength - suffix.length)}${suffix}`;
6589
}
6690

6791
function findHeader(headers: Record<string, string>, target: string): string | undefined {
@@ -95,9 +119,13 @@ function withClawRouterHeaders(
95119
}
96120
}
97121
setHeaderDefault(next, CLIENT_HEADER, "openclaw");
98-
setHeaderDefault(next, AGENT_HEADER, sanitizeAttributionValue(params.agentId));
99-
setHeaderDefault(next, SESSION_HEADER, sanitizeAttributionValue(params.sessionId));
100-
setHeaderDefault(next, REQUEST_ID_HEADER, sanitizeRequestId(params.requestId));
122+
setHeaderDefault(next, AGENT_HEADER, sanitizeBoundedId(params.agentId, ATTRIBUTION_ID_POLICY));
123+
setHeaderDefault(
124+
next,
125+
SESSION_HEADER,
126+
sanitizeBoundedId(params.sessionId, ATTRIBUTION_ID_POLICY),
127+
);
128+
setHeaderDefault(next, REQUEST_ID_HEADER, sanitizeBoundedId(params.requestId, REQUEST_ID_POLICY));
101129
if (params.apiKey) {
102130
next.Authorization = `Bearer ${params.apiKey}`;
103131
}

0 commit comments

Comments
 (0)