-
Notifications
You must be signed in to change notification settings - Fork 0
fix: emit a real X-Wherobots-Client hop instead of the legacy format (WBC-829) #48
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,150 @@ | ||
| import { describe, expect, it } from "vitest"; | ||
| import { platform } from "@platform"; | ||
| import { | ||
| CLIENT_TOKEN, | ||
| MAX_HEADER_BYTES, | ||
| buildHop, | ||
| clientHeaderValue, | ||
| resolvePlatform, | ||
| } from "./clientHeader"; | ||
| import { PACKAGE_VERSION } from "./version"; | ||
|
|
||
| const byteLength = (value: string) => new TextEncoder().encode(value).length; | ||
|
|
||
| describe("buildHop", () => { | ||
| it("renders the token, version and platform", () => { | ||
| expect(buildHop("0.11.1", "linux")).toBe( | ||
| "client=typescript-sdk;ver=0.11.1;plat=linux", | ||
| ); | ||
| }); | ||
|
|
||
| it("uses the package version and host platform by default", () => { | ||
| expect(buildHop()).toBe( | ||
| `client=${CLIENT_TOKEN};ver=${PACKAGE_VERSION};plat=${resolvePlatform()}`, | ||
| ); | ||
| }); | ||
|
|
||
| it("omits a parameter it cannot resolve rather than emitting a placeholder", () => { | ||
| // The `client=` token is the part attribution depends on; a missing | ||
| // version must not cost us the hop. | ||
| expect(buildHop("", "linux")).toBe("client=typescript-sdk;plat=linux"); | ||
| expect(buildHop("0.11.1", "")).toBe("client=typescript-sdk;ver=0.11.1"); | ||
| expect(buildHop("", "")).toBe("client=typescript-sdk"); | ||
| }); | ||
|
|
||
| it("takes its platform from the platform layer", () => { | ||
| // Vitest resolves `@platform` to the Node implementation, so this asserts | ||
| // the Node value; the browser build swaps in `"browser"` at bundle time, | ||
| // which bundle.smoke.test.ts pins by forbidding `process.platform` there. | ||
| expect(resolvePlatform()).toBe(platform.clientPlatform); | ||
| expect(buildHop()).toContain(`;plat=${platform.clientPlatform}`); | ||
| }); | ||
|
|
||
| it("neutralizes grammar delimiters in a parameter", () => { | ||
| const hop = buildHop("1.0;cmd=evil,client=spoofed", "li;nux"); | ||
|
|
||
| expect(hop).toBe( | ||
| "client=typescript-sdk;ver=1.0_cmd_evil_client_spoofed;plat=li_nux", | ||
| ); | ||
| // Exactly one hop, with only the delimiters we rendered ourselves. | ||
| expect(hop).not.toContain(","); | ||
| expect(hop.match(/;/g)).toHaveLength(2); | ||
| }); | ||
|
|
||
| it("bounds parameter length without exposing a trailing separator", () => { | ||
| // The 63-character cut lands right after the `,` that sanitizing turned | ||
| // into a `_`, so stripping separators before truncating is not enough -- | ||
| // the truncation itself can create a new trailing one. | ||
| const hop = buildHop(`${"v".repeat(62)},rc1`, "linux"); | ||
|
|
||
| expect(hop).toBe(`client=typescript-sdk;ver=${"v".repeat(62)};plat=linux`); | ||
| }); | ||
| }); | ||
|
|
||
| describe("clientHeaderValue", () => { | ||
| it("is a single hop when the SDK originates the request", () => { | ||
| const value = clientHeaderValue(); | ||
|
|
||
| expect(value).not.toContain(","); | ||
| expect(value.startsWith(`client=${CLIENT_TOKEN};ver=`)).toBe(true); | ||
| }); | ||
|
|
||
| it("appends its own hop to the right of an upstream chain", () => { | ||
| const value = clientHeaderValue("client=studio-frontend", "client=x"); | ||
|
|
||
| // Leftmost hop stays the origin; ours is the rightmost, direct caller. | ||
| expect(value).toBe("client=studio-frontend, client=x"); | ||
| }); | ||
|
|
||
| it("normalizes upstream spacing and drops empty hops", () => { | ||
| const value = clientHeaderValue( | ||
| " client=cli;ver=1.2.0 ,, , client=mcp ", | ||
| "client=x", | ||
| ); | ||
|
|
||
| expect(value).toBe("client=cli;ver=1.2.0, client=mcp, client=x"); | ||
| }); | ||
|
|
||
| it("preserves upstream hop parameters verbatim", () => { | ||
| // The chain records what upstream asserted; it is not ours to re-render. | ||
| const value = clientHeaderValue( | ||
| "client=mcp;ver=0.9;cmd=run_query", | ||
| "client=x", | ||
| ); | ||
|
|
||
| expect(value).toBe("client=mcp;ver=0.9;cmd=run_query, client=x"); | ||
| }); | ||
|
|
||
| it("strips characters that would inject a header or corrupt the grammar", () => { | ||
| const value = clientHeaderValue( | ||
| 'client=cli\r\nX-Evil: 1\ttab"quote"', | ||
| "client=x", | ||
| ); | ||
|
|
||
| expect(value).not.toMatch(/[\r\n\t"]/); | ||
| expect(value.startsWith("client=cli_")).toBe(true); | ||
| }); | ||
|
|
||
| it("drops the oldest upstream hops rather than blowing the size bound", () => { | ||
| // An oversized value is malformed to the server, which then attributes the | ||
| // whole request to `unknown` -- losing early provenance is the better half | ||
| // of that trade. | ||
| const oversized = Array.from( | ||
| { length: 20 }, | ||
| (_, i) => `client=${String(i).repeat(40)}`, | ||
| ).join(", "); | ||
| expect(byteLength(oversized)).toBeGreaterThan(MAX_HEADER_BYTES); | ||
|
|
||
| const value = clientHeaderValue(oversized, "client=x"); | ||
|
|
||
| expect(byteLength(value)).toBeLessThanOrEqual(MAX_HEADER_BYTES); | ||
| // Our own hop always survives, and it stays rightmost. | ||
| expect(value.endsWith("client=x")).toBe(true); | ||
| }); | ||
|
|
||
| it("keeps our hop when even one upstream hop will not fit", () => { | ||
| const value = clientHeaderValue(`client=${"x".repeat(600)}`, "client=x"); | ||
|
|
||
| expect(value).toBe("client=x"); | ||
| }); | ||
|
|
||
| it("sanitizes non-ASCII before measuring the bound", () => { | ||
| const value = clientHeaderValue(`client=${"é".repeat(600)}`, "client=x"); | ||
|
|
||
| expect(byteLength(value)).toBeLessThanOrEqual(MAX_HEADER_BYTES); | ||
| expect(value).toBe("client=x"); | ||
| }); | ||
|
|
||
| it("is always sendable as a real header value", () => { | ||
| // `fetch` rejects an illegal header value outright, so a hostile chain | ||
| // must never be able to break session creation. | ||
| const value = clientHeaderValue( | ||
| "client=evil\r\nX-Injected: yes, client=日本語", | ||
| ); | ||
|
|
||
| expect(() => new Headers({ "X-Wherobots-Client": value })).not.toThrow(); | ||
| expect( | ||
| new Headers({ "X-Wherobots-Client": value }).get("X-Wherobots-Client"), | ||
| ).toBe(value); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,140 @@ | ||
| // Build the advisory `X-Wherobots-Client` request header. | ||
| // | ||
| // `X-Wherobots-Client` is the shared, cross-client attribution header. It | ||
| // carries an ordered, comma-separated chain of hops modelled on | ||
| // `X-Forwarded-For`: the *leftmost* hop is the origin client and every | ||
| // component that forwards the request *appends its own hop on the right*: | ||
| // | ||
| // client=studio-frontend, client=typescript-sdk;ver=0.11.1;plat=browser | ||
| // | ||
| // Each hop is `client=<token>` plus optional `;key=value` parameters. The | ||
| // convention defines `ver`, `plat` and `cmd`; this SDK emits `ver` and `plat` — | ||
| // there is no subcommand for it to name. Commas and semicolons are the | ||
| // delimiters, so they never appear inside a value, and neither do control | ||
| // characters, which an HTTP header field-value cannot carry at all. | ||
| // | ||
| // The header is *advisory only*: it is client-asserted, used for attribution | ||
| // and analytics, and must never influence authentication or authorization. | ||
| // | ||
| // See studio-backend `docs/client-attribution.md` — that document is the | ||
| // contract this module implements. | ||
|
|
||
| import { platform } from "@platform"; | ||
| import { PACKAGE_VERSION } from "./version"; | ||
|
|
||
| // Canonical name of the shared, cross-service client-chain header. | ||
| export const CLIENT_HEADER_NAME = "X-Wherobots-Client"; | ||
|
|
||
| // This SDK's stable token in the shared client vocabulary. Renaming it splits | ||
| // its analytics history, so it never changes. | ||
| export const CLIENT_TOKEN = "typescript-sdk"; | ||
|
|
||
| // The server treats a header value longer than this (in bytes) as malformed and | ||
| // records `unknown` for the whole chain, so we never emit more: oversized | ||
| // upstream chains are trimmed from the left instead. Every value is scrubbed to | ||
| // ASCII, so bytes and characters count the same here. | ||
| export const MAX_HEADER_BYTES = 512; | ||
|
|
||
| // Bounds a single parameter value (`ver`, `plat`). The server's 64-character | ||
| // limit applies to a hop's `client` token, not to its parameters, so an | ||
| // over-length value here costs nothing on its own; the bound exists so one | ||
| // pathological version string cannot eat the header budget and starve upstream | ||
| // hops. `CLIENT_TOKEN` is a short fixed literal and never passes through | ||
| // `sanitizeValue`. | ||
| const MAX_VALUE_CHARS = 63; | ||
|
|
||
| const HOP_SEPARATOR = ", "; | ||
| const REPLACEMENT = "_"; | ||
|
|
||
| // Everything outside this ASCII allowlist collapses to `_`: the `,` and `;` | ||
| // grammar delimiters, every C0 control character and DEL (a CR or LF here is | ||
| // the classic header-injection primitive, and `fetch` rejects the request | ||
| // outright rather than sending it), and every non-ASCII character. | ||
| const UNSAFE_VALUE_CHARS = /[^A-Za-z0-9._+-]/g; | ||
|
|
||
| // An upstream chain carries its own `,` / `;` / `=` grammar, so those survive; | ||
| // everything else outside the allowlist does not. | ||
| const UNSAFE_CHAIN_CHARS = /[^A-Za-z0-9._+:/@=;, -]/g; | ||
|
|
||
| // Render a value safe to embed in a hop we build ourselves. | ||
| const sanitizeValue = (value: string): string => | ||
| value | ||
| .trim() | ||
| .replace(UNSAFE_VALUE_CHARS, REPLACEMENT) | ||
| .slice(0, MAX_VALUE_CHARS) | ||
| // Trailing separators carry no information and read as noise. Stripped | ||
| // after truncation as well as before it: the cut can land immediately | ||
| // after a replaced character and expose a separator that was in the | ||
| // middle of the value a moment ago. | ||
| .replace(/^[_.-]+|[_.-]+$/g, ""); | ||
|
|
||
| // The `plat` parameter for this hop. Node reports its OS (`darwin`, `linux`, | ||
| // `win32`) to match what the Python SDK and JDBC driver emit; the browser has | ||
| // no equivalent it can report honestly, and a UA-string parse would be a guess, | ||
| // so it reports the runtime instead. Resolved through the platform layer so | ||
| // that `process.platform` stays out of the browser bundle entirely. | ||
| export const resolvePlatform = (): string => platform.clientPlatform; | ||
|
|
||
| // Render this SDK's single hop. A parameter whose value is missing or empty is | ||
| // omitted rather than emitted as a placeholder, so an unresolvable version | ||
| // simply means the hop carries no `ver` — the `client=` token, which is the | ||
| // part attribution actually depends on, is always present. | ||
| export const buildHop = ( | ||
| version: string = PACKAGE_VERSION, | ||
| platformName: string = resolvePlatform(), | ||
| ): string => { | ||
| const segments = [`client=${CLIENT_TOKEN}`]; | ||
| const sanitizedVersion = sanitizeValue(version); | ||
| if (sanitizedVersion) { | ||
| segments.push(`ver=${sanitizedVersion}`); | ||
| } | ||
| const sanitizedPlatform = sanitizeValue(platformName); | ||
| if (sanitizedPlatform) { | ||
| segments.push(`plat=${sanitizedPlatform}`); | ||
| } | ||
| return segments.join(";"); | ||
| }; | ||
|
|
||
| // Split an inbound chain into its individual hops. Hop *parameters* are | ||
| // preserved as-is — the chain is a record of what upstream asserted, not | ||
| // something to re-render — but the text is scrubbed, because an upstream chain | ||
| // is arbitrary caller-supplied input and this is the only sanitization it ever | ||
| // gets before landing in a request header. | ||
| const splitChain = (chain: string | undefined): string[] => { | ||
| if (!chain) { | ||
| return []; | ||
| } | ||
| return chain | ||
| .replace(UNSAFE_CHAIN_CHARS, REPLACEMENT) | ||
| .split(",") | ||
| .map((hop) => hop.replace(/\s+/g, " ").trim()) | ||
| .filter((hop) => hop.length > 0); | ||
| }; | ||
|
|
||
| const byteLength = (value: string): number => | ||
| new TextEncoder().encode(value).length; | ||
|
|
||
| // Build the full header value for a request this SDK is sending. | ||
| // | ||
| // Any `upstreamChain` the caller supplies is preserved — scrubbed, but | ||
| // otherwise untouched — and this SDK's hop is appended on its right, so the | ||
| // origin stays leftmost. When the result would exceed `MAX_HEADER_BYTES` the | ||
| // oldest (leftmost) upstream hops are dropped until it fits: losing early | ||
| // provenance beats the server discarding the whole chain as malformed. | ||
| // | ||
| // Never returns an empty string. With no upstream chain it is this SDK's | ||
| // single hop. | ||
| export const clientHeaderValue = ( | ||
| upstreamChain?: string, | ||
| hop: string = buildHop(), | ||
| ): string => { | ||
| const hops = splitChain(upstreamChain); | ||
| while (hops.length > 0) { | ||
| const value = [...hops, hop].join(HOP_SEPARATOR); | ||
| if (byteLength(value) <= MAX_HEADER_BYTES) { | ||
| return value; | ||
| } | ||
| hops.shift(); | ||
| } | ||
| return hop; | ||
| }; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.