Skip to content

Commit 6fa04b5

Browse files
fix: retain request headers in cache lookups (#204)
1 parent 1a8bd73 commit 6fa04b5

File tree

4 files changed

+124
-18
lines changed

4 files changed

+124
-18
lines changed

packages/cache/src/bootstrap/cache.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,54 @@ describe('Cache API', () => {
151151
expect(missResponse).toBeUndefined()
152152
})
153153

154+
test('retains request headers, discarding any forbidden ones', async () => {
155+
const headers = new Headers()
156+
headers.set('content-type', 'text/html')
157+
headers.set('x-custom-header', 'foobar')
158+
159+
const response = new Response('<h1>Hello world</h1>', { headers })
160+
const mockFetch = new MockFetch()
161+
.get({
162+
url: 'https://example.netlify/.netlify/cache/https%3A%2F%2Fnetlify.com%2F',
163+
headers: (headers) => {
164+
expect(headers.Authorization).toBe(`Bearer ${token}`)
165+
expect(headers['netlify-forwarded-host']).toBe(host)
166+
expect(headers['x-custom-header']).toBe('foo')
167+
expect(headers['netlify-programmable-store']).toBe('my-cache')
168+
expect(headers['netlify-programmable-headers']).toBeUndefined()
169+
},
170+
response,
171+
})
172+
.inject()
173+
const cache = new NetlifyCache({
174+
getContext: ({ operation }) => {
175+
expect(operation).toBe(Operation.Read)
176+
177+
return { host, token, url }
178+
},
179+
name: 'my-cache',
180+
userAgent,
181+
})
182+
183+
const cached = await cache.match(
184+
new Request('https://netlify.com', {
185+
headers: {
186+
'X-Custom-Header': 'foo',
187+
'Netlify-Programmable-Headers': 'forbidden',
188+
'Netlify-Programmable-Store': 'not the right store',
189+
'Netlify-Forwarded-Host': 'this would break things',
190+
},
191+
}),
192+
)
193+
194+
mockFetch.restore()
195+
196+
expect(mockFetch.requests.length).toBe(1)
197+
198+
expect(cached?.status).toBe(200)
199+
expect(await cached?.text()).toBe('<h1>Hello world</h1>')
200+
})
201+
154202
test('is a no-op when the `getContext` callback returns `null`', async () => {
155203
const mockFetch = new MockFetch().inject()
156204
const cache = new NetlifyCache({

packages/cache/src/bootstrap/cache.ts

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,17 @@ const allowedProtocols = new Set(['http:', 'https:'])
1111
// sent to the API.
1212
const discardedHeaders = new Set(['cookie', 'content-encoding', 'content-length'])
1313

14+
// Headers with these prefixes will be discarded from requests when doing cache
15+
// lookups; allowing clients to set them could lead to unexpected behavior.
16+
const forbiddenHeaderPrefixes = ['netlify-programmable-', 'x-nf-']
17+
1418
type NetlifyCacheOptions = EnvironmentOptions & {
1519
name: string
1620
}
1721

1822
const getInternalHeaders = Symbol('getInternalHeaders')
19-
const serializeResourceHeaders = Symbol('serializeResourceHeaders')
23+
const serializeRequestHeaders = Symbol('serializeRequestHeaders')
24+
const serializeResponseHeaders = Symbol('serializeResponseHeaders')
2025

2126
export class NetlifyCache implements Cache {
2227
#getContext: RequestContextFactory
@@ -47,7 +52,26 @@ export class NetlifyCache implements Cache {
4752
return headers
4853
}
4954

50-
private [serializeResourceHeaders](headers: Headers) {
55+
private [serializeRequestHeaders](headers: Headers) {
56+
const headersMap: Record<string, string> = {}
57+
58+
headers.forEach((value, key) => {
59+
const normalizedKey = key.toLowerCase()
60+
61+
// Discard any internal headers that the client should not be setting.
62+
for (const prefix of forbiddenHeaderPrefixes) {
63+
if (normalizedKey.startsWith(prefix)) {
64+
return
65+
}
66+
}
67+
68+
headersMap[normalizedKey] = value
69+
})
70+
71+
return headersMap
72+
}
73+
74+
private [serializeResponseHeaders](headers: Headers) {
5175
const headersMap: Record<string, string[]> = {}
5276

5377
headers.forEach((value, key) => {
@@ -110,7 +134,10 @@ export class NetlifyCache implements Cache {
110134
const resourceURL = extractAndValidateURL(request)
111135
const cacheURL = `${context.url}/${toCacheKey(resourceURL)}`
112136
const response = await fetch(cacheURL, {
113-
headers: this[getInternalHeaders](context),
137+
headers: {
138+
...(request instanceof Request ? this[serializeRequestHeaders](request.headers) : {}),
139+
...this[getInternalHeaders](context),
140+
},
114141
method: 'GET',
115142
})
116143

@@ -164,7 +191,7 @@ export class NetlifyCache implements Cache {
164191
body: response.body,
165192
headers: {
166193
...this[getInternalHeaders](context),
167-
[HEADERS.ResourceHeaders]: this[serializeResourceHeaders](response.headers),
194+
[HEADERS.ResourceHeaders]: this[serializeResponseHeaders](response.headers),
168195
[HEADERS.ResourceStatus]: response.status.toString(),
169196
},
170197
// @ts-expect-error https://github.com/whatwg/fetch/pull/1457

packages/cache/src/fetchwithcache.test.ts

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,43 @@ describe('`fetchWithCache`', () => {
5353
expect(mockFetch.requests.length).toBe(2)
5454
})
5555

56+
test('Respects request headers', async () => {
57+
const headers = new Headers()
58+
headers.set('content-type', 'text/html')
59+
headers.set('vary', 'x-custom-header')
60+
61+
const cachedResponse = new Response('<h1>Hello world</h1>', { headers })
62+
const mockFetch = new MockFetch()
63+
.post({
64+
response: new Response(null, { status: 201 }),
65+
url: 'https://example.netlify/.netlify/cache/https%3A%2F%2Fnetlify.com%2F',
66+
})
67+
.get({
68+
headers: (headers) => {
69+
expect(headers.vary).toBe('x-custom-header')
70+
},
71+
response: () => cachedResponse,
72+
url: 'https://example.netlify/.netlify/cache/https%3A%2F%2Fnetlify.com%2F',
73+
})
74+
.inject()
75+
const resourceURL = 'https://netlify.com'
76+
77+
const cache = await caches.open('')
78+
await cache.put(resourceURL, cachedResponse)
79+
80+
const response = await fetchWithCache(resourceURL, {
81+
headers: {
82+
vary: 'x-custom-header',
83+
},
84+
})
85+
86+
expect(await response.text()).toBe('<h1>Hello world</h1>')
87+
88+
mockFetch.restore()
89+
90+
expect(mockFetch.requests.length).toBe(2)
91+
})
92+
5693
test('Throws when used with a method other than GET', async () => {
5794
const mockFetch = new MockFetch().inject()
5895
const resourceURL = 'https://netlify.com'
@@ -103,7 +140,7 @@ describe('`fetchWithCache`', () => {
103140
url: 'https://example.netlify/.netlify/cache/https%3A%2F%2Fnetlify.com%2F',
104141
response: () => new Response('<h1>Hello world</h1>', { headers }),
105142
})
106-
.get({ url: 'https://netlify.com', response: new Response('<h1>Hello world</h1>', { headers }) })
143+
.get({ url: 'https://netlify.com/', response: new Response('<h1>Hello world</h1>', { headers }) })
107144
.inject()
108145
const resourceURL = 'https://netlify.com'
109146

@@ -152,7 +189,7 @@ describe('`fetchWithCache`', () => {
152189
response: new Response('<h1>Hello world</h1>', { headers }),
153190
})
154191
.get({
155-
url: 'https://netlify.com',
192+
url: 'https://netlify.com/',
156193
response: new Response('<h1>Hello world</h1>', { headers }),
157194
})
158195
.inject()
@@ -218,7 +255,7 @@ describe('`fetchWithCache`', () => {
218255
response: new Response('<h1>Hello world</h1>', { headers }),
219256
})
220257
.get({
221-
url: 'https://netlify.com',
258+
url: 'https://netlify.com/',
222259
response: new Response('<h1>Hello world</h1>', { headers }),
223260
})
224261
.inject()
@@ -253,7 +290,7 @@ describe('`fetchWithCache`', () => {
253290
response: new Response(html),
254291
})
255292
.get({
256-
url: 'https://netlify.com',
293+
url: 'https://netlify.com/',
257294
response: new Response(html),
258295
})
259296
.inject()

packages/cache/src/fetchwithcache.ts

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ type FetchWithCache = {
7272
* whether it comes from the cache or from the network.
7373
*/
7474
export const fetchWithCache: FetchWithCache = async (
75-
request: string | URL | Request,
75+
requestOrURL: string | URL | Request,
7676
optionsOrCacheSettings?: RequestInit | CacheSettings,
7777
cacheOptionsParam?: CacheOptions,
7878
) => {
@@ -87,15 +87,9 @@ export const fetchWithCache: FetchWithCache = async (
8787
requestInit = {}
8888
}
8989

90-
let method: string | undefined
90+
const request = new Request(requestOrURL, requestInit)
9191

92-
if (request instanceof Request) {
93-
method = request.method
94-
} else {
95-
method = requestInit?.method
96-
}
97-
98-
if (method && method?.toLowerCase() !== 'get') {
92+
if (request.method.toLowerCase() !== 'get') {
9993
throw new TypeError('`fetchWithCache` only supports GET requests.')
10094
}
10195

@@ -121,7 +115,7 @@ export const fetchWithCache: FetchWithCache = async (
121115
return cached
122116
}
123117

124-
const fresh = await fetch(request, requestInit)
118+
const fresh = await fetch(request)
125119
if (!fresh.body) {
126120
return fresh
127121
}

0 commit comments

Comments
 (0)