From b029a9562536ed727e205c4f3675dbf48c3253a3 Mon Sep 17 00:00:00 2001 From: Matthew Robertson Date: Thu, 19 Mar 2026 14:45:49 +0000 Subject: [PATCH] =?UTF-8?q?fix(cache):=20check=20Authorization=20on=20requ?= =?UTF-8?q?est=20headers=20per=20RFC=209111=20=C2=A73.5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/handler/cache-handler.js | 17 +++- test/interceptors/cache.js | 192 +++++++++++++++++++++++++++++++++++ 2 files changed, 205 insertions(+), 4 deletions(-) diff --git a/lib/handler/cache-handler.js b/lib/handler/cache-handler.js index 51e8e3ab690..8cfe073503a 100644 --- a/lib/handler/cache-handler.js +++ b/lib/handler/cache-handler.js @@ -135,7 +135,7 @@ class CacheHandler { } const cacheControlDirectives = cacheControlHeader ? parseCacheControlHeader(cacheControlHeader) : {} - if (!canCacheResponse(this.#cacheType, statusCode, resHeaders, cacheControlDirectives)) { + if (!canCacheResponse(this.#cacheType, statusCode, resHeaders, cacheControlDirectives, this.#cacheKey.headers)) { return downstreamOnHeaders() } @@ -340,8 +340,9 @@ class CacheHandler { * @param {number} statusCode * @param {import('../../types/header.d.ts').IncomingHttpHeaders} resHeaders * @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives + * @param {import('../../types/header.d.ts').IncomingHttpHeaders} [reqHeaders] */ -function canCacheResponse (cacheType, statusCode, resHeaders, cacheControlDirectives) { +function canCacheResponse (cacheType, statusCode, resHeaders, cacheControlDirectives, reqHeaders) { // Status code must be final and understood. if (statusCode < 200 || NOT_UNDERSTOOD_STATUS_CODES.includes(statusCode)) { return false @@ -372,8 +373,16 @@ function canCacheResponse (cacheType, statusCode, resHeaders, cacheControlDirect } // https://www.rfc-editor.org/rfc/rfc9111.html#name-storing-responses-to-authen - if (resHeaders.authorization) { - if (!cacheControlDirectives.public || typeof resHeaders.authorization !== 'string') { + if (reqHeaders?.authorization) { + if ( + !cacheControlDirectives.public && + !cacheControlDirectives['s-maxage'] && + !cacheControlDirectives['must-revalidate'] + ) { + return false + } + + if (typeof reqHeaders.authorization !== 'string') { return false } diff --git a/test/interceptors/cache.js b/test/interceptors/cache.js index 07853979b27..95f8e03fefa 100644 --- a/test/interceptors/cache.js +++ b/test/interceptors/cache.js @@ -2091,4 +2091,196 @@ describe('Cache Interceptor', () => { equal(cached.deleteAt, expectedDeleteAt, `deleteAt (${cached.deleteAt}) should be staleAt + 300s (${expectedDeleteAt})`) }) }) + + // https://www.rfc-editor.org/rfc/rfc9111.html#name-storing-responses-to-authen + describe('RFC 9111 ยง3.5 - Storing Responses to Authenticated Requests', () => { + test('caches response when request has Authorization and response has public directive', async () => { + let requestsToOrigin = 0 + const server = createServer({ joinDuplicateHeaders: true }, (_, res) => { + requestsToOrigin++ + res.setHeader('cache-control', 'public, max-age=60') + res.end('authenticated') + }).listen(0) + + after(() => server.close()) + await once(server, 'listening') + + const client = new Client(`http://localhost:${server.address().port}`) + .compose(interceptors.cache()) + + after(() => client.close()) + + const request = { + origin: 'localhost', + method: 'GET', + path: '/', + headers: { + authorization: 'Bearer token123' + } + } + + { + const res = await client.request(request) + equal(requestsToOrigin, 1) + strictEqual(await res.body.text(), 'authenticated') + } + + { + const res = await client.request(request) + equal(requestsToOrigin, 1) + strictEqual(await res.body.text(), 'authenticated') + } + }) + + test('caches response when request has Authorization and response has s-maxage directive', async () => { + let requestsToOrigin = 0 + const server = createServer({ joinDuplicateHeaders: true }, (_, res) => { + requestsToOrigin++ + res.setHeader('cache-control', 's-maxage=60') + res.end('authenticated') + }).listen(0) + + after(() => server.close()) + await once(server, 'listening') + + const client = new Client(`http://localhost:${server.address().port}`) + .compose(interceptors.cache()) + + after(() => client.close()) + + const request = { + origin: 'localhost', + method: 'GET', + path: '/', + headers: { + authorization: 'Bearer token123' + } + } + + { + const res = await client.request(request) + equal(requestsToOrigin, 1) + strictEqual(await res.body.text(), 'authenticated') + } + + { + const res = await client.request(request) + equal(requestsToOrigin, 1) + strictEqual(await res.body.text(), 'authenticated') + } + }) + + test('caches response when request has Authorization and response has must-revalidate directive', async () => { + let requestsToOrigin = 0 + const server = createServer({ joinDuplicateHeaders: true }, (_, res) => { + requestsToOrigin++ + res.setHeader('cache-control', 'must-revalidate, max-age=60') + res.end('authenticated') + }).listen(0) + + after(() => server.close()) + await once(server, 'listening') + + const client = new Client(`http://localhost:${server.address().port}`) + .compose(interceptors.cache()) + + after(() => client.close()) + + const request = { + origin: 'localhost', + method: 'GET', + path: '/', + headers: { + authorization: 'Bearer token123' + } + } + + { + const res = await client.request(request) + equal(requestsToOrigin, 1) + strictEqual(await res.body.text(), 'authenticated') + } + + { + const res = await client.request(request) + equal(requestsToOrigin, 1) + strictEqual(await res.body.text(), 'authenticated') + } + }) + + test('does not cache response when request has Authorization and response only has max-age', async () => { + let requestsToOrigin = 0 + const server = createServer({ joinDuplicateHeaders: true }, (_, res) => { + requestsToOrigin++ + res.setHeader('cache-control', 'max-age=60') + res.end('authenticated') + }).listen(0) + + after(() => server.close()) + await once(server, 'listening') + + const client = new Client(`http://localhost:${server.address().port}`) + .compose(interceptors.cache()) + + after(() => client.close()) + + const request = { + origin: 'localhost', + method: 'GET', + path: '/', + headers: { + authorization: 'Bearer token123' + } + } + + { + const res = await client.request(request) + equal(requestsToOrigin, 1) + strictEqual(await res.body.text(), 'authenticated') + } + + { + const res = await client.request(request) + equal(requestsToOrigin, 2) + strictEqual(await res.body.text(), 'authenticated') + } + }) + + test('does not cache response when request has Authorization and no cache directives', async () => { + let requestsToOrigin = 0 + const server = createServer({ joinDuplicateHeaders: true }, (_, res) => { + requestsToOrigin++ + res.end('authenticated') + }).listen(0) + + after(() => server.close()) + await once(server, 'listening') + + const client = new Client(`http://localhost:${server.address().port}`) + .compose(interceptors.cache()) + + after(() => client.close()) + + const request = { + origin: 'localhost', + method: 'GET', + path: '/', + headers: { + authorization: 'Bearer token123' + } + } + + { + const res = await client.request(request) + equal(requestsToOrigin, 1) + strictEqual(await res.body.text(), 'authenticated') + } + + { + const res = await client.request(request) + equal(requestsToOrigin, 2) + strictEqual(await res.body.text(), 'authenticated') + } + }) + }) })