Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[http-cache]: handle cache invalidation of location and content-location headers #3735

Closed
wants to merge 12 commits into from
14 changes: 14 additions & 0 deletions lib/cache/memory-cache-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,20 @@ class MemoryCacheStore {
this.#data.delete(origin)
}

/**
* @param {string} origin
* @param {string} path
*/
deleteByOriginAndPath (origin, path) {
const cachedPaths = this.#data.get(origin)
for (const key of cachedPaths.keys()) {
// not good to use startsWith here, but it's fine for now
if (key.startsWith(path)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would split the key here by the : because the MemoryCacheStore indeed stores keys in a format path:method, but this might be not true for other types of stores. So if someone will rely on this it might be a breaking change in the future.

cachedPaths.delete(key)
}
}
}

/**
* Gets all of the requests of the same origin, path, and method. Does not
* take the `vary` property into account.
Expand Down
2 changes: 1 addition & 1 deletion lib/core/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -924,6 +924,6 @@ module.exports = {
isHttpOrHttpsPrefixed,
nodeMajor,
nodeMinor,
safeHTTPMethods: ['GET', 'HEAD', 'OPTIONS', 'TRACE'],
safeHTTPMethods: Object.freeze(['GET', 'HEAD', 'OPTIONS', 'TRACE']),
wrapRequestBody
}
122 changes: 76 additions & 46 deletions lib/handler/cache-handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,53 +2,54 @@

const util = require('../core/util')
const DecoratorHandler = require('../handler/decorator-handler')
const { parseCacheControlHeader, parseVaryHeader, UNSAFE_METHODS, assertCacheStoreType } = require('../util/cache')
const {
parseCacheControlHeader,
parseVaryHeader
} = require('../util/cache')
const { sameOrigin } = require('../web/fetch/util.js')

const noop = () => {}

/**
* Writes a response to a CacheStore and then passes it on to the next handler
*/
class CacheHandler extends DecoratorHandler {
/**
* @type {import('../../types/cache-interceptor.d.ts').default.CacheOptions | null}
*/
#opts = null
* @type {import('../../types/cache-interceptor.d.ts').default.CacheStore}
*/
#store

/**
* @type {import('../../types/cache-interceptor.d.ts').default.CacheMethods}
*/
#methods

/**
* @type {import('../../types/dispatcher.d.ts').default.RequestOptions | null}
* @type {import('../../types/dispatcher.d.ts').default.RequestOptions}
*/
#req = null
#requestOptions

/**
* @type {import('../../types/dispatcher.d.ts').default.DispatchHandlers | null}
* @type {import('../../types/dispatcher.d.ts').default.DispatchHandlers}
*/
#handler = null
#handler

/**
* @type {import('../../types/cache-interceptor.d.ts').default.CacheStoreWriteable | undefined}
*/
#writeStream

/**
* @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions} opts
* @param {import('../../types/dispatcher.d.ts').default.RequestOptions} req
* @param {import('../../types/dispatcher.d.ts').default.RequestOptions} requestOptions
* @param {import('../../types/dispatcher.d.ts').default.DispatchHandlers} handler
*/
constructor (opts, req, handler) {
constructor (opts, requestOptions, handler) {
super(handler)

if (typeof opts !== 'object') {
throw new TypeError(`expected opts to be an object, got type ${typeof opts}`)
}

assertCacheStoreType(opts.store)

if (typeof req !== 'object') {
throw new TypeError(`expected req to be an object, got type ${typeof opts}`)
}

if (typeof handler !== 'object') {
throw new TypeError(`expected handler to be an object, got type ${typeof opts}`)
}

this.#opts = opts
this.#req = req
this.#store = opts.store
this.#methods = opts.methods
this.#requestOptions = requestOptions
this.#handler = handler
}

Expand All @@ -74,36 +75,60 @@ class CacheHandler extends DecoratorHandler {
statusMessage
)

const headers = util.parseHeaders(rawHeaders)

// 4.4. Invalidating Stored Responses
//
// https://www.rfc-editor.org/rfc/rfc9111.html#name-invalidating-stored-respons
// Because unsafe request methods (Section 9.2.1 of [HTTP]) such as PUT,
// POST, or DELETE have the potential for changing state on the origin
// server, intervening caches are required to invalidate stored responses
// to keep their contents up to date.

if (
UNSAFE_METHODS.includes(this.#req.method) &&
statusCode >= 200 &&
statusCode <= 399
// A cache MUST invalidate the target URI (Section 7.1 of [HTTP]) when it
// receives a non-error status code in response to an unsafe request
// method (including methods whose safety is unknown).
!this.#methods.includes(this.#requestOptions.method) &&
isNonErrorStatusCode(statusCode)
) {
// https://www.rfc-editor.org/rfc/rfc9111.html#name-invalidating-stored-respons
// Try/catch for if it's synchronous
try {
const result = this.#opts.store.deleteByOrigin(this.#req.origin)
if (
result &&
typeof result.catch === 'function' &&
typeof this.#handler.onError === 'function'
) {
// Fail silently
result.catch(_ => {})
const result = this.#store.deleteByOrigin(this.#requestOptions.origin)
// Fail silently if it is a promise/async function
result && result.catch && result.catch(noop)
} catch (err) { /* Fail silently */ }

// A cache MAY invalidate other URIs when it receives a non-error status code
// in response to an unsafe request method (including methods whose safety is
// unknown). In particular, the URI(s) in the Location and Content-Location
// response header fields (if present) are candidates for invalidation; other
// URIs might be discovered through mechanisms not specified in this document.
// However, a cache MUST NOT trigger an invalidation under these conditions if
// the origin (Section 4.3.1 of [HTTP]) of the URI to be invalidated differs
// from that of the target URI (Section 7.1 of [HTTP]). This helps prevent
// denial-of-service attacks.
['location', 'content-location'].forEach((headerName) => {
if (headers[headerName]) {
const targetURL = new URL(headers[headerName], this.#requestOptions.origin)
if (sameOrigin(targetURL, this.#requestOptions.url)) {
// Try/catch for if it's synchronous
try {
const result = this.#store.deleteByOriginAndPath(this.#requestOptions.origin, targetURL.pathname)
// Fail silently if it is a promise/async function
result && result.catch && result.catch(noop)
} catch (err) { /* Fail silently */ }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should be awaited if it's a promise.

}
}
} catch (err) {
// Fail silently
}
})

return downstreamOnHeaders()
}

const headers = util.parseHeaders(rawHeaders)

const cacheControlHeader = headers['cache-control']
const contentLengthHeader = headers['content-length']

if (!cacheControlHeader || !contentLengthHeader || this.#opts.store.isFull) {
if (!cacheControlHeader || !contentLengthHeader || this.#store.isFull) {
// Don't have the headers we need, can't cache
return downstreamOnHeaders()
}
Expand All @@ -122,7 +147,7 @@ class CacheHandler extends DecoratorHandler {
const staleAt = determineStaleAt(now, headers, cacheControlDirectives)
if (staleAt) {
const varyDirectives = headers.vary
? parseVaryHeader(headers.vary, this.#req.headers)
? parseVaryHeader(headers.vary, this.#requestOptions.headers)
: undefined
const deleteAt = determineDeleteAt(now, cacheControlDirectives, staleAt)

Expand All @@ -132,7 +157,7 @@ class CacheHandler extends DecoratorHandler {
cacheControlDirectives
)

this.#writeStream = this.#opts.store.createWriteStream(this.#req, {
this.#writeStream = this.#store.createWriteStream(this.#requestOptions, {
statusCode,
statusMessage,
rawHeaders: strippedHeaders,
Expand Down Expand Up @@ -362,4 +387,9 @@ function stripNecessaryHeaders (rawHeaders, parsedHeaders, cacheControlDirective
return strippedHeaders ? util.encodeHeaders(strippedHeaders) : rawHeaders
}

function isNonErrorStatusCode (statusCode) {
// A "non-error response" is one with a 2xx (Successful) or 3xx (Redirection) status code
return statusCode >= 200 && statusCode <= 399
}

module.exports = CacheHandler
4 changes: 2 additions & 2 deletions lib/handler/cache-revalidation-handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,12 @@ class CacheRevalidationHandler extends DecoratorHandler {
* @param {import('../../types/dispatcher.d.ts').default.DispatchHandlers} handler
*/
constructor (successCallback, handler) {
super(handler)

if (typeof successCallback !== 'function') {
throw new TypeError('successCallback must be a function')
}

super(handler)

this.#successCallback = successCallback
this.#handler = handler
}
Expand Down
42 changes: 17 additions & 25 deletions lib/interceptor/cache.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,48 +4,40 @@ const util = require('../core/util')
const CacheHandler = require('../handler/cache-handler')
const MemoryCacheStore = require('../cache/memory-cache-store')
const CacheRevalidationHandler = require('../handler/cache-revalidation-handler')
const { UNSAFE_METHODS, assertCacheStoreType } = require('../util/cache.js')
const { assertCacheStore, assertCacheMethods } = require('../util/cache.js')

const AGE_HEADER = Buffer.from('age')

/**
* @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions | undefined} globalOpts
* @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions} [opts]
* @returns {import('../../types/dispatcher.d.ts').default.DispatcherComposeInterceptor}
*/
module.exports = globalOpts => {
if (!globalOpts) {
globalOpts = {}
module.exports = (opts = {}) => {
const {
store = new MemoryCacheStore(),
methods = ['GET']
} = opts

if (typeof opts !== 'object' || opts === null) {
throw new TypeError(`expected type of opts to be an Object, got ${opts === null ? 'null' : typeof opts}`)
}

if (globalOpts.store) {
assertCacheStoreType(globalOpts.store)
} else {
globalOpts.store = new MemoryCacheStore()
}

if (globalOpts.methods) {
if (!Array.isArray(globalOpts.methods)) {
throw new TypeError(`methods needs to be an array, got ${typeof globalOpts.methods}`)
}
assertCacheStore(store, 'opts.store')
assertCacheMethods(methods, 'opts.methods')

if (globalOpts.methods.length === 0) {
throw new Error('methods must have at least one method in it')
}
} else {
globalOpts.methods = ['GET']
const globalOpts = {
store,
methods
}

// Safe methods the user wants and unsafe methods
const methods = [...globalOpts.methods, ...UNSAFE_METHODS]

return dispatch => {
return (opts, handler) => {
if (!opts.origin || !methods.includes(opts.method)) {
// Not a method we want to cache or we don't have the origin, skip
return dispatch(opts, handler)
}

const stream = globalOpts.store.createReadStream(opts)
const stream = store.createReadStream(opts)
if (!stream) {
// Request isn't cached
return dispatch(opts, new CacheHandler(globalOpts, opts, handler))
Expand Down Expand Up @@ -169,7 +161,7 @@ module.exports = globalOpts => {
respondWithCachedValue(stream, value)
}

Promise.resolve(stream).then(handleStream).catch(err => handler.onError(err))
Promise.resolve(stream).then(handleStream).catch(handler.onError)

return true
}
Expand Down
37 changes: 28 additions & 9 deletions lib/util/cache.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
'use strict'

const UNSAFE_METHODS = /** @type {const} */ ([
'POST', 'PUT', 'PATCH', 'DELETE'
])
const {
safeHTTPMethods
} = require('../core/util')

/**
* @see https://www.rfc-editor.org/rfc/rfc9111.html#name-cache-control
Expand Down Expand Up @@ -181,25 +181,44 @@ function parseVaryHeader (varyHeader, headers) {
* @param {unknown} store
* @returns {asserts store is import('../../types/cache-interceptor.d.ts').default.CacheStore}
*/
function assertCacheStoreType (store) {
function assertCacheStore (store, name = 'CacheStore') {
if (typeof store !== 'object' || store === null) {
throw new TypeError(`expected type to be an store, got ${typeof store}`)
throw new TypeError(`expected type of ${name} to be a CacheStore, got ${store === null ? 'null' : typeof store}`)
}

for (const fn of ['createReadStream', 'createWriteStream', 'deleteByOrigin']) {
if (typeof store[fn] !== 'function') {
throw new TypeError(`CacheStore needs a \`${fn}()\` function`)
throw new TypeError(`${name} needs to have a \`${fn}()\` function`)
}
}

if (typeof store.isFull !== 'boolean') {
throw new TypeError(`CacheStore needs a isFull getter with type boolean, current type: ${typeof store.isFull}`)
throw new TypeError(`${name} needs a isFull getter with type boolean, current type: ${typeof store.isFull}`)
}
}
/**
* @param {unknown} methods
* @returns {asserts methods is import('../../types/cache-interceptor.d.ts').default.CacheMethods[]}
*/
function assertCacheMethods (methods, name = 'CacheMethods') {
if (!Array.isArray(methods)) {
throw new TypeError(`expected type of ${name} needs to be an array, got ${methods === null ? 'null' : typeof methods}`)
}

if (methods.length === 0) {
throw new TypeError(`${name} needs to have at least one method`)
}

for (const method of methods) {
if (!safeHTTPMethods.includes(method)) {
throw new TypeError(`element of ${name}-array needs to be one of following values: ${safeHTTPMethods.join(', ')}, got ${method}`)
}
}
}

module.exports = {
UNSAFE_METHODS,
parseCacheControlHeader,
parseVaryHeader,
assertCacheStoreType
assertCacheMethods,
assertCacheStore
}
9 changes: 8 additions & 1 deletion types/cache-interceptor.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import Dispatcher from './dispatcher'
export default CacheHandler

declare namespace CacheHandler {
export type CacheMethods = 'GET' | 'HEAD' | 'OPTIONS' | 'TRACE'

export interface CacheOptions {
store?: CacheStore

Expand All @@ -14,7 +16,7 @@ declare namespace CacheHandler {
* @see https://www.rfc-editor.org/rfc/rfc9111.html#name-invalidating-stored-respons
* @see https://www.rfc-editor.org/rfc/rfc9110#section-9.2.1
*/
methods?: ('GET' | 'HEAD' | 'OPTIONS' | 'TRACE')[]
methods?: CacheMethods[]
}

/**
Expand All @@ -34,6 +36,11 @@ declare namespace CacheHandler {
* Delete all of the cached responses from a certain origin (host)
*/
deleteByOrigin(origin: string): void | Promise<void>

/**
* Delete all of the cached responses from a certain origin (host)
*/
deleteByOriginAndPath(origin: string, path: string): void | Promise<void>
}

export interface CacheStoreReadable extends Readable {
Expand Down
Loading