Skip to content

Commit 4269dab

Browse files
authored
fix: handle missing vary header values (#4031)
1 parent c14781c commit 4269dab

File tree

7 files changed

+166
-22
lines changed

7 files changed

+166
-22
lines changed

lib/cache/memory-cache-store.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,13 @@ class MemoryCacheStore {
7979
const entry = this.#entries.get(topLevelKey)?.find((entry) => (
8080
entry.deleteAt > now &&
8181
entry.method === key.method &&
82-
(entry.vary == null || Object.keys(entry.vary).every(headerName => entry.vary[headerName] === key.headers?.[headerName]))
82+
(entry.vary == null || Object.keys(entry.vary).every(headerName => {
83+
if (entry.vary[headerName] === null) {
84+
return key.headers[headerName] === undefined
85+
}
86+
87+
return entry.vary[headerName] === key.headers[headerName]
88+
}))
8389
))
8490

8591
return entry == null

lib/cache/sqlite-cache-store.js

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -411,10 +411,6 @@ module.exports = class SqliteCacheStore {
411411
let matches = true
412412

413413
if (value.vary) {
414-
if (!headers) {
415-
return undefined
416-
}
417-
418414
const vary = JSON.parse(value.vary)
419415

420416
for (const header in vary) {
@@ -440,18 +436,21 @@ module.exports = class SqliteCacheStore {
440436
* @returns {boolean}
441437
*/
442438
function headerValueEquals (lhs, rhs) {
439+
if (lhs == null && rhs == null) {
440+
return true
441+
}
442+
443+
if ((lhs == null && rhs != null) ||
444+
(lhs != null && rhs == null)) {
445+
return false
446+
}
447+
443448
if (Array.isArray(lhs) && Array.isArray(rhs)) {
444449
if (lhs.length !== rhs.length) {
445450
return false
446451
}
447452

448-
for (let i = 0; i < lhs.length; i++) {
449-
if (rhs.includes(lhs[i])) {
450-
return false
451-
}
452-
}
453-
454-
return true
453+
return lhs.every((x, i) => x === rhs[i])
455454
}
456455

457456
return lhs === rhs

lib/util/cache.js

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,14 @@ function makeCacheKey (opts) {
2626
if (typeof key !== 'string' || typeof val !== 'string') {
2727
throw new Error('opts.headers is not a valid header map')
2828
}
29-
headers[key] = val
29+
headers[key.toLowerCase()] = val
3030
}
3131
} else if (typeof opts.headers === 'object') {
32-
headers = opts.headers
32+
headers = {}
33+
34+
for (const key of Object.keys(opts.headers)) {
35+
headers[key.toLowerCase()] = opts.headers[key]
36+
}
3337
} else {
3438
throw new Error('opts.headers is not an object')
3539
}
@@ -260,19 +264,16 @@ function parseVaryHeader (varyHeader, headers) {
260264
return headers
261265
}
262266

263-
const output = /** @type {Record<string, string | string[]>} */ ({})
267+
const output = /** @type {Record<string, string | string[] | null>} */ ({})
264268

265269
const varyingHeaders = typeof varyHeader === 'string'
266270
? varyHeader.split(',')
267271
: varyHeader
272+
268273
for (const header of varyingHeaders) {
269274
const trimmedHeader = header.trim().toLowerCase()
270275

271-
if (headers[trimmedHeader]) {
272-
output[trimmedHeader] = headers[trimmedHeader]
273-
} else {
274-
return undefined
275-
}
276+
output[trimmedHeader] = headers[trimmedHeader] ?? null
276277
}
277278

278279
return output

test/cache-interceptor/utils.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,40 @@ describe('parseVaryHeader', () => {
214214
'another-one': '123'
215215
})
216216
})
217+
218+
test('handles missing headers with null', () => {
219+
const result = parseVaryHeader('Accept-Encoding, Authorization', {})
220+
deepStrictEqual(result, {
221+
'accept-encoding': null,
222+
authorization: null
223+
})
224+
})
225+
226+
test('handles mix of present and missing headers', () => {
227+
const result = parseVaryHeader('Accept-Encoding, Authorization', {
228+
authorization: 'example-value'
229+
})
230+
deepStrictEqual(result, {
231+
'accept-encoding': null,
232+
authorization: 'example-value'
233+
})
234+
})
235+
236+
test('handles array input', () => {
237+
const result = parseVaryHeader(['Accept-Encoding', 'Authorization'], {
238+
'accept-encoding': 'gzip'
239+
})
240+
deepStrictEqual(result, {
241+
'accept-encoding': 'gzip',
242+
authorization: null
243+
})
244+
})
245+
246+
test('preserves existing * behavior', () => {
247+
const headers = { accept: 'text/html' }
248+
const result = parseVaryHeader('*', headers)
249+
deepStrictEqual(result, headers)
250+
})
217251
})
218252

