Skip to content

Commit 8097e69

Browse files
authored
fix(jwks): enforce maxResponseSize while reading the response (#32)
The size limit was applied to the `Content-Length` header and then re-checked after `res.text()` had resolved. A response without that header — any chunked reply, which the server chooses — skipped the first check, and by the time the second one ran the whole body was already in memory. The limit described a response we had finished buffering rather than one we refused to buffer. `readCapped` reads the body stream with a running byte count and stops at the first chunk that crosses the limit, cancelling the remainder so the connection is not left draining. Falls back to `text()` when the response exposes no stream. Measured against a server streaming 40 MiB with a 64 KiB limit configured: heap growth drops from ~42 MiB to ~1 MiB, and the client disconnects instead of reading to completion.
1 parent b72abf8 commit 8097e69

3 files changed

Lines changed: 99 additions & 7 deletions

File tree

.changeset/jwks-response-limit.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@exortek/jwks': patch
3+
---
4+
5+
Enforce `maxResponseSize` while the JWKS response is read, rather than after.
6+
7+
The limit was checked against the `Content-Length` header and then again once
8+
the body had been read in full. A response that omits the header — any chunked
9+
reply — skipped the first check, so the entire body was already buffered by the
10+
time the second one ran and the limit had no effect on what was allocated. The
11+
body is now read incrementally and abandoned as soon as it crosses the limit.

packages/jwks/src/remote.js

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -132,13 +132,7 @@ export function createRemoteJWKS(uri, options = {}) {
132132
);
133133
}
134134

135-
const text = await res.text();
136-
if (text.length > maxResponseSize) {
137-
throw new JwksError(
138-
ErrorCode.FETCH_FAILED,
139-
`JWKS response from ${uri} exceeds maxResponseSize (${text.length} > ${maxResponseSize})`,
140-
);
141-
}
135+
const text = await readCapped(res, maxResponseSize, uri);
142136

143137
/** @type {unknown} */
144138
let body;
@@ -312,3 +306,52 @@ export function createRemoteJWKS(uri, options = {}) {
312306

313307
return resolver;
314308
}
309+
310+
/**
311+
* Read a response body as text, refusing to buffer more than `limit`
312+
* bytes.
313+
*
314+
* `Content-Length` is only a hint — it is absent on a chunked response,
315+
* and a server chooses whether to send it. Checking it and then calling
316+
* `res.text()` therefore enforces nothing: the whole body is already in
317+
* memory by the time the size is known. Read the stream instead and stop
318+
* at the first chunk that crosses the limit, cancelling the rest so the
319+
* connection is not left draining.
320+
*
321+
* @param {Response} res
322+
* @param {number} limit maximum bytes to buffer
323+
* @param {string} uri for the error message
324+
* @returns {Promise<string>}
325+
*/
326+
async function readCapped(res, limit, uri) {
327+
if (!res.body) {
328+
// No stream (an empty body, or a fetch implementation without one) —
329+
// `text()` is bounded by whatever is already buffered.
330+
return res.text();
331+
}
332+
333+
const reader = res.body.getReader();
334+
const chunks = [];
335+
let total = 0;
336+
337+
try {
338+
for (;;) {
339+
const { done, value } = await reader.read();
340+
if (done) {
341+
break;
342+
}
343+
total += value.byteLength;
344+
if (total > limit) {
345+
throw new JwksError(
346+
ErrorCode.FETCH_FAILED,
347+
`JWKS response from ${uri} exceeds maxResponseSize (> ${limit} bytes)`,
348+
);
349+
}
350+
chunks.push(value);
351+
}
352+
} finally {
353+
await reader.cancel().catch(() => {});
354+
}
355+
356+
return Buffer.concat(chunks).toString('utf8');
357+
}

packages/jwks/tests/remote.test.js

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,44 @@ describe('resolver — error handling', () => {
181181
assert.match(err.message, /maxResponseSize/i);
182182
});
183183

184+
test('stops reading a response with no content-length once the cap is crossed', async () => {
185+
// A chunked reply advertises no length, so the header gate cannot see it.
186+
// The body must be capped while it streams, not after it is buffered.
187+
const CHUNK = new Uint8Array(32 * 1024);
188+
let chunksDelivered = 0;
189+
let cancelled = false;
190+
191+
mockFetch(async () => ({
192+
ok: true,
193+
status: 200,
194+
headers: new Map(),
195+
body: {
196+
getReader: () => ({
197+
read: async () => {
198+
chunksDelivered++;
199+
return { done: false, value: CHUNK };
200+
},
201+
cancel: async () => {
202+
cancelled = true;
203+
},
204+
}),
205+
},
206+
text: async () => {
207+
throw new Error('text() must not be used — it would buffer the whole body');
208+
},
209+
}));
210+
211+
const resolver = createRemoteJWKS('https://example.com/jwks', { maxResponseSize: 64 * 1024 });
212+
const err = await resolver({ kid: 'k1' }).catch(e => e);
213+
214+
assert.ok(err instanceof JwksError);
215+
assert.equal(err.code, ErrorCode.FETCH_FAILED);
216+
assert.match(err.message, /maxResponseSize/i);
217+
// 64 KiB cap over 32 KiB chunks: two fit, the third crosses it and stops.
218+
assert.equal(chunksDelivered, 3, 'must stop at the first chunk past the cap');
219+
assert.ok(cancelled, 'the remainder of the stream must be cancelled');
220+
});
221+
184222
test('throws FETCH_FAILED on invalid response body', async () => {
185223
mockFetch(async () => okResponse({ notKeys: [] }));
186224
const resolver = createRemoteJWKS('https://example.com/jwks');

0 commit comments

Comments
 (0)