Skip to content

Commit feef44d

Browse files
fix(relay): one hook budget per decoded token, whatever its spelling
The environment decodes path segments, so `token` and `%74oken` reach the same hook; keying the budget on the encoded segments let a sender with a valid token spread requests across spellings to get past the limit. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
1 parent 0451ace commit feef44d

2 files changed

Lines changed: 22 additions & 5 deletions

File tree

‎infra/relay/src/hooks/HookForwarder.test.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,15 @@ describe("HookForwarder", () => {
366366
}),
367367
);
368368

369+
it.effect("gives two spellings of one token a single budget", () =>
370+
Effect.gen(function* () {
371+
const harness = makeHarness();
372+
yield* harness.send(new Request(hookUrl("hook-1/token"), { method: "POST", body: "{}" }));
373+
yield* harness.send(new Request(hookUrl("hook-1/%74oken"), { method: "POST", body: "{}" }));
374+
expect(new Set(harness.rateLimitKeys).size).toBe(1);
375+
}),
376+
);
377+
369378
it.effect("returns 429 when the hook's budget is spent", () =>
370379
Effect.gen(function* () {
371380
const harness = makeHarness({ allow: () => false });

‎infra/relay/src/hooks/HookForwarder.ts‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,19 +51,26 @@ export const redactRelayHookUrl = (url: string): string => {
5151
};
5252

5353
/**
54-
* Request budget for public hook forwarding, keyed by a hash of the full hook
55-
* URL (environment, hook and token). Requests with a wrong token get their own
54+
* Request budget for public hook forwarding, keyed by a hash of the hook URL
55+
* (environment, hook and token). Requests with a wrong token get their own
5656
* budget, so they cannot use up a real sender's; the environment rejects them.
57+
* Built from decoded segments, because the environment decodes them too: two
58+
* spellings of one token (`token`, `%74oken`) must share one budget.
5759
*/
5860
const hookBudgetKey = (hook: {
5961
readonly environmentId: string;
60-
readonly rawHookId: string;
61-
readonly rawToken: string;
62+
readonly hookId: string;
63+
readonly token: string;
6264
}) =>
6365
Effect.promise(() =>
6466
crypto.subtle.digest(
6567
"SHA-256",
66-
new TextEncoder().encode(`${hook.environmentId}/${hook.rawHookId}/${hook.rawToken}`),
68+
// Length-prefixed, so no segment contents can make two keys collide.
69+
new TextEncoder().encode(
70+
[hook.environmentId, hook.hookId, hook.token]
71+
.map((part) => `${part.length}:${part}`)
72+
.join(""),
73+
),
6774
),
6875
).pipe(
6976
Effect.map((digest) =>
@@ -105,6 +112,7 @@ function parseHookPath(url: string) {
105112
return {
106113
environmentId: decodeURIComponent(rawEnvironmentId),
107114
hookId: decodeURIComponent(rawHookId),
115+
token: decodeURIComponent(rawToken),
108116
// Forward the encoded segments byte-for-byte; the environment decodes them.
109117
rawHookId,
110118
rawToken,

0 commit comments

Comments
 (0)