mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 02:06:43 +00:00
fix(clawrouter): sanitize attribution headers to ByteString (#106454)
* fix(clawrouter): sanitize bounded header ids Co-authored-by: ZOOWH <xu.wenhan1@xydigit.com> * fix(clawrouter): keep sanitized ids collision-resistant --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
co-authored by
Peter Steinberger
parent
9a30b607c5
commit
1c331b9b0f
@@ -171,6 +171,7 @@ describe("ClawRouter plugin", () => {
|
||||
Authorization: "Bearer runtime-proxy-key",
|
||||
});
|
||||
expect(calls[0]?.headers?.["X-ClawRouter-Session-Id"]).toHaveLength(256);
|
||||
expect(calls[0]?.headers?.["X-ClawRouter-Session-Id"]).toMatch(/~[a-f0-9]{16}$/u);
|
||||
expect(calls[0]?.headers?.["X-Request-ID"]).toHaveLength(128);
|
||||
expect(calls[0]?.headers?.["X-Request-ID"]).toMatch(/~[a-f0-9]{16}:model:1$/u);
|
||||
expect(calls[1]?.headers?.["X-Request-ID"]).toMatch(/~[a-f0-9]{16}:model:2$/u);
|
||||
@@ -178,6 +179,84 @@ describe("ClawRouter plugin", () => {
|
||||
expect(calls[2]?.headers?.["X-Request-ID"]).toMatch(/^turn-_~[a-f0-9]{16}:model:3$/u);
|
||||
});
|
||||
|
||||
it("sanitizes Unicode attribution to distinct printable ASCII ByteStrings", () => {
|
||||
const captured: Array<Record<string, string>> = [];
|
||||
const baseStreamFn: StreamFn = (model) => {
|
||||
captured.push(model.headers ?? {});
|
||||
return {} as ReturnType<StreamFn>;
|
||||
};
|
||||
|
||||
for (const [agentId, sessionId] of [
|
||||
["agent-😀中文-id", "session-🚀测试-id"],
|
||||
["agent-🚀中文-id", "session-😀测试-id"],
|
||||
] as const) {
|
||||
const wrapped = wrapClawRouterProviderStream({
|
||||
provider: "clawrouter",
|
||||
modelId: "openai/gpt-5.5",
|
||||
agentId,
|
||||
streamFn: baseStreamFn,
|
||||
} as never);
|
||||
|
||||
void wrapped?.(
|
||||
{
|
||||
provider: "clawrouter",
|
||||
api: "openai-responses",
|
||||
id: "openai/gpt-5.5",
|
||||
} as never,
|
||||
{} as never,
|
||||
{ sessionId } as never,
|
||||
);
|
||||
}
|
||||
|
||||
expect(captured).toHaveLength(2);
|
||||
for (const headers of captured) {
|
||||
expect(headers["X-ClawRouter-Agent-Id"]).toMatch(/^agent-___-id~[a-f0-9]{16}$/u);
|
||||
expect(headers["X-ClawRouter-Session-Id"]).toMatch(/^session-___-id~[a-f0-9]{16}$/u);
|
||||
expect(() => new Headers(headers)).not.toThrow();
|
||||
}
|
||||
expect(captured[0]?.["X-ClawRouter-Agent-Id"]).not.toBe(captured[1]?.["X-ClawRouter-Agent-Id"]);
|
||||
expect(captured[0]?.["X-ClawRouter-Session-Id"]).not.toBe(
|
||||
captured[1]?.["X-ClawRouter-Session-Id"],
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps encoded and lone-surrogate attribution ids distinct", () => {
|
||||
const captured: string[] = [];
|
||||
const captureAgentId = (agentId: string) => {
|
||||
const wrapped = wrapClawRouterProviderStream({
|
||||
provider: "clawrouter",
|
||||
modelId: "openai/gpt-5.5",
|
||||
agentId,
|
||||
streamFn: ((model) => {
|
||||
captured.push(model.headers?.["X-ClawRouter-Agent-Id"] ?? "");
|
||||
return {} as ReturnType<StreamFn>;
|
||||
}) satisfies StreamFn,
|
||||
} as never);
|
||||
void wrapped?.(
|
||||
{
|
||||
provider: "clawrouter",
|
||||
api: "openai-responses",
|
||||
id: "openai/gpt-5.5",
|
||||
} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
};
|
||||
|
||||
captureAgentId("agent-😀");
|
||||
captureAgentId(captured[0]!);
|
||||
captureAgentId("agent-\uD800");
|
||||
captureAgentId("agent-\uD801");
|
||||
|
||||
expect(captured).toHaveLength(4);
|
||||
expect(captured[1]).not.toBe(captured[0]);
|
||||
expect(captured[2]).not.toBe(captured[3]);
|
||||
for (const value of captured) {
|
||||
expect(value).toMatch(/^[\x20-\x7E]+$/u);
|
||||
expect(() => new Headers({ "X-ClawRouter-Agent-Id": value })).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps an explicit per-request header ahead of the automatic model-call id", () => {
|
||||
const calls: Array<{
|
||||
model: Parameters<StreamFn>[0];
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
import { createHash } from "node:crypto";
|
||||
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
|
||||
import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry";
|
||||
@@ -10,9 +11,36 @@ const CLIENT_HEADER = "X-ClawRouter-Client";
|
||||
const AGENT_HEADER = "X-ClawRouter-Agent-Id";
|
||||
const SESSION_HEADER = "X-ClawRouter-Session-Id";
|
||||
const REQUEST_ID_HEADER = "X-Request-ID";
|
||||
const REQUEST_ID_HASH_LENGTH = 16;
|
||||
const ID_HASH_LENGTH = 16;
|
||||
const REQUEST_ID_PATTERN = /^[A-Za-z0-9._~:/+@=-]+$/u;
|
||||
const REQUEST_ID_UNSAFE_CHARACTER_PATTERN = /[^A-Za-z0-9._~:/+@=-]/gu;
|
||||
const ATTRIBUTION_PRINTABLE_ASCII_PATTERN = /^[\x20-\x7E]+$/u;
|
||||
const ATTRIBUTION_NON_PRINTABLE_ASCII_PATTERN = /[^\x20-\x7E]/gu;
|
||||
const ATTRIBUTION_ENCODED_SUFFIX_PATTERN = /~[a-f0-9]{16}$/u;
|
||||
const REQUEST_ID_SUFFIX_PATTERN = /:model:\d+$/u;
|
||||
const REQUEST_ID_ENCODED_SUFFIX_PATTERN = /~[a-f0-9]{16}(?::model:\d+)?$/u;
|
||||
|
||||
type BoundedIdPolicy = {
|
||||
encodedSuffixPattern: RegExp;
|
||||
maxLength: number;
|
||||
safePattern: RegExp;
|
||||
unsafeCharacterPattern: RegExp;
|
||||
preservedSuffixPattern?: RegExp;
|
||||
};
|
||||
|
||||
const ATTRIBUTION_ID_POLICY: BoundedIdPolicy = {
|
||||
encodedSuffixPattern: ATTRIBUTION_ENCODED_SUFFIX_PATTERN,
|
||||
maxLength: ATTRIBUTION_VALUE_MAX_LENGTH,
|
||||
safePattern: ATTRIBUTION_PRINTABLE_ASCII_PATTERN,
|
||||
unsafeCharacterPattern: ATTRIBUTION_NON_PRINTABLE_ASCII_PATTERN,
|
||||
};
|
||||
const REQUEST_ID_POLICY: BoundedIdPolicy = {
|
||||
encodedSuffixPattern: REQUEST_ID_ENCODED_SUFFIX_PATTERN,
|
||||
maxLength: REQUEST_ID_MAX_LENGTH,
|
||||
safePattern: REQUEST_ID_PATTERN,
|
||||
unsafeCharacterPattern: REQUEST_ID_UNSAFE_CHARACTER_PATTERN,
|
||||
preservedSuffixPattern: REQUEST_ID_SUFFIX_PATTERN,
|
||||
};
|
||||
|
||||
function hasControlCharacter(value: string): boolean {
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
@@ -24,7 +52,7 @@ function hasControlCharacter(value: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function normalizeAttributionValue(value: string | undefined): string | undefined {
|
||||
function normalizeHeaderId(value: string | undefined): string | undefined {
|
||||
const normalized = value?.trim();
|
||||
if (!normalized || hasControlCharacter(normalized)) {
|
||||
return undefined;
|
||||
@@ -32,36 +60,32 @@ function normalizeAttributionValue(value: string | undefined): string | undefine
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function sanitizeAttributionValue(value: string | undefined): string | undefined {
|
||||
const normalized = normalizeAttributionValue(value);
|
||||
function sanitizeBoundedId(value: string | undefined, policy: BoundedIdPolicy): string | undefined {
|
||||
const normalized = normalizeHeaderId(value);
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
return normalized.slice(0, ATTRIBUTION_VALUE_MAX_LENGTH);
|
||||
}
|
||||
|
||||
function sanitizeRequestId(value: string | undefined): string | undefined {
|
||||
const normalized = normalizeAttributionValue(value);
|
||||
if (!normalized) {
|
||||
if (
|
||||
normalized.length <= policy.maxLength &&
|
||||
policy.safePattern.test(normalized) &&
|
||||
!policy.encodedSuffixPattern.test(normalized)
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
if (normalized.length <= REQUEST_ID_MAX_LENGTH && REQUEST_ID_PATTERN.test(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
// Retain the per-call suffix while bounding long run ids; the hash keeps
|
||||
// distinct long prefixes from collapsing onto the same audit identifier.
|
||||
// Hash UTF-16 code units losslessly: UTF-8 string hashing replaces lone
|
||||
// surrogates with U+FFFD. The reserved encoded suffix keeps a rewritten ID
|
||||
// from aliasing an otherwise safe input that already looks encoded.
|
||||
const hash = createHash("sha256")
|
||||
.update(normalized)
|
||||
.update(Buffer.from(normalized, "utf16le"))
|
||||
.digest("hex")
|
||||
.slice(0, REQUEST_ID_HASH_LENGTH);
|
||||
const modelSuffix = normalized.match(/:model:\d+$/u)?.[0] ?? "";
|
||||
const rawPrefix = modelSuffix ? normalized.slice(0, -modelSuffix.length) : normalized;
|
||||
const safePrefix = rawPrefix.replace(REQUEST_ID_UNSAFE_CHARACTER_PATTERN, "_");
|
||||
const boundedSuffix = `~${hash}${modelSuffix}`;
|
||||
if (boundedSuffix.length >= REQUEST_ID_MAX_LENGTH) {
|
||||
return `${safePrefix.slice(0, REQUEST_ID_MAX_LENGTH - hash.length - 1)}~${hash}`;
|
||||
}
|
||||
return `${safePrefix.slice(0, REQUEST_ID_MAX_LENGTH - boundedSuffix.length)}${boundedSuffix}`;
|
||||
.slice(0, ID_HASH_LENGTH);
|
||||
const preservedSuffix = policy.preservedSuffixPattern?.exec(normalized)?.[0] ?? "";
|
||||
const rawPrefix = preservedSuffix ? normalized.slice(0, -preservedSuffix.length) : normalized;
|
||||
const safePrefix = rawPrefix.replace(policy.unsafeCharacterPattern, "_");
|
||||
const hashSuffix = `~${hash}`;
|
||||
const boundedSuffix = `${hashSuffix}${preservedSuffix}`;
|
||||
const suffix = boundedSuffix.length < policy.maxLength ? boundedSuffix : hashSuffix;
|
||||
return `${safePrefix.slice(0, policy.maxLength - suffix.length)}${suffix}`;
|
||||
}
|
||||
|
||||
function findHeader(headers: Record<string, string>, target: string): string | undefined {
|
||||
@@ -95,9 +119,13 @@ function withClawRouterHeaders(
|
||||
}
|
||||
}
|
||||
setHeaderDefault(next, CLIENT_HEADER, "openclaw");
|
||||
setHeaderDefault(next, AGENT_HEADER, sanitizeAttributionValue(params.agentId));
|
||||
setHeaderDefault(next, SESSION_HEADER, sanitizeAttributionValue(params.sessionId));
|
||||
setHeaderDefault(next, REQUEST_ID_HEADER, sanitizeRequestId(params.requestId));
|
||||
setHeaderDefault(next, AGENT_HEADER, sanitizeBoundedId(params.agentId, ATTRIBUTION_ID_POLICY));
|
||||
setHeaderDefault(
|
||||
next,
|
||||
SESSION_HEADER,
|
||||
sanitizeBoundedId(params.sessionId, ATTRIBUTION_ID_POLICY),
|
||||
);
|
||||
setHeaderDefault(next, REQUEST_ID_HEADER, sanitizeBoundedId(params.requestId, REQUEST_ID_POLICY));
|
||||
if (params.apiKey) {
|
||||
next.Authorization = `Bearer ${params.apiKey}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user