Skip to content

Commit de445a7

Browse files
Brooooooklynclaude
andcommitted
Bound the proxy's read of a tenant 403 before retrying
The proxy checks a 403 from the tenant Worker for the binding handler's token reject before retrying with the previous token. That check parsed the whole cloned body, so a Worker could make the shared proxy buffer an arbitrarily large response. Skip bodies that declare more than 256 bytes and stop reading a stream once it passes that size; the response returned to the caller still streams unbuffered. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent a3f08d9 commit de445a7

2 files changed

Lines changed: 88 additions & 3 deletions

File tree

‎platform/packages/proxy/src/dispatch.ts‎

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,20 +65,46 @@ function isGeneration(value: unknown): value is number {
6565
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
6666
}
6767

68-
/** The binding handler's reject for a token it does not hold, sent before any handler runs. */
68+
/** The binding handler's reject is `{"error":"unauthorized"}`; anything larger is not it. */
69+
const TOKEN_REJECTION_MAX_BYTES = 256;
70+
71+
/**
72+
* The binding handler's reject for a token it does not hold, sent before any handler runs.
73+
* The tenant Worker controls this response, so only a small prefix is ever read and the
74+
* returned response still streams unbuffered.
75+
*/
6976
async function isTokenRejection(res: Response): Promise<boolean> {
70-
if (res.status !== 403) {
77+
if (res.status !== 403 || !res.body) {
7178
return false;
7279
}
80+
if (Number(res.headers.get('content-length')) > TOKEN_REJECTION_MAX_BYTES) {
81+
return false;
82+
}
83+
const reader = res.clone().body!.getReader();
84+
const chunks: Array<Uint8Array> = [];
85+
let size = 0;
7386
try {
74-
const data = await res.clone().json();
87+
for (;;) {
88+
const chunk = await reader.read();
89+
if (chunk.done) {
90+
break;
91+
}
92+
size += chunk.value.byteLength;
93+
if (size > TOKEN_REJECTION_MAX_BYTES) {
94+
return false;
95+
}
96+
chunks.push(chunk.value);
97+
}
98+
const data: unknown = JSON.parse(await new Blob(chunks).text());
7599
return (
76100
typeof data === 'object' &&
77101
data !== null &&
78102
(data as { error?: unknown }).error === 'unauthorized'
79103
);
80104
} catch {
81105
return false;
106+
} finally {
107+
void reader.cancel().catch(() => {});
82108
}
83109
}
84110

‎platform/packages/proxy/test/unit/dispatch.test.ts‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -791,6 +791,65 @@ describe('proxy token generations', () => {
791791
expect(sent).toHaveLength(1);
792792
});
793793

794+
it('retries a reject that is streamed without a declared length', async () => {
795+
const legacy = await tok(0);
796+
const { env, sent } = setup({
797+
resolution: { workerName: 'user-my-app', deploymentId: 'dpl_active', proxyTokenGen: 1 },
798+
respond: (token) =>
799+
token === legacy
800+
? Response.json('hello')
801+
: new Response(new Blob(['{"error":"unauthorized"}']).stream(), { status: 403 }),
802+
});
803+
804+
const res = await dispatchToWorker(env, PROJECT_ID, '/kv/get', KV_BODY);
805+
806+
expect(res?.status).toBe(200);
807+
expect(sent.map((s) => s.token)).toEqual([await tok(1), legacy]);
808+
});
809+
810+
it('does not read a 403 that declares a large body', async () => {
811+
const { env, sent } = setup({
812+
resolution: { workerName: 'user-my-app', deploymentId: 'dpl_active', proxyTokenGen: 1 },
813+
respond: () =>
814+
new Response('{"error":"unauthorized"}', {
815+
status: 403,
816+
headers: { 'content-length': String(64 * 1024 * 1024) },
817+
}),
818+
});
819+
820+
const res = await dispatchToWorker(env, PROJECT_ID, '/kv/get', KV_BODY);
821+
822+
expect(res?.status).toBe(403);
823+
expect(sent).toHaveLength(1);
824+
});
825+
826+
it('stops reading a streamed 403 once it outgrows a reject', async () => {
827+
let pulls = 0;
828+
const chunk = new TextEncoder().encode(' '.repeat(64));
829+
const { env, sent } = setup({
830+
resolution: { workerName: 'user-my-app', deploymentId: 'dpl_active', proxyTokenGen: 1 },
831+
respond: () =>
832+
new Response(
833+
new ReadableStream<Uint8Array>({
834+
pull(controller) {
835+
pulls++;
836+
controller.enqueue(chunk);
837+
},
838+
}),
839+
{ status: 403 },
840+
),
841+
});
842+
843+
const res = await dispatchToWorker(env, PROJECT_ID, '/kv/get', KV_BODY);
844+
845+
expect(res?.status).toBe(403);
846+
expect(sent).toHaveLength(1);
847+
expect(pulls).toBeLessThan(16);
848+
const reader = res!.body!.getReader();
849+
expect((await reader.read()).value).toEqual(chunk);
850+
await reader.cancel();
851+
});
852+
794853
it('returns the second reject when the Worker holds neither token', async () => {
795854
const { env, sent } = setup({
796855
resolution: { workerName: 'user-my-app', deploymentId: 'dpl_active', proxyTokenGen: 2 },

0 commit comments

Comments
 (0)