From d49f2d334ddfa19fb3b6f9ea5b1723c243767645 Mon Sep 17 00:00:00 2001 From: Fede Barcelona Date: Thu, 27 Aug 2026 16:22:41 +0200 Subject: [PATCH] chore: update sysdig-cli-scanner to 1.29.0 and refresh pinned deps - bump default cli-scanner-version to 1.29.0, oldest supported to 1.23.0 - bump pinned GitHub Actions via pinact (checkout, codeql-action, setup-node, stale, action-gh-release) - update nix flake lock and npm deps Also fixes the README Default-column cell for cli-scanner-version: the whole HTML-comment marker was wrapped in one backtick code-span, so GitHub rendered the comments literally instead of hiding them. Backticks now wrap only the version number. justfile's version substitution sed is updated to preserve backticks around the number when present, so this keeps working across future just update-cli-scanner runs. --- .github/workflows/ci-scan.yaml | 26 +- .github/workflows/ci.yaml | 4 +- .github/workflows/release.yml | 6 +- .github/workflows/scan.yaml | 16 +- .github/workflows/stale.yml | 2 +- README.md | 4 +- action.yml | 4 +- dist/index.js | 1153 ++++++++++++++--- dist/index.js.map | 2 +- flake.lock | 6 +- justfile | 3 +- package-lock.json | 132 +- .../sysdig/SysdigCliScannerConstants.ts | 2 +- 13 files changed, 1047 insertions(+), 313 deletions(-) diff --git a/.github/workflows/ci-scan.yaml b/.github/workflows/ci-scan.yaml index 7ef119c..5a57757 100644 --- a/.github/workflows/ci-scan.yaml +++ b/.github/workflows/ci-scan.yaml @@ -50,7 +50,7 @@ jobs: - bitnami/kubectl:latest steps: - name: Check out repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Scan ${{ matrix.image }} id: scan @@ -80,7 +80,7 @@ jobs: steps: # This step checks out a copy of your repository. - name: Check out repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Scan dummy-vuln-app from registry id: scan @@ -111,7 +111,7 @@ jobs: - name: Upload SARIF file if: success() || failure() # Upload results regardless previous step fails - uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: sarif_file: ${{ github.workspace }}/sarif.json @@ -130,7 +130,7 @@ jobs: steps: # This step checks out a copy of your repository. - name: Check out repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Scan dummy-vuln-app from registry id: scan @@ -148,7 +148,7 @@ jobs: - name: Upload SARIF file if: success() || failure() # Upload results regardless previous step fails - uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: sarif_file: ${{ github.workspace }}/sarif.json @@ -167,14 +167,14 @@ jobs: steps: # This step checks out a copy of your repository. - name: Check out repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Scan dummy-vuln-app from registry id: scan uses: ./ continue-on-error: true with: - cli-scanner-version: 1.22.5 # oldest-version-marker — DO NOT REMOVE; auto-updated by `just update-oldest-cli-scanner` + cli-scanner-version: 1.23.0 # oldest-version-marker — DO NOT REMOVE; auto-updated by `just update-oldest-cli-scanner` # Tag of the image to analyse image-tag: sysdiglabs/dummy-vuln-app:latest # API token for Sysdig Scanning auth @@ -185,7 +185,7 @@ jobs: - name: Upload SARIF file if: success() || failure() # Upload results regardless previous step fails - uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: sarif_file: ${{ github.workspace }}/sarif.json @@ -204,7 +204,7 @@ jobs: steps: # This step checks out a copy of your repository. - name: Check out repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Donate MainDB from scan id: donnor-scan @@ -232,7 +232,7 @@ jobs: - name: Upload SARIF file if: success() || failure() # Upload results regardless previous step fails - uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: sarif_file: ${{ github.workspace }}/sarif.json @@ -242,7 +242,7 @@ jobs: steps: # This step checks out a copy of your repository. - name: Check out repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Scan repository with multiple IaC policies id: scan @@ -270,7 +270,7 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Scan with correct checksum id: scan @@ -296,7 +296,7 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Scan with incorrect checksum id: scan diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4ac185e..9c10427 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -9,8 +9,8 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24 - run: npm ci diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5ff7540..b31521a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ jobs: changes: ${{ steps.check_changes.outputs.changes }} steps: - name: Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 2 @@ -41,7 +41,7 @@ jobs: cancel-in-progress: false steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 fetch-tags: true @@ -63,7 +63,7 @@ jobs: run: git-chglog -c .github/git-chglog/config.yml -o RELEASE_CHANGELOG.md "${{ steps.version.outputs.version }}" - name: Create release - uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1 + uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: name: ${{ steps.version.outputs.version }} tag_name: ${{ steps.version.outputs.version }} diff --git a/.github/workflows/scan.yaml b/.github/workflows/scan.yaml index 7d90f58..0ff348b 100644 --- a/.github/workflows/scan.yaml +++ b/.github/workflows/scan.yaml @@ -10,7 +10,7 @@ jobs: steps: # This step checks out a copy of your repository. - name: Check out repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Scan dummy-vuln-app from registry id: scan @@ -26,7 +26,7 @@ jobs: - name: Upload SARIF file if: success() || failure() # Upload results regardless previous step fails - uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: sarif_file: ${{ github.workspace }}/sarif.json @@ -36,7 +36,7 @@ jobs: steps: # This step checks out a copy of your repository. - name: Check out repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Scan dummy-vuln-app from registry id: scan @@ -53,7 +53,7 @@ jobs: - name: Upload SARIF file if: success() || failure() # Upload results regardless previous step fails - uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: sarif_file: ${{ github.workspace }}/sarif.json @@ -63,7 +63,7 @@ jobs: steps: # This step checks out a copy of your repository. - name: Check out repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Donate MainDB from scan id: donnor-scan @@ -91,7 +91,7 @@ jobs: - name: Upload SARIF file if: success() || failure() # Upload results regardless previous step fails - uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: sarif_file: ${{ github.workspace }}/sarif.json @@ -101,7 +101,7 @@ jobs: steps: # This step checks out a copy of your repository. - name: Check out repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Scan hello-world from registry id: scan @@ -118,6 +118,6 @@ jobs: - name: Upload SARIF file if: success() || failure() # Upload results regardless previous step fails - uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: sarif_file: ${{ github.workspace }}/sarif.json diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 2a60c0e..8db98b9 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -11,7 +11,7 @@ jobs: issues: write pull-requests: write steps: - - uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0 + - uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0 with: stale-issue-message: 'This issue is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 7 days' stale-pr-message: 'This PR is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 7 days' diff --git a/README.md b/README.md index 79e7050..f771228 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ This action performs analysis on a specific container image and posts the result |------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------| | `cli-scanner-url` | URL to `sysdig-cli-scanner` binary download. The action will detect the runner OS and architecture. For more info about the Sysdig CLI Scanner download visit [the official documentation](https://docs.sysdig.com/en/docs/installation/sysdig-secure/install-vulnerability-cli-scanner/). | | | `mode` | Mode of operation. Can be "vm" or "iac". | `vm` | -| `cli-scanner-version` | Custom sysdig-cli-scanner version to download. Versions are supported for 1 year after release; minimum required version is 1.22.5. Please note that for VM mode the Action has only been tested with the current default version and it is not guaranteed that it will work as expected with other versions. | `1.27.2` | +| `cli-scanner-version` | Custom sysdig-cli-scanner version to download. Versions are supported for 1 year after release; minimum required version is 1.23.0. Please note that for VM mode the Action has only been tested with the current default version and it is not guaranteed that it will work as expected with other versions. | `1.29.0` | | `cli-scanner-sha256sum` | SHA256 sum of the Sysdig CLI scanner binary to verify the download. If not provided, the action will automatically download the official checksum from Sysdig for verification. The scanner download is always verified. | | | `registry-user` | Registry username to authenticate to while pulling the image to scan. | | | `registry-password` | Registry password to authenticate to while pulling the image to scan. | | @@ -136,7 +136,7 @@ The `if: success() || failure()` option makes sure the SARIF report is uploaded uses: sysdiglabs/scan-action@v6 with: sysdig-secure-token: ${{ secrets.SYSDIG_SECURE_TOKEN }} - cli-scanner-version: 1.27.2 # newest-version-marker — DO NOT REMOVE; auto-updated by `just update-cli-scanner` + cli-scanner-version: 1.29.0 # newest-version-marker — DO NOT REMOVE; auto-updated by `just update-cli-scanner` mode: iac iac-scan-path: ./terraform ``` diff --git a/action.yml b/action.yml index eb6380d..468a909 100644 --- a/action.yml +++ b/action.yml @@ -5,8 +5,8 @@ inputs: description: URL to sysdig-cli-scanner binary download required: false cli-scanner-version: - description: Custom sysdig-cli-scanner version to download. Versions are supported for 1 year after release; oldest supported version is 1.22.5. # oldest-version-marker — DO NOT REMOVE; auto-updated by `just update-oldest-cli-scanner` - default: "1.27.2" # newest-version-marker — DO NOT REMOVE; auto-updated by `just update-cli-scanner` + description: Custom sysdig-cli-scanner version to download. Versions are supported for 1 year after release; oldest supported version is 1.23.0. # oldest-version-marker — DO NOT REMOVE; auto-updated by `just update-oldest-cli-scanner` + default: "1.29.0" # newest-version-marker — DO NOT REMOVE; auto-updated by `just update-cli-scanner` required: false cli-scanner-sha256sum: description: 'SHA256 sum of the Sysdig CLI scanner binary to verify the download.' diff --git a/dist/index.js b/dist/index.js index 10e67f7..40ad137 100644 --- a/dist/index.js +++ b/dist/index.js @@ -2415,7 +2415,7 @@ exports.scannerURLForVersion = scannerURLForVersion; exports.getRunArch = getRunArch; exports.getRunOS = getRunOS; const os_1 = __importDefault(__nccwpck_require__(2037)); -const cliScannerVersion = "1.27.2"; // newest-version-marker — DO NOT REMOVE; auto-updated by `just update-cli-scanner` +const cliScannerVersion = "1.29.0"; // newest-version-marker — DO NOT REMOVE; auto-updated by `just update-cli-scanner` const cliScannerOS = getRunOS(); const cliScannerArch = getRunArch(); const cliScannerURLBase = "https://download.sysdig.com/scanning/bin/sysdig-cli-scanner"; @@ -8128,17 +8128,62 @@ class MemoryCacheStore extends EventEmitter { } function findEntry (key, entries, now) { - return entries.find((entry) => ( - entry.deleteAt > now && - entry.method === key.method && - (entry.vary == null || Object.keys(entry.vary).every(headerName => { - if (entry.vary[headerName] === null) { - return key.headers[headerName] === undefined + for (let i = 0; i < entries.length; i++) { + const entry = entries[i] + if ( + entry.deleteAt > now && + entry.method === key.method && + varyMatches(key, entry) + ) { + return entry + } + } +} + +function varyMatches (key, entry) { + if (entry.vary == null) { + return true + } + + for (const headerName in entry.vary) { + if (Object.hasOwn(entry.vary, headerName) && !headerValueEquals(key.headers?.[headerName], entry.vary[headerName])) { + return false + } + } + + return true +} + +/** + * @param {string|string[]|null|undefined} lhs + * @param {string|string[]|null|undefined} rhs + * @returns {boolean} + */ +function headerValueEquals (lhs, rhs) { + if (lhs == null && rhs == null) { + return true + } + + if ((lhs == null && rhs != null) || + (lhs != null && rhs == null)) { + return false + } + + if (Array.isArray(lhs) && Array.isArray(rhs)) { + if (lhs.length !== rhs.length) { + return false + } + + for (let i = 0; i < lhs.length; i++) { + if (lhs[i] !== rhs[i]) { + return false } + } - return entry.vary[headerName] === key.headers[headerName] - })) - )) + return true + } + + return lhs === rhs } module.exports = MemoryCacheStore @@ -8606,7 +8651,13 @@ function headerValueEquals (lhs, rhs) { return false } - return lhs.every((x, i) => x === rhs[i]) + for (let i = 0; i < lhs.length; i++) { + if (lhs[i] !== rhs[i]) { + return false + } + } + + return true } return lhs === rhs @@ -10043,7 +10094,13 @@ function processHeader (request, key, val) { } else if (typeof val[i] === 'object') { throw new InvalidArgumentError(`invalid ${key} header`) } else { - arr.push(`${val[i]}`) + // Coerce primitives (and reject unsafe coercions such as functions + // with a crafted toString/Symbol.toPrimitive). + const str = `${val[i]}` + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`) + } + arr.push(str) } } val = arr @@ -10054,7 +10111,12 @@ function processHeader (request, key, val) { } else if (val === null) { val = '' } else { + // Coerce primitives (and reject unsafe coercions such as functions + // with a crafted toString/Symbol.toPrimitive). val = `${val}` + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`) + } } if (headerName === 'host') { @@ -12418,6 +12480,7 @@ const { RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -13542,8 +13605,16 @@ function writeH1 (client, request) { } body = bodyStream.stream contentLength = bodyStream.length - } else if (util.isBlobLike(body) && request.contentType == null && body.type) { - headers.push('content-type', body.type) + } else if (util.isBlobLike(body) && request.contentType == null) { + const contentType = body.type + if (contentType) { + const contentTypeValue = `${contentType}` + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request, new InvalidArgumentError('invalid content-type header')) + return false + } + headers.push('content-type', contentTypeValue) + } } if (body && typeof body.read === 'function') { @@ -17659,7 +17730,10 @@ module.exports = { const util = __nccwpck_require__(3983) const { parseCacheControlHeader, + hasInvalidCacheControlDirective, parseVaryHeader, + hasVaryStar, + isInvalidOrWildcardVaryHeader, isEtagUsable } = __nccwpck_require__(8047) const { parseHttpDate } = __nccwpck_require__(817) @@ -17682,6 +17756,92 @@ const NOT_UNDERSTOOD_STATUS_CODES = [ const MAX_RESPONSE_AGE = 2147483647000 +function trimOWS (value) { + return value.replace(/^[\t ]+|[\t ]+$/g, '') +} + +function arrayIncludes (array, value) { + for (let i = 0; i < array.length; i++) { + if (array[i] === value) { + return true + } + } + + return false +} + +function appendConnectionHeaderTokens (headersToRemove, connectionHeader) { + const values = Array.isArray(connectionHeader) ? connectionHeader : [connectionHeader] + + for (let i = 0; i < values.length; i++) { + const tokens = values[i].split(',') + for (let j = 0; j < tokens.length; j++) { + headersToRemove.push(trimOWS(tokens[j]).toLowerCase()) + } + } +} + +function getSameOriginPath (cacheKey, location) { + if (typeof location !== 'string') { + return undefined + } + + let originUrl + let requestUrl + let locationUrl + try { + originUrl = new URL(cacheKey.origin) + requestUrl = new URL(cacheKey.path, originUrl) + locationUrl = new URL(location, requestUrl) + } catch { + return undefined + } + + if (locationUrl.origin !== originUrl.origin) { + return undefined + } + + return locationUrl.pathname + locationUrl.search +} + +function deleteCachedUri (store, cacheKey, path) { + deleteCachedValue(store, { + ...cacheKey, + path + }) + + for (let i = 0; i < util.safeHTTPMethods.length; i++) { + const method = util.safeHTTPMethods[i] + if (method !== cacheKey.method) { + deleteCachedValue(store, { + ...cacheKey, + method, + path + }) + } + } +} + +function deleteLocationTargets (store, cacheKey, headerValue) { + if (headerValue === undefined) { + return + } + + const values = Array.isArray(headerValue) ? headerValue : [headerValue] + for (let i = 0; i < values.length; i++) { + const path = getSameOriginPath(cacheKey, values[i]) + if (path !== undefined) { + deleteCachedUri(store, cacheKey, path) + } + } +} + +function invalidateUnsafeRequest (store, cacheKey, resHeaders) { + deleteCachedUri(store, cacheKey, cacheKey.path) + deleteLocationTargets(store, cacheKey, resHeaders.location) + deleteLocationTargets(store, cacheKey, resHeaders['content-location']) +} + /** * @typedef {import('../../types/dispatcher.d.ts').default.DispatchHandler} DispatchHandler * @@ -17763,28 +17923,28 @@ class CacheHandler { const handler = this if ( - !util.safeHTTPMethods.includes(this.#cacheKey.method) && + !arrayIncludes(util.safeHTTPMethods, this.#cacheKey.method) && statusCode >= 200 && statusCode <= 399 ) { // Successful response to an unsafe method, delete it from cache // https://www.rfc-editor.org/rfc/rfc9111.html#name-invalidating-stored-response - try { - this.#store.delete(this.#cacheKey)?.catch?.(noop) - } catch { - // Fail silently - } + invalidateUnsafeRequest(this.#store, this.#cacheKey, resHeaders) return downstreamOnHeaders() } const cacheControlHeader = resHeaders['cache-control'] - const heuristicallyCacheable = resHeaders['last-modified'] && HEURISTICALLY_CACHEABLE_STATUS_CODES.includes(statusCode) + const heuristicallyCacheable = resHeaders['last-modified'] && arrayIncludes(HEURISTICALLY_CACHEABLE_STATUS_CODES, statusCode) if ( !cacheControlHeader && !resHeaders['expires'] && !heuristicallyCacheable && !this.#cacheByDefault ) { + if (statusCode === 304 && resHeaders.vary && isInvalidOrWildcardVaryHeader(resHeaders.vary)) { + deleteCachedValue(this.#store, this.#cacheKey) + } + // Don't have anything to tell us this response is cachable and we're not // caching by default return downstreamOnHeaders() @@ -17792,31 +17952,46 @@ class CacheHandler { const cacheControlDirectives = cacheControlHeader ? parseCacheControlHeader(cacheControlHeader) : {} if (!canCacheResponse(this.#cacheType, statusCode, resHeaders, cacheControlDirectives, this.#cacheKey.headers)) { + if (statusCode === 304 && (cacheControlHeader || revalidationResponseDisallowsCachedReuse(this.#cacheType, resHeaders, cacheControlDirectives))) { + deleteCachedValue(this.#store, this.#cacheKey) + } + return downstreamOnHeaders() } const now = Date.now() - const resAge = resHeaders.age ? getAge(resHeaders.age) : undefined - if (resAge && resAge >= MAX_RESPONSE_AGE) { + const resAge = Object.hasOwn(resHeaders, 'age') ? getAge(resHeaders.age) : undefined + if (resAge !== undefined && resAge >= MAX_RESPONSE_AGE) { // Response considered stale + deleteCachedValueIfNotModified(statusCode, this.#store, this.#cacheKey) return downstreamOnHeaders() } - const resDate = typeof resHeaders.date === 'string' - ? parseHttpDate(resHeaders.date) - : undefined + const resDate = Object.hasOwn(resHeaders, 'date') ? getDate(resHeaders.date) : undefined + if (resDate === null) { + deleteCachedValueIfNotModified(statusCode, this.#store, this.#cacheKey) + return downstreamOnHeaders() + } + + const apparentAge = resDate ? Math.max(0, now - resDate.getTime()) : 0 + const currentAge = Math.max(apparentAge, resAge ?? 0) const staleAt = determineStaleAt(this.#cacheType, now, resAge, resHeaders, resDate, cacheControlDirectives) ?? this.#cacheByDefault - if (staleAt === undefined || (resAge && resAge > staleAt)) { + if (staleAt === undefined || currentAge >= staleAt) { + if (cacheControlHeader || staleAt !== undefined) { + deleteCachedValueIfNotModified(statusCode, this.#store, this.#cacheKey) + } + return downstreamOnHeaders() } - const baseTime = resDate ? resDate.getTime() : now + const baseTime = now - currentAge const absoluteStaleAt = staleAt + baseTime if (now >= absoluteStaleAt) { // Response is already stale + deleteCachedValueIfNotModified(statusCode, this.#store, this.#cacheKey) return downstreamOnHeaders() } @@ -17829,7 +18004,8 @@ class CacheHandler { } } - const deleteAt = determineDeleteAt(baseTime, cacheControlDirectives, absoluteStaleAt) + const cachedAt = baseTime + const deleteAt = determineDeleteAt(baseTime, now, cacheControlDirectives, absoluteStaleAt) const strippedHeaders = stripNecessaryHeaders(resHeaders, cacheControlDirectives) /** @@ -17841,7 +18017,7 @@ class CacheHandler { headers: strippedHeaders, vary: varyDirectives, cacheControlDirectives, - cachedAt: resAge ? now - resAge : now, + cachedAt, staleAt: absoluteStaleAt, deleteAt } @@ -17859,6 +18035,7 @@ class CacheHandler { value.statusCode = cachedValue.statusCode value.statusMessage = cachedValue.statusMessage value.etag = cachedValue.etag + value.vary = varyDirectives ?? cachedValue.vary value.headers = { ...cachedValue.headers, ...strippedHeaders } downstreamOnHeaders() @@ -17989,6 +18166,36 @@ class CacheHandler { } } +/** + * @param {import('../../types/cache-interceptor.d.ts').default.CacheStore} store + * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} cacheKey + */ +function deleteCachedValue (store, cacheKey) { + try { + store.delete(cacheKey)?.catch?.(noop) + } catch { + // Fail silently + } +} + +function deleteCachedValueIfNotModified (statusCode, store, cacheKey) { + if (statusCode === 304) { + deleteCachedValue(store, cacheKey) + } +} + +/** + * @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions['type']} cacheType + * @param {import('../../types/header.d.ts').IncomingHttpHeaders} resHeaders + * @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives + * @returns {boolean} + */ +function revalidationResponseDisallowsCachedReuse (cacheType, resHeaders, cacheControlDirectives) { + return cacheControlDirectives['no-store'] === true || + (cacheType === 'shared' && cacheControlDirectives.private === true) || + (resHeaders.vary ? isInvalidOrWildcardVaryHeader(resHeaders.vary) : false) +} + /** * @see https://www.rfc-editor.org/rfc/rfc9111.html#name-storing-responses-to-authen * @@ -18000,12 +18207,12 @@ class CacheHandler { */ function canCacheResponse (cacheType, statusCode, resHeaders, cacheControlDirectives, reqHeaders) { // Status code must be final and understood. - if (statusCode < 200 || NOT_UNDERSTOOD_STATUS_CODES.includes(statusCode)) { + if (statusCode < 200 || arrayIncludes(NOT_UNDERSTOOD_STATUS_CODES, statusCode)) { return false } // Responses with neither status codes that are heuristically cacheable, nor "explicit enough" caching // directives, are not cacheable. "Explicit enough": see https://www.rfc-editor.org/rfc/rfc9111.html#section-3 - if (!HEURISTICALLY_CACHEABLE_STATUS_CODES.includes(statusCode) && !resHeaders['expires'] && + if (!arrayIncludes(HEURISTICALLY_CACHEABLE_STATUS_CODES, statusCode) && !resHeaders['expires'] && !cacheControlDirectives.public && cacheControlDirectives['max-age'] === undefined && // RFC 9111: a private response directive, if the cache is not shared @@ -18024,12 +18231,12 @@ function canCacheResponse (cacheType, statusCode, resHeaders, cacheControlDirect } // https://www.rfc-editor.org/rfc/rfc9111.html#section-4.1-5 - if (resHeaders.vary?.includes('*')) { + if (resHeaders.vary && hasVaryStar(resHeaders.vary)) { return false } // https://www.rfc-editor.org/rfc/rfc9111.html#name-storing-responses-to-authen - if (reqHeaders?.authorization) { + if (reqHeaders != null && Object.hasOwn(reqHeaders, 'authorization')) { if ( !cacheControlDirectives.public && !cacheControlDirectives['s-maxage'] && @@ -18044,14 +18251,14 @@ function canCacheResponse (cacheType, statusCode, resHeaders, cacheControlDirect if ( Array.isArray(cacheControlDirectives['no-cache']) && - cacheControlDirectives['no-cache'].includes('authorization') + arrayIncludes(cacheControlDirectives['no-cache'], 'authorization') ) { return false } if ( Array.isArray(cacheControlDirectives['private']) && - cacheControlDirectives['private'].includes('authorization') + arrayIncludes(cacheControlDirectives['private'], 'authorization') ) { return false } @@ -18060,14 +18267,51 @@ function canCacheResponse (cacheType, statusCode, resHeaders, cacheControlDirect return true } +/** + * @param {string | string[]} dateHeader + * @returns {Date | null | undefined} + */ +function getDate (dateHeader) { + let dateValue = dateHeader + if (Array.isArray(dateValue)) { + if (dateValue.length !== 1) { + return null + } + + dateValue = dateValue[0] + } + + if (typeof dateValue !== 'string') { + return null + } + + return parseHttpDate(dateValue) +} + /** * @param {string | string[]} ageHeader * @returns {number | undefined} */ function getAge (ageHeader) { - const age = parseInt(Array.isArray(ageHeader) ? ageHeader[0] : ageHeader) + let ageValue = ageHeader + if (Array.isArray(ageValue)) { + if (ageValue.length !== 1) { + return MAX_RESPONSE_AGE + } + + ageValue = ageValue[0] + } - return isNaN(age) ? undefined : age * 1000 + if (typeof ageValue !== 'string' || !/^[\t ]*[0-9]+[\t ]*$/.test(ageValue)) { + return MAX_RESPONSE_AGE + } + + const age = BigInt(ageValue.replace(/^[\t ]+|[\t ]+$/g, '')) + if (age >= BigInt(MAX_RESPONSE_AGE / 1000)) { + return MAX_RESPONSE_AGE + } + + return Number(age) * 1000 } /** @@ -18085,43 +18329,60 @@ function determineStaleAt (cacheType, now, age, resHeaders, responseDate, cacheC // Prioritize s-maxage since we're a shared cache // s-maxage > max-age > Expire // https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.10-3 + if (hasInvalidCacheControlDirective(cacheControlDirectives, 's-maxage')) { + return 0 + } + const sMaxAge = cacheControlDirectives['s-maxage'] if (sMaxAge !== undefined) { - return sMaxAge > 0 ? sMaxAge * 1000 : undefined + return sMaxAge * 1000 } } + if (hasInvalidCacheControlDirective(cacheControlDirectives, 'max-age')) { + return 0 + } + const maxAge = cacheControlDirectives['max-age'] if (maxAge !== undefined) { - return maxAge > 0 ? maxAge * 1000 : undefined + return maxAge * 1000 } - if (typeof resHeaders.expires === 'string') { + if (Object.hasOwn(resHeaders, 'expires')) { // https://www.rfc-editor.org/rfc/rfc9111.html#section-5.3 + if (typeof resHeaders.expires !== 'string') { + return 0 + } + const expiresDate = parseHttpDate(resHeaders.expires) - if (expiresDate) { - if (now >= expiresDate.getTime()) { - return undefined - } + if (!expiresDate) { + return 0 + } - if (responseDate) { - if (responseDate >= expiresDate) { - return undefined - } + if (now >= expiresDate.getTime()) { + return 0 + } - if (age !== undefined && age > (expiresDate - responseDate)) { - return undefined - } + if (responseDate) { + if (responseDate >= expiresDate) { + return 0 } - return expiresDate.getTime() - now + const freshnessLifetime = expiresDate.getTime() - responseDate.getTime() + if (age !== undefined && age >= freshnessLifetime) { + return 0 + } + + return freshnessLifetime } + + return expiresDate.getTime() - now } if (typeof resHeaders['last-modified'] === 'string') { // https://www.rfc-editor.org/rfc/rfc9111.html#name-calculating-heuristic-fresh - const lastModified = new Date(resHeaders['last-modified']) - if (isValidDate(lastModified)) { + const lastModified = parseHttpDate(resHeaders['last-modified']) + if (lastModified) { if (lastModified.getTime() >= now) { return undefined } @@ -18134,18 +18395,19 @@ function determineStaleAt (cacheType, now, age, resHeaders, responseDate, cacheC if (cacheControlDirectives.immutable) { // https://www.rfc-editor.org/rfc/rfc8246.html#section-2.2 - return 31536000 + return 31536000000 } return undefined } /** - * @param {number} now + * @param {number} baseTime + * @param {number} cachedAt * @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives * @param {number} staleAt */ -function determineDeleteAt (now, cacheControlDirectives, staleAt) { +function determineDeleteAt (baseTime, cachedAt, cacheControlDirectives, staleAt) { let staleWhileRevalidate = -Infinity let staleIfError = -Infinity let immutable = -Infinity @@ -18159,15 +18421,21 @@ function determineDeleteAt (now, cacheControlDirectives, staleAt) { } if (cacheControlDirectives.immutable && staleWhileRevalidate === -Infinity && staleIfError === -Infinity) { - immutable = now + 31536000000 + immutable = cachedAt + 31536000000 } // When no stale directives or immutable flag, add a revalidation buffer // equal to the freshness lifetime so the entry survives past staleAt long // enough to be revalidated instead of silently disappearing. + // + // Response Date headers only have second precision, so baseTime can trail the + // actual cache insertion time by up to ~1s. Pad the buffer by that bounded + // skew so short-lived entries do not disappear exactly when they should be + // revalidated. if (staleWhileRevalidate === -Infinity && staleIfError === -Infinity && immutable === -Infinity) { - const freshnessLifetime = staleAt - now - return staleAt + freshnessLifetime + const freshnessLifetime = staleAt - baseTime + const datePrecisionPadding = Math.min(Math.max(cachedAt - baseTime, 0), 1000) + return staleAt + freshnessLifetime + datePrecisionPadding } return Math.max(staleAt, staleWhileRevalidate, staleIfError, immutable) @@ -18194,14 +18462,7 @@ function stripNecessaryHeaders (resHeaders, cacheControlDirectives) { ] if (resHeaders['connection']) { - if (Array.isArray(resHeaders['connection'])) { - // connection: a - // connection: b - headersToRemove.push(...resHeaders['connection'].map(header => header.trim())) - } else { - // connection: a, b - headersToRemove.push(...resHeaders['connection'].split(',').map(header => header.trim())) - } + appendConnectionHeaderTokens(headersToRemove, resHeaders['connection']) } if (Array.isArray(cacheControlDirectives['no-cache'])) { @@ -18214,7 +18475,7 @@ function stripNecessaryHeaders (resHeaders, cacheControlDirectives) { let strippedHeaders for (const headerName of headersToRemove) { - if (resHeaders[headerName]) { + if (Object.hasOwn(resHeaders, headerName)) { strippedHeaders ??= { ...resHeaders } delete strippedHeaders[headerName] } @@ -18223,14 +18484,6 @@ function stripNecessaryHeaders (resHeaders, cacheControlDirectives) { return strippedHeaders ?? resHeaders } -/** - * @param {Date} date - * @returns {boolean} - */ -function isValidDate (date) { - return date instanceof Date && Number.isFinite(date.valueOf()) -} - module.exports = CacheHandler @@ -18261,7 +18514,7 @@ class CacheRevalidationHandler { #successful = false /** - * @type {((boolean, any) => void) | null} + * @type {((success: boolean, context?: any, statusCode?: number, headers?: import('../../types/header.d.ts').IncomingHttpHeaders) => void) | null} */ #callback @@ -18278,7 +18531,7 @@ class CacheRevalidationHandler { #allowErrorStatusCodes /** - * @param {(boolean) => void} callback Function to call if the cached value is valid + * @param {(success: boolean, context?: any, statusCode?: number, headers?: import('../../types/header.d.ts').IncomingHttpHeaders) => void} callback Function to call if the cached value is valid * @param {import('../../types/dispatcher.d.ts').default.DispatchHandlers} handler * @param {boolean} allowErrorStatusCodes */ @@ -18313,7 +18566,7 @@ class CacheRevalidationHandler { // https://datatracker.ietf.org/doc/html/rfc5861#section-4 this.#successful = statusCode === 304 || (this.#allowErrorStatusCodes && statusCode >= 500 && statusCode <= 504) - this.#callback(this.#successful, this.#context) + this.#callback(this.#successful, this.#context, statusCode, headers) this.#callback = null if (this.#successful) { @@ -19178,6 +19431,26 @@ function calculateRetryAfterHeader (retryAfter) { return isNaN(retryTime) ? 0 : retryTime - Date.now() } +function validatePartialResponseContentLength (headers, range, statusCode, retryCount) { + const contentLength = headers['content-length'] + if (contentLength == null) { + return + } + + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return + } + + const length = Number(contentLength) + const expectedLength = range.end - range.start + 1 + if (!Number.isFinite(length) || length !== expectedLength) { + throw new RequestRetryError('Content-Length mismatch', statusCode, { + headers, + data: { count: retryCount } + }) + } +} + class RetryHandler { constructor (opts, { dispatch, handler }) { const { retryOptions, ...dispatchOpts } = opts @@ -19392,6 +19665,8 @@ class RetryHandler { }) } + validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount) + const { start, size, end = size ? size - 1 : null } = contentRange assert(this.start === start, 'content-range mismatch') @@ -19416,6 +19691,8 @@ class RetryHandler { return } + validatePartialResponseContentLength(headers, range, statusCode, this.retryCount) + const { start, size, end = size ? size - 1 : null } = range assert( start != null && Number.isFinite(start), @@ -19798,8 +20075,9 @@ const util = __nccwpck_require__(3983) const CacheHandler = __nccwpck_require__(7665) const MemoryCacheStore = __nccwpck_require__(8393) const CacheRevalidationHandler = __nccwpck_require__(4976) -const { assertCacheStore, assertCacheMethods, makeCacheKey, normalizeHeaders, parseCacheControlHeader } = __nccwpck_require__(8047) +const { assertCacheStore, assertCacheMethods, makeCacheKey, normalizeHeaders, parseCacheControlHeader, isInvalidOrWildcardVaryHeader } = __nccwpck_require__(8047) const { AbortError } = __nccwpck_require__(8045) +const { parseHttpDate } = __nccwpck_require__(817) /** * @param {(string | RegExp)[] | undefined} origins @@ -19820,6 +20098,44 @@ function assertCacheOrigins (origins, name) { const nop = () => {} +function trimOWS (value) { + return value.replace(/^[\t ]+|[\t ]+$/g, '') +} + +function arrayIncludes (array, value) { + for (let i = 0; i < array.length; i++) { + if (array[i] === value) { + return true + } + } + + return false +} + +function hasPragmaNoCache (headers) { + const pragma = headers?.pragma + if (!pragma) { + return false + } + + const values = Array.isArray(pragma) ? pragma : [pragma] + for (let i = 0; i < values.length; i++) { + const value = values[i] + if (typeof value !== 'string') { + continue + } + + const directives = value.split(',') + for (let j = 0; j < directives.length; j++) { + if (trimOWS(directives[j]).toLowerCase() === 'no-cache') { + return true + } + } + } + + return false +} + /** * @typedef {(options: import('../../types/dispatcher.d.ts').default.DispatchOptions, handler: import('../../types/dispatcher.d.ts').default.DispatchHandler) => void} DispatchFn */ @@ -19849,16 +20165,92 @@ function needsRevalidation (result, cacheControlDirectives, { headers = {} }) { return false } +/** + * @param {import('../../types/cache-interceptor.d.ts').default.GetResult} result + * @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions['type']} cacheType + * @returns {boolean} + */ +function staleResponseRequiresRevalidation (result, cacheType) { + return result.cacheControlDirectives?.['must-revalidate'] === true || + (cacheType === 'shared' && ( + result.cacheControlDirectives?.['proxy-revalidate'] === true || + // https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.10 + // s-maxage implies proxy-revalidate for shared caches. + result.cacheControlDirectives?.['s-maxage'] !== undefined + )) +} + +/** + * @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions['type']} cacheType + * @param {import('../../types/header.d.ts').IncomingHttpHeaders} headers + * @returns {boolean} + */ +function revalidationResponseDisallowsCachedReuse (cacheType, headers) { + if (headers.vary && isInvalidOrWildcardVaryHeader(headers.vary)) { + return true + } + + const cacheControl = headers['cache-control'] + if (!cacheControl) { + return false + } + + const cacheControlDirectives = parseCacheControlHeader(cacheControl) + return cacheControlDirectives['no-store'] === true || + (cacheType === 'shared' && cacheControlDirectives.private === true) +} + +function revalidationResponseUpdatesCacheControl (headers) { + return headers['cache-control'] !== undefined +} + +function deleteCachedValue (store, cacheKey) { + try { + store.delete(cacheKey)?.catch?.(nop) + } catch { + // Fail silently + } +} + +function getUsableLastModified (headers) { + const lastModified = headers?.['last-modified'] + if (typeof lastModified === 'string' && parseHttpDate(lastModified)) { + return lastModified + } +} + +function makeRevalidationHeaders (opts, result) { + const headers = { + ...opts.headers, + 'if-modified-since': getUsableLastModified(result.headers) ?? new Date(result.cachedAt).toUTCString() + } + + if (result.etag) { + headers['if-none-match'] = result.etag + } + + if (result.vary) { + for (const key in result.vary) { + if (result.vary[key] != null) { + headers[key] = result.vary[key] + } + } + } + + return headers +} + /** * @param {import('../../types/cache-interceptor.d.ts').default.GetResult} result * @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives | undefined} cacheControlDirectives + * @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions['type']} cacheType * @returns {boolean} */ -function isStale (result, cacheControlDirectives) { +function isStale (result, cacheControlDirectives, cacheType) { const now = Date.now() if (now > result.staleAt) { // Response is stale - if (cacheControlDirectives?.['max-stale']) { + if (!staleResponseRequiresRevalidation(result, cacheType) && cacheControlDirectives?.['max-stale']) { // There's a threshold where we can serve stale responses, let's see if // we're in it // https://www.rfc-editor.org/rfc/rfc9111.html#name-max-stale @@ -19885,11 +20277,12 @@ function isStale (result, cacheControlDirectives) { /** * Check if we're within the stale-while-revalidate window for a stale response * @param {import('../../types/cache-interceptor.d.ts').default.GetResult} result + * @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions['type']} cacheType * @returns {boolean} */ -function withinStaleWhileRevalidateWindow (result) { +function withinStaleWhileRevalidateWindow (result, cacheType) { const staleWhileRevalidate = result.cacheControlDirectives?.['stale-while-revalidate'] - if (!staleWhileRevalidate) { + if (!staleWhileRevalidate || staleResponseRequiresRevalidation(result, cacheType)) { return false } @@ -20059,14 +20452,10 @@ function handleResult ( } const age = Math.round((now - result.cachedAt) / 1000) - if (reqCacheControl?.['max-age'] && age >= reqCacheControl['max-age']) { - // Response is considered expired for this specific request - // https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.1.1 - return dispatch(opts, handler) - } + const requestMaxAgeExpired = reqCacheControl?.['max-age'] !== undefined && age >= reqCacheControl['max-age'] - const stale = isStale(result, reqCacheControl) - const revalidate = needsRevalidation(result, reqCacheControl, opts) + const stale = requestMaxAgeExpired || isStale(result, reqCacheControl, globalOpts.type) + const revalidate = requestMaxAgeExpired || needsRevalidation(result, reqCacheControl, opts) // Check if the response is stale if (stale || revalidate) { @@ -20078,28 +20467,13 @@ function handleResult ( // RFC 5861: If we're within stale-while-revalidate window, serve stale immediately // and revalidate in background, unless immediate revalidation is necessary - if (!revalidate && withinStaleWhileRevalidateWindow(result)) { + if (!revalidate && withinStaleWhileRevalidateWindow(result, globalOpts.type)) { // Serve stale response immediately sendCachedValue(handler, opts, result, age, null, true) // Start background revalidation (fire-and-forget) queueMicrotask(() => { - const headers = { - ...opts.headers, - 'if-modified-since': new Date(result.cachedAt).toUTCString() - } - - if (result.etag) { - headers['if-none-match'] = result.etag - } - - if (result.vary) { - for (const key in result.vary) { - if (result.vary[key] != null) { - headers[key] = result.vary[key] - } - } - } + const headers = makeRevalidationHeaders(opts, result) // Background revalidation - update cache if we get new data dispatch( @@ -20123,28 +20497,15 @@ function handleResult ( } let withinStaleIfErrorThreshold = false - const staleIfErrorExpiry = result.cacheControlDirectives['stale-if-error'] ?? reqCacheControl?.['stale-if-error'] - if (staleIfErrorExpiry) { - withinStaleIfErrorThreshold = now < (result.staleAt + (staleIfErrorExpiry * 1000)) - } - - const headers = { - ...opts.headers, - 'if-modified-since': new Date(result.cachedAt).toUTCString() - } - - if (result.etag) { - headers['if-none-match'] = result.etag - } - - if (result.vary) { - for (const key in result.vary) { - if (result.vary[key] != null) { - headers[key] = result.vary[key] - } + if (!staleResponseRequiresRevalidation(result, globalOpts.type)) { + const staleIfErrorExpiry = result.cacheControlDirectives['stale-if-error'] ?? reqCacheControl?.['stale-if-error'] + if (staleIfErrorExpiry) { + withinStaleIfErrorThreshold = now < (result.staleAt + (staleIfErrorExpiry * 1000)) } } + const headers = makeRevalidationHeaders(opts, result) + // We need to revalidate the response return dispatch( { @@ -20152,8 +20513,23 @@ function handleResult ( headers }, new CacheRevalidationHandler( - (success, context) => { + (success, context, statusCode, headers) => { if (success) { + if (statusCode === 304) { + if (revalidationResponseDisallowsCachedReuse(globalOpts.type, headers)) { + if (util.isStream(result.body)) { + result.body.on('error', nop).destroy() + } + + deleteCachedValue(globalOpts.store, cacheKey) + return dispatch(opts, new CacheHandler(globalOpts, cacheKey, handler)) + } + + if (revalidationResponseUpdatesCacheControl(headers)) { + deleteCachedValue(globalOpts.store, cacheKey) + } + } + // TODO: successful revalidation should be considered fresh (not give stale warning). sendCachedValue(handler, opts, result, age, context, stale) } else if (util.isStream(result.body)) { @@ -20210,11 +20586,17 @@ module.exports = (opts = {}) => { type } - const safeMethodsToNotCache = util.safeHTTPMethods.filter(method => methods.includes(method) === false) + const safeMethodsToNotCache = [] + for (let i = 0; i < util.safeHTTPMethods.length; i++) { + const method = util.safeHTTPMethods[i] + if (!arrayIncludes(methods, method)) { + safeMethodsToNotCache.push(method) + } + } return dispatch => { return (opts, handler) => { - if (!opts.origin || safeMethodsToNotCache.includes(opts.method)) { + if (!opts.origin || arrayIncludes(safeMethodsToNotCache, opts.method)) { // Not a method we want to cache or we don't have the origin, skip return dispatch(opts, handler) } @@ -20249,7 +20631,9 @@ module.exports = (opts = {}) => { const reqCacheControl = opts.headers?.['cache-control'] ? parseCacheControlHeader(opts.headers['cache-control']) - : undefined + : hasPragmaNoCache(opts.headers) + ? { 'no-cache': true } + : undefined if (reqCacheControl?.['no-store']) { return dispatch(opts, handler) @@ -24765,11 +25149,147 @@ module.exports = { const { safeHTTPMethods, pathHasQueryOrFragment, - hasSafeIterator + hasSafeIterator, + isValidHTTPToken } = __nccwpck_require__(3983) const { serializePathWithQuery } = __nccwpck_require__(3983) +const MAX_DELTA_SECONDS = 2147483647 +const RESTRICTIVE_DIRECTIVE_NAMES = ['no-store', 'private', 'no-cache'] +const kInvalidCacheControlDirectives = Symbol('invalid cache-control directives') + +function trimOWS (value) { + return value.replace(/^[\t ]+|[\t ]+$/g, '') +} + +function arrayIncludes (array, value) { + for (let i = 0; i < array.length; i++) { + if (array[i] === value) { + return true + } + } + + return false +} + +function trimOWSStart (value) { + return value.replace(/^[\t ]+/, '') +} + +function trimOWSEnd (value) { + return value.replace(/[\t ]+$/, '') +} + +function findUnescapedQuote (value, start) { + let escaped = false + for (let i = start; i < value.length; i++) { + if (escaped) { + escaped = false + } else if (value[i] === '\\') { + escaped = true + } else if (value[i] === '"') { + return i + } + } + + return -1 +} + +function splitCacheControlHeaderValue (value) { + const directives = [] + let start = 0 + let quoteStart = -1 + let inQuote = false + let escaped = false + + for (let i = 0; i < value.length; i++) { + if (inQuote) { + if (escaped) { + escaped = false + } else if (value[i] === '\\') { + escaped = true + } else if (value[i] === '"') { + inQuote = false + quoteStart = -1 + } + } else if (value[i] === '"') { + inQuote = true + quoteStart = i + } else if (value[i] === ',') { + directives.push({ value: value.substring(start, i), fromMalformedQuote: false }) + start = i + 1 + } + } + + if (!inQuote) { + directives.push({ value: value.substring(start), fromMalformedQuote: false }) + return directives + } + + const tail = value.substring(start) + const quoteOffset = quoteStart - start + let tailStart = 0 + for (let i = 0; i < tail.length; i++) { + if (tail[i] === ',') { + directives.push({ + value: tail.substring(tailStart, i), + fromMalformedQuote: tailStart > quoteOffset + }) + tailStart = i + 1 + } + } + + directives.push({ + value: tail.substring(tailStart), + fromMalformedQuote: tailStart > quoteOffset + }) + return directives +} + +function markInvalidCacheControlDirective (directives, key) { + let invalidDirectives = directives[kInvalidCacheControlDirectives] + + if (invalidDirectives === undefined) { + invalidDirectives = new Set() + Object.defineProperty(directives, kInvalidCacheControlDirectives, { + value: invalidDirectives + }) + } + + invalidDirectives.add(key) +} + +function hasInvalidCacheControlDirective (directives, key) { + return directives[kInvalidCacheControlDirectives]?.has(key) === true +} + +function getMalformedRestrictiveDirectiveName (key) { + for (const directiveName of RESTRICTIVE_DIRECTIVE_NAMES) { + if ( + key.startsWith(directiveName) && + key.length > directiveName.length && + !isValidHTTPToken(key[directiveName.length]) + ) { + return directiveName + } + } + + let tokenOnlyKey = '' + let hasInvalidTokenChar = false + for (let i = 0; i < key.length; i++) { + if (isValidHTTPToken(key[i])) { + tokenOnlyKey += key[i] + } else { + hasInvalidTokenChar = true + } + } + + if (hasInvalidTokenChar && arrayIncludes(RESTRICTIVE_DIRECTIVE_NAMES, tokenOnlyKey)) { + return tokenOnlyKey + } +} + /** * @param {import('../../types/dispatcher.d.ts').default.DispatchOptions} opts */ @@ -24792,6 +25312,20 @@ function makeCacheKey (opts) { } } +function appendHeader (headers, key, val) { + const headerName = key.toLowerCase() + const current = headers[headerName] + const values = Array.isArray(val) ? val : [val] + + if (current === undefined) { + headers[headerName] = Array.isArray(val) ? val.slice() : val + } else if (Array.isArray(current)) { + current.push(...values) + } else { + headers[headerName] = [current, ...values] + } +} + /** * @param {Record} * @returns {Record} @@ -24812,11 +25346,11 @@ function normalizeHeaders (opts) { if (typeof key !== 'string' || typeof val !== 'string') { throw new Error('opts.headers is not a valid header map') } - headers[key.toLowerCase()] = val + appendHeader(headers, key, val) } } else { for (const key of Object.keys(opts.headers)) { - headers[key.toLowerCase()] = opts.headers[key] + appendHeader(headers, key, opts.headers[key]) } } } else { @@ -24888,29 +25422,37 @@ function parseCacheControlHeader (header) { * @type {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} */ const output = {} + const invalidNumericDirectives = new Set() + const invalidNoArgumentDirectives = new Set() - let directives - if (Array.isArray(header)) { - directives = [] - - for (const directive of header) { - directives.push(...directive.split(',')) - } - } else { - directives = header.split(',') - } + const directives = splitCacheControlHeaderValue(Array.isArray(header) ? header.join(',') : header) for (let i = 0; i < directives.length; i++) { - const directive = directives[i].toLowerCase() + const directiveRecord = directives[i] + const directive = directiveRecord.value.toLowerCase() + const fromMalformedQuote = directiveRecord.fromMalformedQuote const keyValueDelimiter = directive.indexOf('=') let key let value + let keyHasTrailingWhitespace = false + let valueHasLeadingWhitespace = false if (keyValueDelimiter !== -1) { - key = directive.substring(0, keyValueDelimiter).trimStart() - value = directive.substring(keyValueDelimiter + 1) + const rawKey = directive.substring(0, keyValueDelimiter) + const rawValue = directive.substring(keyValueDelimiter + 1) + + keyHasTrailingWhitespace = trimOWSEnd(rawKey) !== rawKey + valueHasLeadingWhitespace = trimOWSStart(rawValue) !== rawValue + key = trimOWS(rawKey) + value = trimOWSStart(rawValue) } else { - key = directive.trim() + key = trimOWS(directive) + } + + const malformedRestrictiveDirectiveName = getMalformedRestrictiveDirectiveName(key) + if (malformedRestrictiveDirectiveName !== undefined) { + output[malformedRestrictiveDirectiveName] = true + continue } switch (key) { @@ -24920,7 +25462,14 @@ function parseCacheControlHeader (header) { case 's-maxage': case 'stale-while-revalidate': case 'stale-if-error': { - if (value === undefined || value[0] === ' ') { + if (fromMalformedQuote || invalidNumericDirectives.has(key)) { + continue + } + + if (value === undefined || keyHasTrailingWhitespace || valueHasLeadingWhitespace) { + delete output[key] + invalidNumericDirectives.add(key) + markInvalidCacheControlDirective(output, key) continue } @@ -24932,22 +25481,37 @@ function parseCacheControlHeader (header) { value = value.substring(1, value.length - 1) } - const parsedValue = parseInt(value, 10) - // eslint-disable-next-line no-self-compare - if (parsedValue !== parsedValue) { + if (!/^[0-9]+$/.test(value)) { + delete output[key] + invalidNumericDirectives.add(key) + markInvalidCacheControlDirective(output, key) continue } - if (key === 'max-age' && key in output && output[key] >= parsedValue) { - continue - } + const parsedValue = Math.min(parseInt(value, 10), MAX_DELTA_SECONDS) - output[key] = parsedValue + if (key === 'min-fresh') { + if (!(key in output) || output[key] < parsedValue) { + output[key] = parsedValue + } + } else if (!(key in output) || output[key] > parsedValue) { + output[key] = parsedValue + } break } case 'private': case 'no-cache': { + if (fromMalformedQuote) { + output[key] = true + break + } + + if (value !== undefined && value.length === 0) { + output[key] = true + break + } + if (value) { // The private and no-cache directives can be unqualified (aka just // `private` or `no-cache`) or qualified (w/ a value). When they're @@ -24955,45 +25519,64 @@ function parseCacheControlHeader (header) { // `no-cache="header1"`, or `no-cache="header1, header2"` // If we're given multiple headers, the comma messes us up since // we split the full header by commas. So, let's loop through the - // remaining parts in front of us until we find one that ends in a - // quote. We can then just splice all of the parts in between the - // starting quote and the ending quote out of the directives array - // and continue parsing like normal. + // remaining parts in front of us until we find one that contains a + // closing quote. We can then skip the consumed quoted-list fragments and + // continue parsing like normal. // https://www.rfc-editor.org/rfc/rfc9111.html#name-no-cache-2 if (value[0] === '"') { // Something like `no-cache="some-header"` OR `no-cache="some-header, another-header"`. + value = trimOWSEnd(value) - // Add the first header on and cut off the leading quote - const headers = [value.substring(1)] + let fieldList = '' + let lastQuotedPart = i + let foundEndingQuote = false + const closingQuote = findUnescapedQuote(value, 1) - let foundEndingQuote = value[value.length - 1] === '"' - if (!foundEndingQuote) { + if (closingQuote !== -1) { + fieldList = value.substring(1, closingQuote) + foundEndingQuote = true + } else { // Something like `no-cache="some-header, another-header"` // This can still be something invalid, e.g. `no-cache="some-header, ...` + const fieldListParts = [value.substring(1)] + for (let j = i + 1; j < directives.length; j++) { - const nextPart = directives[j] - const nextPartLength = nextPart.length + const nextPart = trimOWS(directives[j].value) + const closingQuote = findUnescapedQuote(nextPart, 0) - headers.push(nextPart.trim()) + lastQuotedPart = j - if (nextPartLength !== 0 && nextPart[nextPartLength - 1] === '"') { + if (closingQuote !== -1) { + fieldListParts.push(nextPart.substring(0, closingQuote)) foundEndingQuote = true break } + + fieldListParts.push(nextPart) } + + fieldList = fieldListParts.join(',') } - if (foundEndingQuote) { - let lastHeader = headers[headers.length - 1] - if (lastHeader[lastHeader.length - 1] === '"') { - lastHeader = lastHeader.substring(0, lastHeader.length - 1) - headers[headers.length - 1] = lastHeader - } + if (!foundEndingQuote) { + output[key] = true + break + } - for (let j = 0; j < headers.length; j++) { - headers[j] = headers[j].trim() + i = lastQuotedPart + + const headers = fieldList.split(',') + let validFieldNames = true + for (let j = 0; j < headers.length; j++) { + headers[j] = trimOWS(headers[j]) + if (!isValidHTTPToken(headers[j])) { + validFieldNames = false } + } + if (!validFieldNames) { + output[key] = true + } else if (output[key] !== true) { if (key in output) { output[key] = output[key].concat(headers) } else { @@ -25001,13 +25584,17 @@ function parseCacheControlHeader (header) { } } } else { - // Something like `no-cache="some-header"` - const fieldName = value.trim() + // Something like `no-cache=some-header` + const fieldName = trimOWS(value) - if (key in output) { - output[key] = output[key].concat(fieldName) - } else { - output[key] = [fieldName] + if (!isValidHTTPToken(fieldName)) { + output[key] = true + } else if (output[key] !== true) { + if (key in output) { + output[key] = output[key].concat(fieldName) + } else { + output[key] = [fieldName] + } } } @@ -25016,19 +25603,27 @@ function parseCacheControlHeader (header) { } // eslint-disable-next-line no-fallthrough case 'public': - case 'no-store': case 'must-revalidate': case 'proxy-revalidate': case 'immutable': case 'no-transform': case 'must-understand': case 'only-if-cached': - if (value) { + if (fromMalformedQuote || invalidNoArgumentDirectives.has(key)) { + continue + } + + if (value !== undefined) { // These are qualified (something like `public=...`) when they aren't - // allowed to be, skip + // allowed to be, skip all instances of the malformed directive. + delete output[key] + invalidNoArgumentDirectives.add(key) continue } + output[key] = true + break + case 'no-store': output[key] = true break default: @@ -25040,31 +25635,79 @@ function parseCacheControlHeader (header) { return output } +/** + * @param {string | string[]} varyHeader Vary header from the server + * @returns {string[]} + */ +function splitVaryHeader (varyHeader) { + const values = Array.isArray(varyHeader) ? varyHeader : [varyHeader] + const output = [] + + for (let i = 0; i < values.length; i++) { + const parts = values[i].split(',') + for (let j = 0; j < parts.length; j++) { + output.push(parts[j]) + } + } + + return output +} + +/** + * @param {string | string[]} varyHeader Vary header from the server + * @returns {boolean} + */ +function hasVaryStar (varyHeader) { + const values = splitVaryHeader(varyHeader) + for (let i = 0; i < values.length; i++) { + if (trimOWS(values[i]).indexOf('*') !== -1) { + return true + } + } + + return false +} + /** * @param {string | string[]} varyHeader Vary header from the server * @param {Record} headers Request headers - * @returns {Record} + * @returns {Record | undefined} */ function parseVaryHeader (varyHeader, headers) { - if (typeof varyHeader === 'string' && varyHeader.includes('*')) { + if (hasVaryStar(varyHeader)) { return headers } const output = /** @type {Record} */ ({}) - const varyingHeaders = typeof varyHeader === 'string' - ? varyHeader.split(',') - : varyHeader + const varyingHeaders = splitVaryHeader(varyHeader) for (const header of varyingHeaders) { - const trimmedHeader = header.trim().toLowerCase() + const trimmedHeader = trimOWS(header).toLowerCase() - output[trimmedHeader] = headers[trimmedHeader] ?? null + if (trimmedHeader.length === 0) { + continue + } + + if (!isValidHTTPToken(trimmedHeader)) { + return undefined + } + + const headerValue = headers[trimmedHeader] + output[trimmedHeader] = Array.isArray(headerValue) ? headerValue.slice() : headerValue ?? null } return output } +/** + * @param {string | string[]} varyHeader Vary header from the server + * @returns {boolean} + */ +function isInvalidOrWildcardVaryHeader (varyHeader) { + return hasVaryStar(varyHeader) || parseVaryHeader(varyHeader, {}) === undefined +} + /** * Note: this deviates from the spec a little. Empty etags ("", W/"") are valid, * however, including them in cached resposnes serves little to no purpose. @@ -25128,7 +25771,7 @@ function assertCacheMethods (methods, name = 'CacheMethods') { } for (const method of methods) { - if (!safeHTTPMethods.includes(method)) { + if (!arrayIncludes(safeHTTPMethods, method)) { throw new TypeError(`element of ${name}-array needs to be one of following values: ${safeHTTPMethods.join(', ')}, got ${method}`) } } @@ -25168,7 +25811,10 @@ module.exports = { assertCacheKey, assertCacheValue, parseCacheControlHeader, + hasInvalidCacheControlDirective, parseVaryHeader, + hasVaryStar, + isInvalidOrWildcardVaryHeader, isEtagUsable, assertCacheMethods, assertCacheStore, @@ -25202,6 +25848,26 @@ function parseHttpDate (date) { } } +function makeDate (year, monthIdx, day, hour, minute, second, weekday) { + const result = new Date(Date.UTC(year, monthIdx, day, hour, minute, second)) + + // Date.UTC treats years 0-99 as 1900-1999. Reset the full year so component + // checks below validate the HTTP date as written. + if (year >= 0 && year <= 99) { + result.setUTCFullYear(year) + } + + return result.getUTCFullYear() === year && + result.getUTCMonth() === monthIdx && + result.getUTCDate() === day && + result.getUTCHours() === hour && + result.getUTCMinutes() === minute && + result.getUTCSeconds() === second && + result.getUTCDay() === weekday + ? result + : undefined +} + /** * @see https://httpwg.org/specs/rfc9110.html#preferred.date.format * @@ -25408,8 +26074,7 @@ function parseImfDate (date) { second = (code1 - 48) * 10 + (code2 - 48) // Convert ASCII codes to number } - const result = new Date(Date.UTC(year, monthIdx, day, hour, minute, second)) - return result.getUTCDay() === weekday ? result : undefined + return makeDate(year, monthIdx, day, hour, minute, second, weekday) } /** @@ -25613,8 +26278,7 @@ function parseAscTimeDate (date) { } const year = (yearDigit1 - 48) * 1000 + (yearDigit2 - 48) * 100 + (yearDigit3 - 48) * 10 + (yearDigit4 - 48) - const result = new Date(Date.UTC(year, monthIdx, day, hour, minute, second)) - return result.getUTCDay() === weekday ? result : undefined + return makeDate(year, monthIdx, day, hour, minute, second, weekday) } /** @@ -25828,8 +26492,7 @@ function parseRfc850Date (date) { second = (code1 - 48) * 10 + (code2 - 48) // Convert ASCII codes to number } - const result = new Date(Date.UTC(year, monthIdx, day, hour, minute, second)) - return result.getUTCDay() === weekday ? result : undefined + return makeDate(year, monthIdx, day, hour, minute, second, weekday) } module.exports = { @@ -28225,7 +28888,7 @@ function validateCookiePath (path) { if ( code < 0x20 || // exclude CTLs (0-31) - code === 0x7F || // DEL + code > 0x7E || // exclude DEL and non-ascii code === 0x3B // ; ) { throw new Error('Invalid cookie path') @@ -28234,16 +28897,80 @@ function validateCookiePath (path) { } /** - * I have no idea why these values aren't allowed to be honest, - * but Deno tests these. - Khafra + * ::= | + * + * ::= any one of the 52 alphabetic characters A through Z in + * upper case and a through z in lower case + * + * ::= any one of the ten digits 0 through 9r + * + * @see https://www.rfc-editor.org/rfc/rfc1034#section-3.5 + * @param {number} code + */ +function isLetterOrDigit (code) { + return ( + (code >= 0x30 && code <= 0x39) || // 0-9 + (code >= 0x41 && code <= 0x5A) || // A-Z + (code >= 0x61 && code <= 0x7A) // a-z + ) +} + +/** + * Validates a cookie domain against the "preferred name syntax". + * + * ::= | " " + * ::=