Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions lib/handler/cache-handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ class CacheHandler {
} catch {
// Fail silently
}
this.#deleteLocationHeaderEntries(resHeaders)
return downstreamOnHeaders()
}

Expand Down Expand Up @@ -314,6 +315,58 @@ class CacheHandler {
}
}

/**
* Deletes the cache entries for the URIs in the response's Location and
* Content-Location headers when they share the request URI's origin
* https://www.rfc-editor.org/rfc/rfc9111.html#section-4.4
*
* @param {import('../../types/header.d.ts').IncomingHttpHeaders} resHeaders
*/
#deleteLocationHeaderEntries (resHeaders) {
let requestUrl
try {
requestUrl = new URL(this.#cacheKey.path, this.#cacheKey.origin)
} catch {
// Can't resolve the request URI, don't invalidate anything else
return
}

const invalidatedPaths = new Set([this.#cacheKey.path])

for (const headerName of ['location', 'content-location']) {
const header = resHeaders[headerName]
const value = Array.isArray(header) ? header[0] : header
if (typeof value !== 'string' || value === '') {
continue
}

let url
try {
url = new URL(value, requestUrl)
} catch {
continue
}

if (url.origin !== requestUrl.origin) {
// Only invalidate URIs sharing the request URI's origin, invalidating
// cross-origin URIs could be used for cache poisoning
continue
}

const path = `${url.pathname}${url.search}`
if (invalidatedPaths.has(path)) {
continue
}
invalidatedPaths.add(path)

try {
this.#store.delete({ ...this.#cacheKey, path })?.catch?.(noop)
} catch {
// Fail silently
}
}
}

onResponseData (controller, chunk) {
if (this.#writeStream?.write(chunk) === false) {
controller.pause()
Expand Down
77 changes: 77 additions & 0 deletions test/interceptors/cache.js
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,83 @@ describe('Cache Interceptor', () => {
}
})

test('unsafe methods invalidate the URIs in Location and Content-Location response headers', async () => {
const requestsToOrigin = {
'/target': 0,
'/content-target': 0,
'/cross-origin-target': 0
}

const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
if (req.method === 'GET') {
requestsToOrigin[req.url]++
res.setHeader('cache-control', 'public, s-maxage=100')
res.end('asd')
return
}

res.statusCode = 204
if (req.url === '/same-origin') {
// Absolute same-origin Location, relative Content-Location
res.setHeader('location', `http://localhost:${server.address().port}/target`)
res.setHeader('content-location', '/content-target')
} else if (req.url === '/cross-origin') {
// Same path as a cached entry but a different origin, must not be
// invalidated
res.setHeader('location', 'http://other-origin.example/cross-origin-target')
}
res.end()
}).listen(0)

const client = new Client(`http://localhost:${server.address().port}`)
.compose(interceptors.cache())

after(async () => {
server.close()
await client.close()
})

await once(server, 'listening')

const origin = `http://localhost:${server.address().port}`

// Prime the cache and make sure the responses are served from it
for (const path of Object.keys(requestsToOrigin)) {
for (let i = 0; i < 2; i++) {
const res = await client.request({ origin, method: 'GET', path })
strictEqual(await res.body.text(), 'asd')
equal(requestsToOrigin[path], 1, path)
}
}

// Successful unsafe request whose response points at /target and
// /content-target, both must be invalidated
// https://www.rfc-editor.org/rfc/rfc9111.html#section-4.4
{
const res = await client.request({ origin, method: 'POST', path: '/same-origin' })
await res.body.text()
}

for (const path of ['/target', '/content-target']) {
const res = await client.request({ origin, method: 'GET', path })
strictEqual(await res.body.text(), 'asd')
equal(requestsToOrigin[path], 2, path)
}

// Successful unsafe request whose response Location shares a path with a
// cached entry but sits on another origin, the cached entry must survive
{
const res = await client.request({ origin, method: 'POST', path: '/cross-origin' })
await res.body.text()
}

{
const res = await client.request({ origin, method: 'GET', path: '/cross-origin-target' })
strictEqual(await res.body.text(), 'asd')
equal(requestsToOrigin['/cross-origin-target'], 1)
}
})

test('unsafe methods aren\'t cached', async () => {
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => {
res.setHeader('cache-control', 'public, s-maxage=1')
Expand Down
Loading