diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml index 112a98b6e72..a8bbc5e3d90 100644 --- a/.github/workflows/bench.yml +++ b/.github/workflows/bench.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Code - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2 with: persist-credentials: false ref: ${{ github.base_ref }} @@ -34,7 +34,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Code - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2 with: persist-credentials: false - name: Setup Node @@ -55,7 +55,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Code - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2 with: persist-credentials: false ref: ${{ github.base_ref }} @@ -77,7 +77,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Code - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2 with: persist-credentials: false - name: Setup Node diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 47f83e6c296..8025f85bffd 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -46,7 +46,7 @@ jobs: egress-policy: audit - name: Checkout repository - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index d86863210fa..d7368f39bd6 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2 with: persist-credentials: false diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index deabbc6dc78..28f66097a3e 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -5,26 +5,40 @@ on: schedule: - cron: "0 10 * * *" +permissions: + contents: read + jobs: - test: + test-linux: + if: github.repository == 'nodejs/undici' + uses: ./.github/workflows/test.yml + with: + node-version: 22-nightly + runs-on: ubuntu-latest + secrets: inherit + + test-windows: + if: github.repository == 'nodejs/undici' + uses: ./.github/workflows/test.yml + with: + node-version: 22-nightly + runs-on: windows-latest + secrets: inherit + + test-macos: if: github.repository == 'nodejs/undici' - strategy: - fail-fast: false - max-parallel: 0 - matrix: - runs-on: - - ubuntu-latest - - windows-latest - - macos-latest uses: ./.github/workflows/test.yml with: node-version: 22-nightly - runs-on: ${{ matrix.runs-on }} + runs-on: macos-latest secrets: inherit report-failure: - if: failure() - needs: test + if: ${{ always() && (needs.test-linux.result == 'failure' && needs.test-windows.result == 'failure' && needs.test-macos.result == 'failure') }} + needs: + - test-linux + - test-windows + - test-macos runs-on: ubuntu-latest permissions: issues: write diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index 6373d4ac7e7..1eca5e7fce5 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -23,7 +23,7 @@ jobs: egress-policy: audit - name: Checkout - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2 with: persist-credentials: false @@ -35,7 +35,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2 with: persist-credentials: false @@ -69,13 +69,84 @@ jobs: runs-on: ${{ matrix.runs-on }} secrets: inherit + test-without-intl: + name: Test with Node.js ${{ matrix.version }} compiled --without-intl + strategy: + fail-fast: false + max-parallel: 0 + matrix: + version: [20, 21] + runs-on: ubuntu-latest + timeout-minutes: 120 + steps: + - name: Checkout + uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2 + with: + persist-credentials: false + + # Setup node, install deps, and build undici prior to building icu-less node and testing + - name: Setup Node.js@${{ inputs.version }} + uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + with: + node-version: ${{ inputs.version }} + + - name: Install dependencies + run: npm install + + - name: Build undici + run: npm run build:node + + - name: Determine latest release + id: release + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + result-encoding: string + script: | + const req = await fetch('https://nodejs.org/download/release/index.json') + const releases = await req.json() + + const latest = releases.find((r) => r.version.startsWith('v${{ matrix.version }}')) + return latest.version + + - name: Download and extract source + run: curl https://nodejs.org/download/release/${{ steps.release.outputs.result }}/node-${{ steps.release.outputs.result }}.tar.xz | tar xfJ - + + - name: Install ninja + run: sudo apt-get install ninja-build + + - name: ccache + uses: hendrikmuhs/ccache-action@faf867a11c028c0b483fb2ae72b6fc8f7d842714 #v1.2.12 + with: + key: node${{ matrix.version }} + + - name: Build node + working-directory: ./node-${{ steps.release.outputs.result }} + run: | + export CC="ccache gcc" + export CXX="ccache g++" + ./configure --without-intl --ninja --prefix=./final + make + make install + echo "$(pwd)/final/bin" >> $GITHUB_PATH + + - name: Print version information + run: | + echo OS: $(node -p "os.version()") + echo Node.js: $(node --version) + echo npm: $(npm --version) + echo git: $(git --version) + echo icu config: $(node -e "console.log(process.config)" | grep icu) + + - name: Run tests + run: npm run test:javascript:withoutintl + test-types: name: Test TypeScript types timeout-minutes: 15 runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2 with: persist-credentials: false @@ -97,12 +168,12 @@ jobs: - dependency-review - test - test-types + - test-without-intl - lint runs-on: ubuntu-latest permissions: contents: write pull-requests: write - actions: write steps: - name: Merge Dependabot PR uses: fastify/github-action-merge-dependabot@9e7bfb249c69139d7bdcd8d984f9665edd49020b # v3.10.1 diff --git a/.github/workflows/publish-undici-types.yml b/.github/workflows/publish-undici-types.yml index 92bb1943138..5ebbce01505 100644 --- a/.github/workflows/publish-undici-types.yml +++ b/.github/workflows/publish-undici-types.yml @@ -13,7 +13,7 @@ jobs: publish: runs-on: ubuntu-latest steps: - - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2 - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 with: node-version: lts/* diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 8289262a937..a5dcb80095c 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -29,7 +29,7 @@ jobs: steps: - name: "Checkout code" - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2 with: persist-credentials: false diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9cebbb20bef..84201924a75 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,6 +10,9 @@ on: required: true type: string +permissions: + contents: read + jobs: test: name: Test with Node.js ${{ inputs.node-version }} on ${{ inputs.runs-on }} @@ -17,7 +20,7 @@ jobs: runs-on: ${{ inputs.runs-on }} steps: - name: Checkout - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2 with: persist-credentials: false @@ -48,6 +51,6 @@ jobs: - name: Coverage Report if: inputs.runs-on == 'ubuntu-latest' && inputs.node-version == 20 - uses: codecov/codecov-action@54bcd8715eee62d40e33596ef5e8f0f48dbbccab # v4.1.0 + uses: codecov/codecov-action@c16abc29c95fcf9174b58eb7e1abf4c866893bc8 # v4.1.1 with: token: ${{ secrets.CODECOV_TOKEN }} diff --git a/README.md b/README.md index 72c32de1346..476c0280c8f 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,12 @@ An HTTP/1.1 client, written from scratch for Node.js. > Undici means eleven in Italian. 1.1 -> 11 -> Eleven -> Undici. It is also a Stranger Things reference. +## How to get involved + Have a question about using Undici? Open a [Q&A Discussion](https://github.com/nodejs/undici/discussions/new) or join our official OpenJS [Slack](https://openjs-foundation.slack.com/archives/C01QF9Q31QD) channel. +Looking to contribute? Start by reading the [contributing guide](./CONTRIBUTING.md) + ## Install ``` diff --git a/benchmarks/fetch/bytes-match.mjs b/benchmarks/fetch/bytes-match.mjs new file mode 100644 index 00000000000..6c6b263499d --- /dev/null +++ b/benchmarks/fetch/bytes-match.mjs @@ -0,0 +1,24 @@ +import { createHash } from 'node:crypto' +import { bench, run } from 'mitata' +import { bytesMatch } from '../../lib/web/fetch/util.js' + +const body = Buffer.from('Hello world!') +const validSha256Base64 = `sha256-${createHash('sha256').update(body).digest('base64')}` +const invalidSha256Base64 = `sha256-${createHash('sha256').update(body).digest('base64')}` +const validSha256Base64Url = `sha256-${createHash('sha256').update(body).digest('base64url')}` +const invalidSha256Base64Url = `sha256-${createHash('sha256').update(body).digest('base64url')}` + +bench('bytesMatch valid sha256 and base64', () => { + bytesMatch(body, validSha256Base64) +}) +bench('bytesMatch invalid sha256 and base64', () => { + bytesMatch(body, invalidSha256Base64) +}) +bench('bytesMatch valid sha256 and base64url', () => { + bytesMatch(body, validSha256Base64Url) +}) +bench('bytesMatch invalid sha256 and base64url', () => { + bytesMatch(body, invalidSha256Base64Url) +}) + +await run() diff --git a/lib/core/util.js b/lib/core/util.js index a62396e23e0..e7b5d9c1edd 100644 --- a/lib/core/util.js +++ b/lib/core/util.js @@ -246,9 +246,6 @@ function bufferToLowerCasedHeaderName (value) { * @returns {Record} */ function parseHeaders (headers, obj) { - // For H2 support - if (!Array.isArray(headers)) return headers - if (obj === undefined) obj = {} for (let i = 0; i < headers.length; i += 2) { const key = headerNameToString(headers[i]) diff --git a/lib/dispatcher/client-h2.js b/lib/dispatcher/client-h2.js index c4a785b62d4..28cb7bc6fa3 100644 --- a/lib/dispatcher/client-h2.js +++ b/lib/dispatcher/client-h2.js @@ -79,6 +79,26 @@ const { } } = http2 +function parseH2Headers (headers) { + const result = [] + + for (const [name, value] of Object.entries(headers)) { + // h2 may concat the header value by array + // e.g. Set-Cookie + if (Array.isArray(value)) { + for (const subvalue of value) { + // we need to provide each header value of header name + // because the headers handler expect name-value pair + result.push(Buffer.from(name), Buffer.from(subvalue)) + } + } else { + result.push(Buffer.from(name), Buffer.from(value)) + } + } + + return result +} + async function connectH2 (client, socket) { client[kSocket] = socket @@ -414,7 +434,19 @@ function writeH2 (client, request) { stream.once('response', headers => { const { [HTTP2_HEADER_STATUS]: statusCode, ...realHeaders } = headers - + + // Due to the stream nature, it is possible we face a race condition + // where the stream has been assigned, but the request has been aborted + // the request remains in-flight and headers hasn't been received yet + // for those scenarios, best effort is to destroy the stream immediately + // as there's no value to keep it open. + if (request.aborted || request.completed) { + const err = new RequestAbortedError() + errorRequest(client, request, err) + util.destroy(stream, err) + return + } + const resume = stream.resume.bind(stream) if (request.onStart(new Controller(stream)) === false) { diff --git a/lib/handler/redirect-handler.js b/lib/handler/redirect-handler.js index d1d3b6191f1..7e26abb50a4 100644 --- a/lib/handler/redirect-handler.js +++ b/lib/handler/redirect-handler.js @@ -205,9 +205,9 @@ function shouldRemoveHeader (header, removeContent, unknownOrigin) { if (removeContent && util.headerNameToString(header).startsWith('content-')) { return true } - if (unknownOrigin && (header.length === 13 || header.length === 6)) { + if (unknownOrigin && (header.length === 13 || header.length === 6 || header.length === 19)) { const name = util.headerNameToString(header) - return name === 'authorization' || name === 'cookie' + return name === 'authorization' || name === 'cookie' || name === 'proxy-authorization' } return false } diff --git a/lib/mock/pending-interceptors-formatter.js b/lib/mock/pending-interceptors-formatter.js index ba6e4ebce1b..ccca951195a 100644 --- a/lib/mock/pending-interceptors-formatter.js +++ b/lib/mock/pending-interceptors-formatter.js @@ -3,6 +3,9 @@ const { Transform } = require('node:stream') const { Console } = require('node:console') +const PERSISTENT = process.versions.icu ? '✅' : 'Y ' +const NOT_PERSISTENT = process.versions.icu ? '❌' : 'N ' + /** * Gets the output of `console.table(…)` as a string. */ @@ -29,7 +32,7 @@ module.exports = class PendingInterceptorsFormatter { Origin: origin, Path: path, 'Status code': statusCode, - Persistent: persist ? '✅' : '❌', + Persistent: persist ? PERSISTENT : NOT_PERSISTENT, Invocations: timesInvoked, Remaining: persist ? Infinity : times - timesInvoked })) diff --git a/lib/web/fetch/data-url.js b/lib/web/fetch/data-url.js index ef292011c2c..ddef36390f3 100644 --- a/lib/web/fetch/data-url.js +++ b/lib/web/fetch/data-url.js @@ -8,12 +8,12 @@ const encoder = new TextEncoder() * @see https://mimesniff.spec.whatwg.org/#http-token-code-point */ const HTTP_TOKEN_CODEPOINTS = /^[!#$%&'*+-.^_|~A-Za-z0-9]+$/ -const HTTP_WHITESPACE_REGEX = /[\u000A|\u000D|\u0009|\u0020]/ // eslint-disable-line +const HTTP_WHITESPACE_REGEX = /[\u000A\u000D\u0009\u0020]/ // eslint-disable-line const ASCII_WHITESPACE_REPLACE_REGEX = /[\u0009\u000A\u000C\u000D\u0020]/g // eslint-disable-line /** * @see https://mimesniff.spec.whatwg.org/#http-quoted-string-token-code-point */ -const HTTP_QUOTED_STRING_TOKENS = /[\u0009|\u0020-\u007E|\u0080-\u00FF]/ // eslint-disable-line +const HTTP_QUOTED_STRING_TOKENS = /[\u0009\u0020-\u007E\u0080-\u00FF]/ // eslint-disable-line // https://fetch.spec.whatwg.org/#data-url-processor /** @param {URL} dataURL */ diff --git a/lib/web/fetch/headers.js b/lib/web/fetch/headers.js index b849b0e356c..2235f125c65 100644 --- a/lib/web/fetch/headers.js +++ b/lib/web/fetch/headers.js @@ -12,7 +12,7 @@ const { } = require('./util') const { webidl } = require('./webidl') const assert = require('node:assert') -const util = require('util') +const util = require('node:util') const kHeadersMap = Symbol('headers map') const kHeadersSortedMap = Symbol('headers map sorted') diff --git a/lib/web/fetch/index.js b/lib/web/fetch/index.js index d8c20c59bf7..58d61585c06 100644 --- a/lib/web/fetch/index.js +++ b/lib/web/fetch/index.js @@ -2141,29 +2141,6 @@ async function httpNetworkFetch ( codings = contentEncoding.toLowerCase().split(',').map((x) => x.trim()) } location = headersList.get('location', true) - } else { - const keys = Object.keys(rawHeaders) - for (let i = 0; i < keys.length; ++i) { - // The header names are already in lowercase. - const key = keys[i] - const value = rawHeaders[key] - if (key === 'set-cookie') { - for (let j = 0; j < value.length; ++j) { - headersList.append(key, value[j], true) - } - } else { - headersList.append(key, value, true) - } - } - // For H2, The header names are already in lowercase, - // so we can avoid the `HeadersList#get` call here. - const contentEncoding = rawHeaders['content-encoding'] - if (contentEncoding) { - // https://www.rfc-editor.org/rfc/rfc7231#section-3.1.2.1 - // "All content-coding values are case-insensitive..." - codings = contentEncoding.toLowerCase().split(',').map((x) => x.trim()).reverse() - } - location = rawHeaders.location } this.body = new Readable({ read: resume }) diff --git a/lib/web/fetch/util.js b/lib/web/fetch/util.js index a1ef3f47a9d..824413bc873 100644 --- a/lib/web/fetch/util.js +++ b/lib/web/fetch/util.js @@ -11,11 +11,15 @@ const assert = require('node:assert') const { isUint8Array } = require('node:util/types') const { webidl } = require('./webidl') +let supportedHashes = [] + // https://nodejs.org/api/crypto.html#determining-if-crypto-support-is-unavailable /** @type {import('crypto')} */ let crypto try { crypto = require('node:crypto') + const possibleRelevantHashes = ['sha256', 'sha384', 'sha512'] + supportedHashes = crypto.getHashes().filter((hash) => possibleRelevantHashes.includes(hash)) /* c8 ignore next 3 */ } catch { @@ -565,66 +569,56 @@ function bytesMatch (bytes, metadataList) { return true } - // 3. If parsedMetadata is the empty set, return true. + // 3. If response is not eligible for integrity validation, return false. + // TODO + + // 4. If parsedMetadata is the empty set, return true. if (parsedMetadata.length === 0) { return true } - // 4. Let metadata be the result of getting the strongest + // 5. Let metadata be the result of getting the strongest // metadata from parsedMetadata. - const list = parsedMetadata.sort((c, d) => d.algo.localeCompare(c.algo)) - // get the strongest algorithm - const strongest = list[0].algo - // get all entries that use the strongest algorithm; ignore weaker - const metadata = list.filter((item) => item.algo === strongest) + const strongest = getStrongestMetadata(parsedMetadata) + const metadata = filterMetadataListByAlgorithm(parsedMetadata, strongest) - // 5. For each item in metadata: + // 6. For each item in metadata: for (const item of metadata) { // 1. Let algorithm be the alg component of item. const algorithm = item.algo // 2. Let expectedValue be the val component of item. - let expectedValue = item.hash + const expectedValue = item.hash // See https://github.com/web-platform-tests/wpt/commit/e4c5cc7a5e48093220528dfdd1c4012dc3837a0e // "be liberal with padding". This is annoying, and it's not even in the spec. - if (expectedValue.endsWith('==')) { - expectedValue = expectedValue.slice(0, -2) - } - // 3. Let actualValue be the result of applying algorithm to bytes. let actualValue = crypto.createHash(algorithm).update(bytes).digest('base64') - if (actualValue.endsWith('==')) { - actualValue = actualValue.slice(0, -2) + if (actualValue[actualValue.length - 1] === '=') { + if (actualValue[actualValue.length - 2] === '=') { + actualValue = actualValue.slice(0, -2) + } else { + actualValue = actualValue.slice(0, -1) + } } // 4. If actualValue is a case-sensitive match for expectedValue, // return true. - if (actualValue === expectedValue) { - return true - } - - let actualBase64URL = crypto.createHash(algorithm).update(bytes).digest('base64url') - - if (actualBase64URL.endsWith('==')) { - actualBase64URL = actualBase64URL.slice(0, -2) - } - - if (actualBase64URL === expectedValue) { + if (compareBase64Mixed(actualValue, expectedValue)) { return true } } - // 6. Return false. + // 7. Return false. return false } // https://w3c.github.io/webappsec-subresource-integrity/#grammardef-hash-with-options // https://www.w3.org/TR/CSP2/#source-list-syntax // https://www.rfc-editor.org/rfc/rfc5234#appendix-B.1 -const parseHashWithOptions = /(?sha256|sha384|sha512)-(?[A-Za-z0-9+/]+={0,2}(?=\s|$))( +[!-~]*)?/i +const parseHashWithOptions = /(?sha256|sha384|sha512)-((?[A-Za-z0-9+/]+|[A-Za-z0-9_-]+)={0,2}(?:\s|$)( +[!-~]*)?)?/i /** * @see https://w3c.github.io/webappsec-subresource-integrity/#parse-metadata @@ -638,8 +632,6 @@ function parseMetadata (metadata) { // 2. Let empty be equal to true. let empty = true - const supportedHashes = crypto.getHashes() - // 3. For each token returned by splitting metadata on spaces: for (const token of metadata.split(' ')) { // 1. Set empty to false. @@ -649,7 +641,11 @@ function parseMetadata (metadata) { const parsedToken = parseHashWithOptions.exec(token) // 3. If token does not parse, continue to the next token. - if (parsedToken === null || parsedToken.groups === undefined) { + if ( + parsedToken === null || + parsedToken.groups === undefined || + parsedToken.groups.algo === undefined + ) { // Note: Chromium blocks the request at this point, but Firefox // gives a warning that an invalid integrity was given. The // correct behavior is to ignore these, and subsequently not @@ -658,11 +654,11 @@ function parseMetadata (metadata) { } // 4. Let algorithm be the hash-algo component of token. - const algorithm = parsedToken.groups.algo + const algorithm = parsedToken.groups.algo.toLowerCase() // 5. If algorithm is a hash function recognized by the user // agent, add the parsed token to result. - if (supportedHashes.includes(algorithm.toLowerCase())) { + if (supportedHashes.includes(algorithm)) { result.push(parsedToken.groups) } } @@ -675,6 +671,82 @@ function parseMetadata (metadata) { return result } +/** + * @param {{ algo: 'sha256' | 'sha384' | 'sha512' }[]} metadataList + */ +function getStrongestMetadata (metadataList) { + // Let algorithm be the algo component of the first item in metadataList. + // Can be sha256 + let algorithm = metadataList[0].algo + // If the algorithm is sha512, then it is the strongest + // and we can return immediately + if (algorithm[3] === '5') { + return algorithm + } + + for (let i = 1; i < metadataList.length; ++i) { + const metadata = metadataList[i] + // If the algorithm is sha512, then it is the strongest + // and we can break the loop immediately + if (metadata.algo[3] === '5') { + algorithm = 'sha512' + break + // If the algorithm is sha384, then a potential sha256 or sha384 is ignored + } else if (algorithm[3] === '3') { + continue + // algorithm is sha256, check if algorithm is sha384 and if so, set it as + // the strongest + } else if (metadata.algo[3] === '3') { + algorithm = 'sha384' + } + } + return algorithm +} + +function filterMetadataListByAlgorithm (metadataList, algorithm) { + if (metadataList.length === 1) { + return metadataList + } + + let pos = 0 + for (let i = 0; i < metadataList.length; ++i) { + if (metadataList[i].algo === algorithm) { + metadataList[pos++] = metadataList[i] + } + } + + metadataList.length = pos + + return metadataList +} + +/** + * Compares two base64 strings, allowing for base64url + * in the second string. + * +* @param {string} actualValue always base64 + * @param {string} expectedValue base64 or base64url + * @returns {boolean} + */ +function compareBase64Mixed (actualValue, expectedValue) { + if (actualValue.length !== expectedValue.length) { + return false + } + for (let i = 0; i < actualValue.length; ++i) { + if (actualValue[i] !== expectedValue[i]) { + if ( + (actualValue[i] === '+' && expectedValue[i] === '-') || + (actualValue[i] === '/' && expectedValue[i] === '_') + ) { + continue + } + return false + } + } + + return true +} + // https://w3c.github.io/webappsec-upgrade-insecure-requests/#upgrade-request function tryUpgradeRequestToAPotentiallyTrustworthyURL (request) { // TODO diff --git a/package.json b/package.json index f7d7bfd5b9a..751c3c48f39 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "undici", - "version": "6.10.2", + "version": "6.11.1", "description": "An HTTP/1.1 client, written from scratch for Node.js", "homepage": "https://undici.nodejs.org", "bugs": { @@ -69,10 +69,13 @@ "lint:fix": "standard --fix | snazzy", "test": "npm run test:javascript && cross-env NODE_V8_COVERAGE= npm run test:typescript", "test:javascript": "node scripts/generate-pem && npm run test:unit && npm run test:node-fetch && npm run test:fetch && npm run test:cookies && npm run test:eventsource && npm run test:wpt && npm run test:websocket && npm run test:node-test && npm run test:jest", + "test:javascript:withoutintl": "node scripts/generate-pem && npm run test:unit && npm run test:node-fetch && npm run test:fetch:nobuild && npm run test:cookies && npm run test:eventsource:nobuild && npm run test:wpt:withoutintl && npm run test:node-test", "test:cookies": "borp -p \"test/cookie/*.js\"", "test:node-fetch": "borp -p \"test/node-fetch/**/*.js\"", - "test:eventsource": "npm run build:node && borp --expose-gc -p \"test/eventsource/*.js\"", - "test:fetch": "npm run build:node && borp --expose-gc -p \"test/fetch/*.js\" && borp -p \"test/webidl/*.js\" && borp -p \"test/busboy/*.js\"", + "test:eventsource": "npm run build:node && npm run test:eventsource:nobuild", + "test:eventsource:nobuild": "borp --expose-gc -p \"test/eventsource/*.js\"", + "test:fetch": "npm run build:node && npm run test:fetch:nobuild", + "test:fetch:nobuild": "borp --expose-gc -p \"test/fetch/*.js\" && borp -p \"test/webidl/*.js\" && borp -p \"test/busboy/*.js\"", "test:jest": "cross-env NODE_V8_COVERAGE= jest", "test:unit": "borp --expose-gc -p \"test/*.js\"", "test:node-test": "borp -p \"test/node-test/**/*.js\"", @@ -81,6 +84,7 @@ "test:typescript": "tsd && tsc --skipLibCheck test/imports/undici-import.ts", "test:websocket": "borp -p \"test/websocket/*.js\"", "test:wpt": "node test/wpt/start-fetch.mjs && node test/wpt/start-FileAPI.mjs && node test/wpt/start-mimesniff.mjs && node test/wpt/start-xhr.mjs && node test/wpt/start-websockets.mjs && node test/wpt/start-cacheStorage.mjs && node test/wpt/start-eventsource.mjs", + "test:wpt:withoutintl": "node test/wpt/start-fetch.mjs && node test/wpt/start-mimesniff.mjs && node test/wpt/start-xhr.mjs && node test/wpt/start-cacheStorage.mjs && node test/wpt/start-eventsource.mjs", "coverage": "npm run coverage:clean && cross-env NODE_V8_COVERAGE=./coverage/tmp npm run test:javascript && npm run coverage:report", "coverage:ci": "npm run coverage:clean && cross-env NODE_V8_COVERAGE=./coverage/tmp npm run test:javascript && npm run coverage:report:ci", "coverage:clean": "node ./scripts/clean-coverage.js", @@ -96,7 +100,7 @@ "@sinonjs/fake-timers": "^11.1.0", "@types/node": "^18.0.3", "abort-controller": "^3.0.0", - "borp": "^0.9.1", + "borp": "^0.10.0", "c8": "^9.1.0", "cross-env": "^7.0.3", "dns-packet": "^5.4.0", @@ -112,7 +116,7 @@ "proxy": "^2.1.1", "snazzy": "^9.0.0", "standard": "^17.0.0", - "tsd": "^0.30.1", + "tsd": "^0.31.0", "typescript": "^5.0.2", "ws": "^8.11.0" }, diff --git a/test/connect-timeout.js b/test/connect-timeout.js index 98f7c466bbf..0a28e1f250d 100644 --- a/test/connect-timeout.js +++ b/test/connect-timeout.js @@ -9,7 +9,7 @@ const assert = require('node:assert') // Using describe instead of test to avoid the timeout describe('prioritize socket errors over timeouts', async () => { const t = tspl({ ...assert, after: () => {} }, { plan: 1 }) - const client = new Pool('http://foobar.bar:1234', { connectTimeout: 1 }) + const client = new Pool('http://foorbar.invalid:1234', { connectTimeout: 1 }) client.request({ method: 'GET', path: '/foobar' }) .then(() => t.fail()) diff --git a/test/fetch/headers-inspect-custom.js b/test/fetch/headers-inspect-custom.js index 1aa3326e98c..8145e9d7536 100644 --- a/test/fetch/headers-inspect-custom.js +++ b/test/fetch/headers-inspect-custom.js @@ -3,7 +3,7 @@ const { Headers } = require('../../lib/web/fetch/headers') const { test } = require('node:test') const assert = require('node:assert') -const util = require('util') +const util = require('node:util') test('Headers class custom inspection', () => { const headers = new Headers() diff --git a/test/fetch/http2.js b/test/fetch/http2.js index 0793a21556c..f64756de788 100644 --- a/test/fetch/http2.js +++ b/test/fetch/http2.js @@ -34,7 +34,7 @@ test('[Fetch] Issue#2311', async (t) => { res.end(body) }) - const { strictEqual } = tspl(t, { plan: 1 }) + const { strictEqual } = tspl(t, { plan: 2 }) server.listen() await once(server, 'listening') @@ -65,6 +65,7 @@ test('[Fetch] Issue#2311', async (t) => { t.after(closeClientAndServerAsPromise(client, server)) strictEqual(responseBody, expectedBody) + strictEqual(response.headers.get('x-custom-h2'), 'foo') }) test('[Fetch] Simple GET with h2', async (t) => { @@ -461,3 +462,48 @@ test('Issue #2386', async (t) => { controller.abort() ok(true) }) + +test('Issue #3046', async (t) => { + const server = createSecureServer(pem) + + const { strictEqual, deepStrictEqual } = tspl(t, { plan: 6 }) + + server.on('stream', async (stream, headers) => { + strictEqual(headers[':method'], 'GET') + strictEqual(headers[':path'], '/') + strictEqual(headers[':scheme'], 'https') + + stream.respond({ + 'set-cookie': ['hello=world', 'foo=bar'], + 'content-type': 'text/html; charset=utf-8', + ':status': 200 + }) + + stream.end('

Hello World

') + }) + + server.listen(0) + await once(server, 'listening') + + const client = new Client(`https://localhost:${server.address().port}`, { + connect: { + rejectUnauthorized: false + }, + allowH2: true + }) + + t.after(closeClientAndServerAsPromise(client, server)) + + const response = await fetch( + `https://localhost:${server.address().port}/`, + // Needs to be passed to disable the reject unauthorized + { + method: 'GET', + dispatcher: client + } + ) + + strictEqual(response.status, 200) + strictEqual(response.headers.get('content-type'), 'text/html; charset=utf-8') + deepStrictEqual(response.headers.getSetCookie(), ['hello=world', 'foo=bar']) +}) diff --git a/test/fetch/integrity.js b/test/fetch/integrity.js index f3bc27e4dc5..b88780175f8 100644 --- a/test/fetch/integrity.js +++ b/test/fetch/integrity.js @@ -1,6 +1,7 @@ 'use strict' -const { test } = require('node:test') +const { test, after } = require('node:test') +const { tspl } = require('@matteo.collina/tspl') const assert = require('node:assert') const { createServer } = require('node:http') const { createHash, getHashes } = require('node:crypto') @@ -147,3 +148,202 @@ test('request with sha512 hash', { skip: !supportedHashes.includes('sha512') }, integrity: 'sha512-ypeBEsobvcr6wjGzmiPcTaeG7/gUfE5yuYB3ha/uSLs=' })) }) + +test('request with correct integrity checksum (base64url)', async (t) => { + t = tspl(t, { plan: 1 }) + const body = 'Hello world!' + const hash = createHash('sha256').update(body).digest('base64url') + + const server = createServer((req, res) => { + res.end(body) + }) + + after(closeServerAsPromise(server)) + + server.listen(0, async () => { + const response = await fetch(`http://localhost:${server.address().port}`, { + integrity: `sha256-${hash}` + }) + t.strictEqual(body, await response.text()) + }) + + await t.completed +}) + +test('request with incorrect integrity checksum (base64url)', async (t) => { + t = tspl(t, { plan: 1 }) + + const body = 'Hello world!' + const hash = createHash('sha256').update('invalid').digest('base64url') + + const server = createServer((req, res) => { + res.end(body) + }) + + after(closeServerAsPromise(server)) + + server.listen(0, async () => { + await t.rejects(fetch(`http://localhost:${server.address().port}`, { + integrity: `sha256-${hash}` + })) + }) + + await t.completed +}) + +test('request with incorrect integrity checksum (only dash)', async (t) => { + t = tspl(t, { plan: 1 }) + + const body = 'Hello world!' + + const server = createServer((req, res) => { + res.end(body) + }) + + after(closeServerAsPromise(server)) + + server.listen(0, async () => { + await t.rejects(fetch(`http://localhost:${server.address().port}`, { + integrity: 'sha256--' + })) + }) + + await t.completed +}) + +test('request with incorrect integrity checksum (non-ascii character)', async (t) => { + t = tspl(t, { plan: 1 }) + + const body = 'Hello world!' + + const server = createServer((req, res) => { + res.end(body) + }) + + after(closeServerAsPromise(server)) + + server.listen(0, async () => { + await t.rejects(() => fetch(`http://localhost:${server.address().port}`, { + integrity: 'sha256-ä' + })) + }) + + await t.completed +}) + +test('request with incorrect stronger integrity checksum (non-ascii character)', async (t) => { + t = tspl(t, { plan: 2 }) + + const body = 'Hello world!' + const sha256 = createHash('sha256').update(body).digest('base64') + const sha384 = 'ä' + + const server = createServer((req, res) => { + res.end(body) + }) + + after(closeServerAsPromise(server)) + + server.listen(0, async () => { + await t.rejects(() => fetch(`http://localhost:${server.address().port}`, { + integrity: `sha256-${sha256} sha384-${sha384}` + })) + await t.rejects(() => fetch(`http://localhost:${server.address().port}`, { + integrity: `sha384-${sha384} sha256-${sha256}` + })) + }) + + await t.completed +}) + +test('request with correct integrity checksum (base64). mixed', async (t) => { + t = tspl(t, { plan: 6 }) + const body = 'Hello world!' + const sha256 = createHash('sha256').update(body).digest('base64') + const sha384 = createHash('sha384').update(body).digest('base64') + const sha512 = createHash('sha512').update(body).digest('base64') + + const server = createServer((req, res) => { + res.end(body) + }) + + after(closeServerAsPromise(server)) + + server.listen(0, async () => { + let response + response = await fetch(`http://localhost:${server.address().port}`, { + integrity: `sha256-${sha256} sha512-${sha512}` + }) + t.strictEqual(body, await response.text()) + response = await fetch(`http://localhost:${server.address().port}`, { + integrity: `sha512-${sha512} sha256-${sha256}` + }) + + t.strictEqual(body, await response.text()) + response = await fetch(`http://localhost:${server.address().port}`, { + integrity: `sha384-${sha384} sha512-${sha512}` + }) + t.strictEqual(body, await response.text()) + response = await fetch(`http://localhost:${server.address().port}`, { + integrity: `sha384-${sha384} sha512-${sha512}` + }) + t.strictEqual(body, await response.text()) + + response = await fetch(`http://localhost:${server.address().port}`, { + integrity: `sha256-${sha256} sha384-${sha384}` + }) + t.strictEqual(body, await response.text()) + response = await fetch(`http://localhost:${server.address().port}`, { + integrity: `sha384-${sha384} sha256-${sha256}` + }) + t.strictEqual(body, await response.text()) + }) + + await t.completed +}) + +test('request with correct integrity checksum (base64url). mixed', async (t) => { + t = tspl(t, { plan: 6 }) + const body = 'Hello world!' + const sha256 = createHash('sha256').update(body).digest('base64url') + const sha384 = createHash('sha384').update(body).digest('base64url') + const sha512 = createHash('sha512').update(body).digest('base64url') + + const server = createServer((req, res) => { + res.end(body) + }) + + after(closeServerAsPromise(server)) + + server.listen(0, async () => { + let response + response = await fetch(`http://localhost:${server.address().port}`, { + integrity: `sha256-${sha256} sha512-${sha512}` + }) + t.strictEqual(body, await response.text()) + response = await fetch(`http://localhost:${server.address().port}`, { + integrity: `sha512-${sha512} sha256-${sha256}` + }) + + t.strictEqual(body, await response.text()) + response = await fetch(`http://localhost:${server.address().port}`, { + integrity: `sha384-${sha384} sha512-${sha512}` + }) + t.strictEqual(body, await response.text()) + response = await fetch(`http://localhost:${server.address().port}`, { + integrity: `sha384-${sha384} sha512-${sha512}` + }) + t.strictEqual(body, await response.text()) + + response = await fetch(`http://localhost:${server.address().port}`, { + integrity: `sha256-${sha256} sha384-${sha384}` + }) + t.strictEqual(body, await response.text()) + response = await fetch(`http://localhost:${server.address().port}`, { + integrity: `sha384-${sha384} sha256-${sha256}` + }) + t.strictEqual(body, await response.text()) + }) + + await t.completed +}) diff --git a/test/fetch/iterators.js b/test/fetch/iterators.js index 5b4a7d3ebdf..dd8c420b486 100644 --- a/test/fetch/iterators.js +++ b/test/fetch/iterators.js @@ -14,7 +14,7 @@ test('Implements " Iterator" properly', async (t) => { const IteratorPrototype = Object.getPrototypeOf(Object.getPrototypeOf([][Symbol.iterator]())) const iteratorProto = Object.getPrototypeOf(gen) - assert.ok(gen.constructor === Object) + assert.ok(gen.constructor === IteratorPrototype.constructor) assert.ok(gen.prototype === undefined) // eslint-disable-next-line no-proto assert.strictEqual(gen.__proto__[Symbol.toStringTag], 'Headers Iterator') @@ -38,7 +38,7 @@ test('Implements " Iterator" properly', async (t) => { const IteratorPrototype = Object.getPrototypeOf(Object.getPrototypeOf([][Symbol.iterator]())) const iteratorProto = Object.getPrototypeOf(gen) - assert.ok(gen.constructor === Object) + assert.ok(gen.constructor === IteratorPrototype.constructor) assert.ok(gen.prototype === undefined) // eslint-disable-next-line no-proto assert.strictEqual(gen.__proto__[Symbol.toStringTag], 'FormData Iterator') diff --git a/test/fetch/request-inspect-custom.js b/test/fetch/request-inspect-custom.js index 7cb7fc73152..e2e60bdab7b 100644 --- a/test/fetch/request-inspect-custom.js +++ b/test/fetch/request-inspect-custom.js @@ -1,8 +1,8 @@ 'use strict' const { describe, it } = require('node:test') -const assert = require('assert') -const util = require('util') +const assert = require('node:assert') +const util = require('node:util') const { Request } = require('../../') describe('Request custom inspection', () => { diff --git a/test/fetch/response-inspect-custom.js b/test/fetch/response-inspect-custom.js index bf72a053a6e..ca8a5a0fc1a 100644 --- a/test/fetch/response-inspect-custom.js +++ b/test/fetch/response-inspect-custom.js @@ -1,8 +1,8 @@ 'use strict' const { describe, it } = require('node:test') -const assert = require('assert') -const util = require('util') +const assert = require('node:assert') +const util = require('node:util') const { Response } = require('../../') describe('Response custom inspection', () => { diff --git a/test/fetch/util.js b/test/fetch/util.js index a5c33d64934..7e3c90c1fc3 100644 --- a/test/fetch/util.js +++ b/test/fetch/util.js @@ -284,9 +284,9 @@ test('parseMetadata', async (t) => { const result = util.parseMetadata(validMetadata) assert.deepEqual(result, [ - { algo: 'sha256', hash: hash256 }, - { algo: 'sha384', hash: hash384 }, - { algo: 'sha512', hash: hash512 } + { algo: 'sha256', hash: hash256.replace(/=/g, '') }, + { algo: 'sha384', hash: hash384.replace(/=/g, '') }, + { algo: 'sha512', hash: hash512.replace(/=/g, '') } ]) }) @@ -300,9 +300,9 @@ test('parseMetadata', async (t) => { const result = util.parseMetadata(validMetadata) assert.deepEqual(result, [ - { algo: 'sha256', hash: hash256 }, - { algo: 'sha384', hash: hash384 }, - { algo: 'sha512', hash: hash512 } + { algo: 'sha256', hash: hash256.replace(/=/g, '') }, + { algo: 'sha384', hash: hash384.replace(/=/g, '') }, + { algo: 'sha512', hash: hash512.replace(/=/g, '') } ]) }) @@ -316,13 +316,13 @@ test('parseMetadata', async (t) => { const result = util.parseMetadata(validMetadata) assert.deepEqual(result, [ - { algo: 'sha256', hash: hash256 }, - { algo: 'sha384', hash: hash384 }, - { algo: 'sha512', hash: hash512 } + { algo: 'sha256', hash: hash256.replace(/=/g, '') }, + { algo: 'sha384', hash: hash384.replace(/=/g, '') }, + { algo: 'sha512', hash: hash512.replace(/=/g, '') } ]) }) - await t.test('should ignore invalid metadata with invalid base64 chars', () => { + await t.test('should set hash as undefined when invalid base64 chars are provided', () => { const body = 'Hello world!' const hash256 = createHash('sha256').update(body).digest('base64') const invalidHash384 = 'zifp5hE1Xl5LQQqQz[]Bq/iaq9Wb6jVb//T7EfTmbXD2aEP5c2ZdJr9YTDfcTE1ZH+' @@ -332,8 +332,9 @@ test('parseMetadata', async (t) => { const result = util.parseMetadata(validMetadata) assert.deepEqual(result, [ - { algo: 'sha256', hash: hash256 }, - { algo: 'sha512', hash: hash512 } + { algo: 'sha256', hash: hash256.replace(/=/g, '') }, + { algo: 'sha384', hash: undefined }, + { algo: 'sha512', hash: hash512.replace(/=/g, '') } ]) }) }) diff --git a/test/http2.js b/test/http2.js index 3319ffddb1e..a4ecc1ac3e8 100644 --- a/test/http2.js +++ b/test/http2.js @@ -851,8 +851,10 @@ test('Should handle h2 request with body (string or buffer) - dispatch', async t }, onHeaders (statusCode, headers) { t.strictEqual(statusCode, 200) - t.strictEqual(headers['content-type'], 'text/plain; charset=utf-8') - t.strictEqual(headers['x-custom-h2'], 'foo') + t.strictEqual(headers[0].toString('utf-8'), 'content-type') + t.strictEqual(headers[1].toString('utf-8'), 'text/plain; charset=utf-8') + t.strictEqual(headers[2].toString('utf-8'), 'x-custom-h2') + t.strictEqual(headers[3].toString('utf-8'), 'foo') }, onData (chunk) { response.push(chunk) @@ -1294,3 +1296,88 @@ test('Should throw informational error on half-closed streams (remote)', async t t.strictEqual(err.code, 'UND_ERR_INFO') }) }) + +test('#2364 - Concurrent aborts', async t => { + const server = createSecureServer(pem) + + server.on('stream', (stream, headers, _flags, rawHeaders) => { + t.strictEqual(headers['x-my-header'], 'foo') + t.strictEqual(headers[':method'], 'GET') + setTimeout(() => { + stream.respond({ + 'content-type': 'text/plain; charset=utf-8', + 'x-custom-h2': 'hello', + ':status': 200 + }) + stream.end('hello h2!') + }, 100) + }) + + server.listen(0) + await once(server, 'listening') + + const client = new Client(`https://localhost:${server.address().port}`, { + connect: { + rejectUnauthorized: false + }, + allowH2: true + }) + + t = tspl(t, { plan: 18 }) + after(() => server.close()) + after(() => client.close()) + const controller = new AbortController() + + client.request({ + path: '/', + method: 'GET', + headers: { + 'x-my-header': 'foo' + } + }, (err, response) => { + t.ifError(err) + t.strictEqual(response.headers['content-type'], 'text/plain; charset=utf-8') + t.strictEqual(response.headers['x-custom-h2'], 'hello') + t.strictEqual(response.statusCode, 200) + response.body.dump() + }) + + client.request({ + path: '/', + method: 'GET', + headers: { + 'x-my-header': 'foo' + }, + signal: controller.signal + }, (err, response) => { + t.strictEqual(err.name, 'AbortError') + }) + + client.request({ + path: '/', + method: 'GET', + headers: { + 'x-my-header': 'foo' + } + }, (err, response) => { + t.ifError(err) + t.strictEqual(response.headers['content-type'], 'text/plain; charset=utf-8') + t.strictEqual(response.headers['x-custom-h2'], 'hello') + t.strictEqual(response.statusCode, 200) + }) + + client.request({ + path: '/', + method: 'GET', + headers: { + 'x-my-header': 'foo' + }, + signal: controller.signal + }, (err, response) => { + t.strictEqual(err.name, 'AbortError') + }) + + controller.abort() + + await t.completed +}) diff --git a/test/mock-interceptor-unused-assertions.js b/test/mock-interceptor-unused-assertions.js index e6f5360a3f9..5be3942d3db 100644 --- a/test/mock-interceptor-unused-assertions.js +++ b/test/mock-interceptor-unused-assertions.js @@ -10,6 +10,10 @@ const util = require('../lib/core/util') // https://github.com/nodejs/node/pull/50135 const tableRowsAlignedToLeft = util.nodeMajor >= 21 || (util.nodeMajor === 20 && util.nodeMinor >= 11) +// `console.table` treats emoji as two character widths for cell width determination +const Y = process.versions.icu ? '✅' : 'Y ' +const N = process.versions.icu ? '❌' : 'N ' + // Avoid colors in the output for inline snapshots. const pendingInterceptorsFormatter = new PendingInterceptorsFormatter({ disableColors: true }) @@ -55,7 +59,7 @@ test('1 pending interceptor', t => { ┌─────────┬────────┬───────────────────────┬──────┬─────────────┬────────────┬─────────────┬───────────┐ │ (index) │ Method │ Origin │ Path │ Status code │ Persistent │ Invocations │ Remaining │ ├─────────┼────────┼───────────────────────┼──────┼─────────────┼────────────┼─────────────┼───────────┤ -│ 0 │ 'GET' │ 'https://example.com' │ '/' │ 200 │ '❌' │ 0 │ 1 │ +│ 0 │ 'GET' │ 'https://example.com' │ '/' │ 200 │ '${N}' │ 0 │ 1 │ └─────────┴────────┴───────────────────────┴──────┴─────────────┴────────────┴─────────────┴───────────┘ `.trim() : ` @@ -64,7 +68,7 @@ test('1 pending interceptor', t => { ┌─────────┬────────┬───────────────────────┬──────┬─────────────┬────────────┬─────────────┬───────────┐ │ (index) │ Method │ Origin │ Path │ Status code │ Persistent │ Invocations │ Remaining │ ├─────────┼────────┼───────────────────────┼──────┼─────────────┼────────────┼─────────────┼───────────┤ -│ 0 │ 'GET' │ 'https://example.com' │ '/' │ 200 │ '❌' │ 0 │ 1 │ +│ 0 │ 'GET' │ 'https://example.com' │ '/' │ 200 │ '${N}' │ 0 │ 1 │ └─────────┴────────┴───────────────────────┴──────┴─────────────┴────────────┴─────────────┴───────────┘ `.trim()) } @@ -88,8 +92,8 @@ test('2 pending interceptors', t => { ┌─────────┬────────┬──────────────────────────┬──────────────┬─────────────┬────────────┬─────────────┬───────────┐ │ (index) │ Method │ Origin │ Path │ Status code │ Persistent │ Invocations │ Remaining │ ├─────────┼────────┼──────────────────────────┼──────────────┼─────────────┼────────────┼─────────────┼───────────┤ -│ 0 │ 'GET' │ 'https://example.com' │ '/' │ 200 │ '❌' │ 0 │ 1 │ -│ 1 │ 'GET' │ 'https://localhost:9999' │ '/some/path' │ 204 │ '❌' │ 0 │ 1 │ +│ 0 │ 'GET' │ 'https://example.com' │ '/' │ 200 │ '${N}' │ 0 │ 1 │ +│ 1 │ 'GET' │ 'https://localhost:9999' │ '/some/path' │ 204 │ '${N}' │ 0 │ 1 │ └─────────┴────────┴──────────────────────────┴──────────────┴─────────────┴────────────┴─────────────┴───────────┘ `.trim() : ` @@ -98,8 +102,8 @@ test('2 pending interceptors', t => { ┌─────────┬────────┬──────────────────────────┬──────────────┬─────────────┬────────────┬─────────────┬───────────┐ │ (index) │ Method │ Origin │ Path │ Status code │ Persistent │ Invocations │ Remaining │ ├─────────┼────────┼──────────────────────────┼──────────────┼─────────────┼────────────┼─────────────┼───────────┤ -│ 0 │ 'GET' │ 'https://example.com' │ '/' │ 200 │ '❌' │ 0 │ 1 │ -│ 1 │ 'GET' │ 'https://localhost:9999' │ '/some/path' │ 204 │ '❌' │ 0 │ 1 │ +│ 0 │ 'GET' │ 'https://example.com' │ '/' │ 200 │ '${N}' │ 0 │ 1 │ +│ 1 │ 'GET' │ 'https://localhost:9999' │ '/some/path' │ 204 │ '${N}' │ 0 │ 1 │ └─────────┴────────┴──────────────────────────┴──────────────┴─────────────┴────────────┴─────────────┴───────────┘ `.trim()) } @@ -164,10 +168,10 @@ test('Variations of persist(), times(), and pending status', async t => { ┌─────────┬────────┬──────────────────────────┬──────────────────────┬─────────────┬────────────┬─────────────┬───────────┐ │ (index) │ Method │ Origin │ Path │ Status code │ Persistent │ Invocations │ Remaining │ ├─────────┼────────┼──────────────────────────┼──────────────────────┼─────────────┼────────────┼─────────────┼───────────┤ -│ 0 │ 'GET' │ 'https://example.com' │ '/' │ 200 │ '❌' │ 0 │ 1 │ -│ 1 │ 'GET' │ 'https://localhost:9999' │ '/persistent/unused' │ 200 │ '✅' │ 0 │ Infinity │ -│ 2 │ 'GET' │ 'https://localhost:9999' │ '/times/partial' │ 200 │ '❌' │ 1 │ 4 │ -│ 3 │ 'GET' │ 'https://localhost:9999' │ '/times/unused' │ 200 │ '❌' │ 0 │ 2 │ +│ 0 │ 'GET' │ 'https://example.com' │ '/' │ 200 │ '${N}' │ 0 │ 1 │ +│ 1 │ 'GET' │ 'https://localhost:9999' │ '/persistent/unused' │ 200 │ '${Y}' │ 0 │ Infinity │ +│ 2 │ 'GET' │ 'https://localhost:9999' │ '/times/partial' │ 200 │ '${N}' │ 1 │ 4 │ +│ 3 │ 'GET' │ 'https://localhost:9999' │ '/times/unused' │ 200 │ '${N}' │ 0 │ 2 │ └─────────┴────────┴──────────────────────────┴──────────────────────┴─────────────┴────────────┴─────────────┴───────────┘ `.trim() : ` @@ -176,10 +180,10 @@ test('Variations of persist(), times(), and pending status', async t => { ┌─────────┬────────┬──────────────────────────┬──────────────────────┬─────────────┬────────────┬─────────────┬───────────┐ │ (index) │ Method │ Origin │ Path │ Status code │ Persistent │ Invocations │ Remaining │ ├─────────┼────────┼──────────────────────────┼──────────────────────┼─────────────┼────────────┼─────────────┼───────────┤ -│ 0 │ 'GET' │ 'https://example.com' │ '/' │ 200 │ '❌' │ 0 │ 1 │ -│ 1 │ 'GET' │ 'https://localhost:9999' │ '/persistent/unused' │ 200 │ '✅' │ 0 │ Infinity │ -│ 2 │ 'GET' │ 'https://localhost:9999' │ '/times/partial' │ 200 │ '❌' │ 1 │ 4 │ -│ 3 │ 'GET' │ 'https://localhost:9999' │ '/times/unused' │ 200 │ '❌' │ 0 │ 2 │ +│ 0 │ 'GET' │ 'https://example.com' │ '/' │ 200 │ '${N}' │ 0 │ 1 │ +│ 1 │ 'GET' │ 'https://localhost:9999' │ '/persistent/unused' │ 200 │ '${Y}' │ 0 │ Infinity │ +│ 2 │ 'GET' │ 'https://localhost:9999' │ '/times/partial' │ 200 │ '${N}' │ 1 │ 4 │ +│ 3 │ 'GET' │ 'https://localhost:9999' │ '/times/unused' │ 200 │ '${N}' │ 0 │ 2 │ └─────────┴────────┴──────────────────────────┴──────────────────────┴─────────────┴────────────┴─────────────┴───────────┘ `.trim()) } @@ -229,7 +233,7 @@ test('defaults to rendering output with terminal color when process.env.CI is un ┌─────────┬────────┬───────────────────────┬──────┬─────────────┬────────────┬─────────────┬───────────┐ │ (index) │ Method │ Origin │ Path │ Status code │ Persistent │ Invocations │ Remaining │ ├─────────┼────────┼───────────────────────┼──────┼─────────────┼────────────┼─────────────┼───────────┤ -│ 0 │ \u001b[32m'GET'\u001b[39m │ \u001b[32m'https://example.com'\u001b[39m │ \u001b[32m'/'\u001b[39m │ \u001b[33m200\u001b[39m │ \u001b[32m'❌'\u001b[39m │ \u001b[33m0\u001b[39m │ \u001b[33m1\u001b[39m │ +│ 0 │ \u001b[32m'GET'\u001b[39m │ \u001b[32m'https://example.com'\u001b[39m │ \u001b[32m'/'\u001b[39m │ \u001b[33m200\u001b[39m │ \u001b[32m'${N}'\u001b[39m │ \u001b[33m0\u001b[39m │ \u001b[33m1\u001b[39m │ └─────────┴────────┴───────────────────────┴──────┴─────────────┴────────────┴─────────────┴───────────┘ `.trim() : ` @@ -238,7 +242,7 @@ test('defaults to rendering output with terminal color when process.env.CI is un ┌─────────┬────────┬───────────────────────┬──────┬─────────────┬────────────┬─────────────┬───────────┐ │ (index) │ Method │ Origin │ Path │ Status code │ Persistent │ Invocations │ Remaining │ ├─────────┼────────┼───────────────────────┼──────┼─────────────┼────────────┼─────────────┼───────────┤ -│ 0 │ \u001b[32m'GET'\u001b[39m │ \u001b[32m'https://example.com'\u001b[39m │ \u001b[32m'/'\u001b[39m │ \u001b[33m200\u001b[39m │ \u001b[32m'❌'\u001b[39m │ \u001b[33m0\u001b[39m │ \u001b[33m1\u001b[39m │ +│ 0 │ \u001b[32m'GET'\u001b[39m │ \u001b[32m'https://example.com'\u001b[39m │ \u001b[32m'/'\u001b[39m │ \u001b[33m200\u001b[39m │ \u001b[32m'${N}'\u001b[39m │ \u001b[33m0\u001b[39m │ \u001b[33m1\u001b[39m │ └─────────┴────────┴───────────────────────┴──────┴─────────────┴────────────┴─────────────┴───────────┘ `.trim()) diff --git a/test/node-test/debug.js b/test/node-test/debug.js index 3e6ca0bc0ef..d7c462f57ae 100644 --- a/test/node-test/debug.js +++ b/test/node-test/debug.js @@ -8,7 +8,7 @@ const { tspl } = require('@matteo.collina/tspl') // eslint-disable-next-line no-control-regex const removeEscapeColorsRE = /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g -test('debug#websocket', async t => { +test('debug#websocket', { skip: !process.versions.icu }, async t => { const assert = tspl(t, { plan: 8 }) const child = spawn( process.execPath, diff --git a/test/redirect-cross-origin-header.js b/test/redirect-cross-origin-header.js new file mode 100644 index 00000000000..451563cadc5 --- /dev/null +++ b/test/redirect-cross-origin-header.js @@ -0,0 +1,52 @@ +'use strict' + +const { test } = require('node:test') +const { tspl } = require('@matteo.collina/tspl') +const { createServer } = require('node:http') +const { once } = require('node:events') +const { request } = require('..') + +test('Cross-origin redirects clear forbidden headers', async (t) => { + const { strictEqual } = tspl(t, { plan: 6 }) + + const server1 = createServer((req, res) => { + strictEqual(req.headers.cookie, undefined) + strictEqual(req.headers.authorization, undefined) + strictEqual(req.headers['proxy-authorization'], undefined) + + res.end('redirected') + }).listen(0) + + const server2 = createServer((req, res) => { + strictEqual(req.headers.authorization, 'test') + strictEqual(req.headers.cookie, 'ddd=dddd') + + res.writeHead(302, { + ...req.headers, + Location: `http://localhost:${server1.address().port}` + }) + res.end() + }).listen(0) + + t.after(() => { + server1.close() + server2.close() + }) + + await Promise.all([ + once(server1, 'listening'), + once(server2, 'listening') + ]) + + const res = await request(`http://localhost:${server2.address().port}`, { + maxRedirections: 1, + headers: { + Authorization: 'test', + Cookie: 'ddd=dddd', + 'Proxy-Authorization': 'test' + } + }) + + const text = await res.body.text() + strictEqual(text, 'redirected') +}) diff --git a/test/types/fetch.test-d.ts b/test/types/fetch.test-d.ts index b06e50ac480..583ad3b8675 100644 --- a/test/types/fetch.test-d.ts +++ b/test/types/fetch.test-d.ts @@ -139,7 +139,7 @@ expectType(request.integrity) expectType(request.method) expectType(request.mode) expectType(request.redirect) -expectType(request.referrerPolicy) +expectType(request.referrerPolicy) expectType(request.url) expectType(request.keepalive) expectType(request.signal) @@ -172,3 +172,5 @@ expectAssignable({ duplex: 'half' }) expectNotAssignable({ duplex: 'not valid' }) expectType(headers.getSetCookie()) + +expectType(new Request('https://example.com', request)) diff --git a/types/fetch.d.ts b/types/fetch.d.ts index d51311b13df..18eadb6ac98 100644 --- a/types/fetch.d.ts +++ b/types/fetch.d.ts @@ -163,7 +163,7 @@ export declare class Request extends BodyMixin { readonly method: string readonly mode: RequestMode readonly redirect: RequestRedirect - readonly referrerPolicy: string + readonly referrerPolicy: ReferrerPolicy readonly url: string readonly keepalive: boolean