219253
describe('isEtagUsable', () => {

test/issue-3959.js

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
const { describe, test, after } = require('node:test')
2+
const assert = require('node:assert')
3+
const { createServer } = require('node:http')
4+
const MemoryCacheStore = require('../lib/cache/memory-cache-store.js')
5+
const { request, Agent, setGlobalDispatcher } = require('..')
6+
const { interceptors } = require('..')
7+
8+
describe('Cache with Vary headers', () => {
9+
async function runCacheTest (store) {
10+
let requestCount = 0
11+
const server = createServer((req, res) => {
12+
requestCount++
13+
res.setHeader('Vary', 'Accept-Encoding')
14+
res.setHeader('Cache-Control', 'max-age=60')
15+
res.end(`Request count: ${requestCount}`)
16+
})
17+
18+
await new Promise(resolve => server.listen(0, resolve))
19+
const port = server.address().port
20+
const url = `http://localhost:${port}`
21+
22+
const agent = new Agent()
23+
setGlobalDispatcher(
24+
agent.compose(
25+
interceptors.cache({
26+
store,
27+
cacheByDefault: 1000,
28+
methods: ['GET']
29+
})
30+
)
31+
)
32+
33+
const res1 = await request(url)
34+
const body1 = await res1.body.text()
35+
assert.strictEqual(body1, 'Request count: 1')
36+
assert.strictEqual(requestCount, 1)
37+
38+
const res2 = await request(url)
39+
const body2 = await res2.body.text()
40+
assert.strictEqual(body2, 'Request count: 1')
41+
assert.strictEqual(requestCount, 1)
42+
43+
const res3 = await request(url, {
44+
headers: {
45+
'Accept-Encoding': 'gzip'
46+
}
47+
})
48+
const body3 = await res3.body.text()
49+
assert.strictEqual(body3, 'Request count: 2')
50+
assert.strictEqual(requestCount, 2)
51+
52+
await new Promise(resolve => server.close(resolve))
53+
}
54+
55+
test('should cache response with MemoryCacheStore when Vary header exists but request header is missing', async () => {
56+
await runCacheTest(new MemoryCacheStore())
57+
})
58+
59+
test('should cache response with SqliteCacheStore when Vary header exists but request header is missing', { skip: process.versions.node < '22' }, async () => {
60+
const SqliteCacheStore = require('../lib/cache/sqlite-cache-store.js')
61+
const sqliteStore = new SqliteCacheStore()
62+
await runCacheTest(sqliteStore)
63+
after(() => sqliteStore.close())
64+
})
65+
})

test/types/cache-interceptor.test-d.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,45 @@ expectNotAssignable<CacheInterceptor.CacheValue>({
7878
deleteAt: ''
7979
})
8080

81+
expectAssignable<CacheInterceptor.CacheValue>({
82+
statusCode: 200,
83+
statusMessage: 'OK',
84+
headers: {},
85+
vary: {
86+
'accept-encoding': null,
87+
authorization: 'example-value'
88+
},
89+
cachedAt: 0,
90+
staleAt: 0,
91+
deleteAt: 0
92+
})
93+
94+
expectAssignable<CacheInterceptor.CacheValue>({
95+
statusCode: 200,
96+
statusMessage: 'OK',
97+
headers: {},
98+
vary: {
99+
'accept-encoding': null,
100+
authorization: null
101+
},
102+
cachedAt: 0,
103+
staleAt: 0,
104+
deleteAt: 0
105+
})
106+
107+
expectNotAssignable<CacheInterceptor.CacheValue>({
108+
statusCode: 200,
109+
statusMessage: 'OK',
110+
headers: {},
111+
vary: {
112+
'accept-encoding': undefined,
113+
authorization: 'example-value'
114+
},
115+
cachedAt: 0,
116+
staleAt: 0,
117+
deleteAt: 0
118+
})
119+
81120
expectAssignable<CacheInterceptor.MemoryCacheStoreOpts>({})
82121
expectAssignable<CacheInterceptor.MemoryCacheStoreOpts>({
83122
maxSize: 0

types/cache-interceptor.d.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ declare namespace CacheHandler {
7070
statusCode: number
7171
statusMessage: string
7272
headers: Record<string, string | string[]>
73-
vary?: Record<string, string | string[]>
73+
vary?: Record<string, string | string[] | null>
7474
etag?: string
7575
cacheControlDirectives?: CacheControlDirectives
7676
cachedAt: number
@@ -88,7 +88,7 @@ declare namespace CacheHandler {
8888
statusCode: number
8989
statusMessage: string
9090
headers: Record<string, string | string[]>
91-
vary?: Record<string, string | string[]>
91+
vary?: Record<string, string | string[] | null>
9292
etag?: string
9393
body?: Readable | Iterable<Buffer> | AsyncIterable<Buffer> | Buffer | Iterable<string> | AsyncIterable<string> | string
9494
cacheControlDirectives: CacheControlDirectives,

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy