diff --git a/.github/workflows/stress-test.yml b/.github/workflows/stress-test.yml new file mode 100644 index 00000000000000..e3846cf4d387ac --- /dev/null +++ b/.github/workflows/stress-test.yml @@ -0,0 +1,116 @@ +name: Stress Test (rebase tested branch on main for cache reuse) + +on: + workflow_dispatch: + inputs: + test_path: + description: Test path or glob (e.g. test/parallel/test-debugger-break.js or "test/parallel/test-debugger-*") + required: true + type: string + os: + description: Runner to use + required: true + default: macos-15 + type: choice + options: + - macos-15 + - ubuntu-24.04 + - ubuntu-24.04-arm + repeat: + description: Number of times to repeat each test (--repeat) + required: true + default: '1000' + type: string + test_jobs: + description: Number of jobs to run in parallel (-j). + required: true + default: '1' + type: string + test_args: + description: Extra args for tools/test.py, space-separated (e.g. "-t 10 --worker") + required: false + default: '' + type: string + +env: + PYTHON_VERSION: '3.14' + XCODE_VERSION: '16.4' + CLANG_VERSION: '19' + RUSTC_VERSION: '1.82' + +permissions: + contents: read + +jobs: + stress-test: + runs-on: ${{ inputs.os }} + env: + # Linux builds with clang (matching test-linux.yml), macOS with gcc/g++. + CC: ${{ startsWith(inputs.os, 'ubuntu') && 'sccache clang-19' || 'sccache gcc' }} + CXX: ${{ startsWith(inputs.os, 'ubuntu') && 'sccache clang++-19' || 'sccache g++' }} + # Enable sccache - this is okay for a manually-dispatched workflow that is typically used to + # test lib-only or test-only deflaking changes. This should be able to pick up cache from + # test-linux.yml and test-macos.yml running on the main branch. + SCCACHE_GHA_ENABLED: 'true' + SCCACHE_IDLE_TIMEOUT: '0' + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + path: node + - name: Install Clang ${{ env.CLANG_VERSION }} + if: runner.os == 'Linux' + uses: ./node/.github/actions/install-clang + with: + clang-version: ${{ env.CLANG_VERSION }} + - name: Set up Xcode ${{ env.XCODE_VERSION }} + if: runner.os == 'macOS' + run: sudo xcode-select -s /Applications/Xcode_${{ env.XCODE_VERSION }}.app + - name: Set up Python ${{ env.PYTHON_VERSION }} + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: ${{ env.PYTHON_VERSION }} + allow-prereleases: true + - name: Install Rust ${{ env.RUSTC_VERSION }} + run: | + rustup override set "$RUSTC_VERSION" + rustup --version + - name: Set up sccache + uses: Mozilla-Actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10 + with: + version: v0.16.0 + - name: Environment Information + run: npx envinfo@7.21.0 + # This is needed due to https://github.com/nodejs/build/issues/3878 + - name: Cleanup + if: runner.os == 'macOS' + run: | + echo "::group::Free space before cleanup" + df -h + echo "::endgroup::" + echo "::group::Cleaned Files" + + sudo rm -rf /Users/runner/Library/Android/sdk + + echo "::endgroup::" + echo "::group::Free space after cleanup" + df -h + echo "::endgroup::" + - name: Build + run: make -C node build-ci -j"$(getconf _NPROCESSORS_ONLN)" V=1 CONFIG_FLAGS="--error-on-warn --v8-enable-temporal-support" + - name: Stress run ${{ inputs.test_path }} x${{ inputs.repeat }} + shell: bash + env: + REPEAT: ${{ inputs.repeat }} + JOBS: ${{ inputs.test_jobs }} + TEST_ARGS: ${{ inputs.test_args }} + TEST_PATH: ${{ inputs.test_path }} + run: | + cd node + read -ra EXTRA_ARGS <<< "$TEST_ARGS" + python3 tools/test.py \ + --repeat "$REPEAT" \ + -j "$JOBS" \ + -p actions \ + "${EXTRA_ARGS[@]}" \ + "$TEST_PATH" diff --git a/README.md b/README.md index f93cfa6d492b47..dc175eadf5bb6a 100644 --- a/README.md +++ b/README.md @@ -785,6 +785,8 @@ Primary GPG keys for Node.js Releasers (some Releasers sign with subkeys): `C82FA3AE1CBEDC6BE46B9360C43CEC45C17AB93C` * **Ruy Adorno** <> `108F52B48DB57BB0CC439B2997B01419BD92F80A` +* **Stewart X Addison** <> + `655F3B5C1FB3FA8D1A0CA6BDE4A7D232B936D2FD` * **Ulises Gascón** <> `A363A499291CBBC940DD62E41F10027AF002F8B0` @@ -802,6 +804,7 @@ gpg --keyserver hkps://keys.openpgp.org --recv-keys 8FCCA13FEF1D0C2E91008E09770F gpg --keyserver hkps://keys.openpgp.org --recv-keys 890C08DB8579162FEE0DF9DB8BEAB4DFCF555EF4 # Rafael Gonzaga gpg --keyserver hkps://keys.openpgp.org --recv-keys C82FA3AE1CBEDC6BE46B9360C43CEC45C17AB93C # Richard Lau gpg --keyserver hkps://keys.openpgp.org --recv-keys 108F52B48DB57BB0CC439B2997B01419BD92F80A # Ruy Adorno +gpg --keyserver hkps://keys.openpgp.org --recv-keys 655F3B5C1FB3FA8D1A0CA6BDE4A7D232B936D2FD # Stewart X Addison gpg --keyserver hkps://keys.openpgp.org --recv-keys A363A499291CBBC940DD62E41F10027AF002F8B0 # Ulises Gascón ``` diff --git a/configure.py b/configure.py index a5b57064d1fa4a..2dc6d72d159cc4 100755 --- a/configure.py +++ b/configure.py @@ -55,7 +55,7 @@ valid_mips_float_abi = ('soft', 'hard') valid_intl_modes = ('none', 'small-icu', 'full-icu', 'system-icu') icu_versions = json.loads((tools_path / 'icu' / 'icu_versions.json').read_text(encoding='utf-8')) -maglev_enabled_architectures = ('x64', 'arm', 'arm64', 'ppc64', 's390x') +maglev_enabled_architectures = ('x64', 'arm', 'arm64', 'ppc64', 's390x', 'riscv64') # builtins may be removed later if they have been disabled by options shareable_builtins = {'undici/undici': 'deps/undici/undici.js', diff --git a/deps/libffi/libffi.gyp b/deps/libffi/libffi.gyp index f3122a4e2a0041..2d43dcdb733c97 100644 --- a/deps/libffi/libffi.gyp +++ b/deps/libffi/libffi.gyp @@ -204,6 +204,13 @@ }, ], 'conditions': [ + ['OS == "win" and clang == 1', { + 'msvs_settings': { + 'VCCLCompilerTool': { + 'AdditionalOptions': ['-Wno-incompatible-pointer-types'], + }, + }, + }], ['OS == "win" and (target_arch == "x64" or target_arch == "x86_64")', { 'actions': [ { diff --git a/deps/ncrypto/ncrypto.cc b/deps/ncrypto/ncrypto.cc index 81af3ded563777..d62485e626c158 100644 --- a/deps/ncrypto/ncrypto.cc +++ b/deps/ncrypto/ncrypto.cc @@ -3086,6 +3086,17 @@ EVPKeyPointer SSLPointer::getPeerTempKey() const { return EVPKeyPointer(raw_key); } +std::optional SSLPointer::getNegotiatedGroup() const { +#if OPENSSL_VERSION_PREREQ(3, 5) + if (!ssl_) return std::nullopt; + const char* group = SSL_get0_group_name(get()); + if (group == nullptr) return std::nullopt; + return group; +#else + return std::nullopt; +#endif +} + std::optional SSLPointer::getCipherName() const { auto cipher = getCipher(); if (cipher == nullptr) return std::nullopt; diff --git a/deps/ncrypto/ncrypto.h b/deps/ncrypto/ncrypto.h index a6befbf7cf6794..76d79652607139 100644 --- a/deps/ncrypto/ncrypto.h +++ b/deps/ncrypto/ncrypto.h @@ -1198,6 +1198,7 @@ class SSLPointer final { std::optional getServerName() const; X509View getCertificate() const; EVPKeyPointer getPeerTempKey() const; + std::optional getNegotiatedGroup() const; const SSL_CIPHER* getCipher() const; bool isServer() const; diff --git a/doc/api/ffi.md b/doc/api/ffi.md index 4deef4accad375..172af6bbc6edee 100644 --- a/doc/api/ffi.md +++ b/doc/api/ffi.md @@ -125,8 +125,13 @@ raw pointer `bigint` values. For pointer-like parameters, `null`, `undefined`, strings, `Buffer`, typed array, `DataView`, and `ArrayBuffer` values are converted on the JavaScript side before calling the optimized native wrapper. -Optimized Fast FFI calls support at most 8 function arguments. Functions with -more than 7 arguments use the generic FFI call path instead. +Optimized Fast FFI calls support at most 8 function arguments, but the exact +limit depends on the architecture and on the argument types, because each +argument must fit in the registers used by the platform trampoline. Integer +and pointer arguments are limited to 7 on AArch64 and to 6 on x86-64, while +floating-point arguments can use up to 8 on both. Functions that exceed these +limits, including any function with more than 8 arguments, use the generic FFI +call path instead. ## Signature objects diff --git a/doc/api/http.md b/doc/api/http.md index f23908d70ae90f..a48e747e8647b6 100644 --- a/doc/api/http.md +++ b/doc/api/http.md @@ -4096,6 +4096,18 @@ changes: E.G. `'/index.html?page=12'`. An exception is thrown when the request path contains illegal characters. Currently, only spaces are rejected but that may change in the future. **Default:** `'/'`. + The content in `path` is sent as the [request target][] in the HTTP 1.1 message. + When `path` is an absolute URL, this means the request target in the message in [absolute form][]. + If the receiving server is a proxy, the server typically forwards the request to the + destination specified in the request target, and ignores the `Host` header. + The user needs to make sure that `path`, `host` and the Host headers conform to the + requirement of the [request target][] in the HTTP specification. + When the receiving server is known to be a proxy because the request is routed through + [Built-in Proxy Support][], `http.request` will additionally perform a best-effort + check to see that the `host` option or `Host` in `headers` agrees with the authority + in `path` during the initial construction of the request. It gives up rewriting the + request target for proxying and throws an error if they don't match at request + construction time, though there won't be checks for later header mutations done by the user. * `port` {number} Port of remote server. **Default:** `defaultPort` if set, else `80`. * `protocol` {string} Protocol to use. **Default:** `'http:'`. @@ -4792,5 +4804,7 @@ const agent2 = new http.Agent({ proxyEnv: process.env }); [`writable.destroyed`]: stream.md#writabledestroyed [`writable.uncork()`]: stream.md#writableuncork [`writable.write()`]: stream.md#writablewritechunk-encoding-callback +[absolute form]: https://datatracker.ietf.org/doc/html/rfc9112#section-3.2.2 [information event]: #event-information [initial delay]: net.md#socketsetkeepaliveenable-initialdelay-interval-count +[request target]: https://datatracker.ietf.org/doc/html/rfc9112#section-3.2 diff --git a/doc/api/tls.md b/doc/api/tls.md index 28126d021bd7a3..95335e6f275099 100644 --- a/doc/api/tls.md +++ b/doc/api/tls.md @@ -131,7 +131,8 @@ the character "E" appended to the traditional abbreviations): Perfect forward secrecy using ECDHE is enabled by default. The `ecdhCurve` option can be used when creating a TLS server to customize the list of supported -ECDH curves to use. See [`tls.createServer()`][] for more info. +ECDH curves for TLSv1.2 and below, and the list of supported TLS groups for +TLSv1.3. See [`tls.createServer()`][] for more info. DHE is disabled by default but can be enabled alongside ECDHE by setting the `dhparam` option to `'auto'`. Custom DHE parameters are also supported but @@ -1196,12 +1197,19 @@ added: v5.0.0 * Returns: {Object} -Returns an object representing the type, name, and size of parameter of -an ephemeral key exchange in [perfect forward secrecy][] on a client -connection. It returns an empty object when the key exchange is not -ephemeral. As this is only supported on a client socket; `null` is returned -if called on a server socket. The supported types are `'DH'` and `'ECDH'`. The -`name` property is available only when type is `'ECDH'`. +Returns an object describing ephemeral key agreement in [perfect forward +secrecy][] on a client connection. It returns an empty object when the key +agreement is not ephemeral. As this is only supported on a client socket; +`null` is returned if called on a server socket. The supported types are `'DH'`, +`'ECDH'`, and `'TLSGroup'`. For `'DH'` and `'ECDH'`, the object describes peer +temporary key parameters. For `'TLSGroup'`, the object identifies the negotiated +TLS Supported Group used for key agreement when a peer temporary key object is +not available. + +The `name` property is available only when type is `'ECDH'` or `'TLSGroup'`. The +`size` property is not available when type is `'TLSGroup'`. For `'TLSGroup'`, +`name` is the negotiated TLS Supported Group name. Standardized TLS group names +and code points are listed in the [IANA TLS Supported Groups registry][]. For example: `{ type: 'ECDH', name: 'prime256v1', size: 256 }`. @@ -2019,12 +2027,16 @@ changes: required for non-ECDHE [perfect forward secrecy][]. If omitted or invalid, the parameters are silently discarded and DHE ciphers will not be available. [ECDHE][]-based [perfect forward secrecy][] will still be available. - * `ecdhCurve` {string} A string describing a named curve or a colon separated - list of curve NIDs or names, for example `P-521:P-384:P-256`, to use for - ECDH key agreement. Set to `auto` to select the - curve automatically. Use [`crypto.getCurves()`][] to obtain a list of - available curve names. On recent releases, `openssl ecparam -list_curves` - will also display the name and description of each available elliptic curve. + * `ecdhCurve` {string} A string describing a named curve, TLS group, or + colon-separated list of named curves or TLS groups to use for key agreement, + for example `P-521:P-384:P-256`, `X25519`, or `X25519MLKEM768`. The + historical name of this option refers to ECDH key agreement in TLSv1.2 and + below. In TLSv1.3, this option configures the TLS Supported Groups and + key share groups offered or accepted by the TLS stack. Set to `auto` to + select the group automatically. Use [`crypto.getCurves()`][] to obtain a + list of available elliptic curve names. For TLS group names, use + `openssl list -tls-groups` or consult the [IANA TLS Supported Groups + registry][]. **Default:** [`tls.DEFAULT_ECDH_CURVE`][]. * `honorCipherOrder` {boolean} Attempt to use the server's cipher suite preferences instead of the client's. When `true`, causes @@ -2442,9 +2454,9 @@ changes: description: Default value changed to `'auto'`. --> -The default curve name to use for ECDH key agreement in a tls server. The -default value is `'auto'`. See [`tls.createSecureContext()`][] for further -information. +The default named curve or TLS group list to use for key agreement in a TLS +server. The default value is `'auto'`. See [`tls.createSecureContext()`][] for +further information. ## `tls.DEFAULT_MAX_VERSION` @@ -2492,6 +2504,7 @@ added: v0.11.3 [Chrome's 'modern cryptography' setting]: https://www.chromium.org/Home/chromium-security/education/tls#TOC-Cipher-Suites [DHE]: https://en.wikipedia.org/wiki/Diffie%E2%80%93Hellman_key_exchange [ECDHE]: https://en.wikipedia.org/wiki/Elliptic_curve_Diffie%E2%80%93Hellman +[IANA TLS Supported Groups registry]: https://www.iana.org/assignments/tls-parameters/tls-parameters.xhtml#tls-parameters-8 [Modifying the default TLS cipher suite]: #modifying-the-default-tls-cipher-suite [Mozilla's publicly trusted list of CAs]: https://hg.mozilla.org/mozilla-central/raw-file/tip/security/nss/lib/ckfw/builtins/certdata.txt [OCSP request]: https://en.wikipedia.org/wiki/OCSP_stapling diff --git a/lib/_http_client.js b/lib/_http_client.js index 73d7b84c17a8fd..e6bd38aa35ac9d 100644 --- a/lib/_http_client.js +++ b/lib/_http_client.js @@ -84,6 +84,7 @@ const { validateInteger, validateBoolean, validateOneOf, + validatePort, validateString, } = require('internal/validators'); const { getTimerDuration } = require('internal/timers'); @@ -139,15 +140,134 @@ class HTTPClientAsyncResource { } } +// The only documented shape is [k, v, k, v, ...]. Here we also accept [[k, v], [k, v], ...]. +// for backward compatibility, and reject others. Also reject if there are duplicate Host entries. +// Returns the Host header value, or undefined if absent. +function getHostFromHeaderArray(headers) { + let host; + const isPairs = headers.length > 0 && ArrayIsArray(headers[0]); + if (isPairs) { + for (let i = 0; i < headers.length; i++) { + const entry = headers[i]; + if (!ArrayIsArray(entry)) { + throw new ERR_INVALID_ARG_VALUE(`options.headers[${i}]`, typeof entry, + 'must be an array when headers is passed as an array of pairs'); + } + if (`${entry[0]}`.toLowerCase() === 'host') { + if (host !== undefined) { + throw new ERR_INVALID_ARG_VALUE('options.headers', '(redacted)', + 'must not contain duplicate Host headers'); + } + host = `${entry[1]}`; + } + } + } else { + for (let i = 0; i + 1 < headers.length; i += 2) { + if (`${headers[i]}`.toLowerCase() === 'host') { + if (host !== undefined) { + throw new ERR_INVALID_ARG_VALUE('options.headers', '(redacted)', + 'must not contain duplicate Host headers'); + } + host = `${headers[i + 1]}`; + } + } + } + return host; +} + +function authoritiesMatch(canonicalHost, hostFromHeader) { + let parsed; + try { + parsed = new URL(`http://${hostFromHeader}`); + } catch { + return false; + } + if (parsed.username || parsed.password || + parsed.pathname !== '/' || parsed.search || parsed.hash) { + return false; + } + return parsed.host === canonicalHost; +} + +// https://datatracker.ietf.org/doc/html/rfc9112#section-3.2 +// When the request target is in absolute-form, ensure it is consistent with +// the request authority: same scheme, no userinfo, and an authority +// component agree with options.host[:port]. +function validateRequestAuthority(pathOption, proxyAuthority, userHostHeader, headerArray) { + validatePort(proxyAuthority.port, 'options.port', true); + pathOption = `${pathOption}`; + const requestBase = new URL(`http://${proxyAuthority.host}`); + requestBase.port = proxyAuthority.port; + + const result = { requestBase }; + if (headerArray !== undefined) { + const host = getHostFromHeaderArray(headerArray); + // Since we don't mutate the header array to normalize the Host value, unlike + // in the case of other shapes of headers provided, we check that it is identical + // to the authority from the requestBase. + if (host !== undefined && host !== requestBase.host) { + throw new ERR_INVALID_ARG_VALUE( + 'Host in options.headers', host, + `must match the request authority (${requestBase.host})`); + } + } else if (userHostHeader !== undefined) { + if (!authoritiesMatch(requestBase.host, userHostHeader)) { + throw new ERR_INVALID_ARG_VALUE( + 'Host in options.headers', userHostHeader, + `must match the request authority (${requestBase.host})`); + } + } + + // Per RFC 9112 Section 3.2, if request target is in absolute-form its authority + // must agree with the request authority. + let requestURL; + let isAbsoluteForm = false; + try { + requestURL = new URL(pathOption); + isAbsoluteForm = true; + } catch { + if (pathOption.charCodeAt(0) !== 0x2F) { + throw new ERR_INVALID_ARG_VALUE( + 'options.path', pathOption, 'must be in absolute-form or start with /'); + } + requestURL = new URL(requestBase.origin + pathOption); + } + result.requestURL = requestURL; + if (!isAbsoluteForm) { + return result; + } + + if (requestURL.username || requestURL.password) { + requestURL.username = ''; + requestURL.password = ''; + throw new ERR_INVALID_ARG_VALUE( + 'options.path', requestURL.href, 'must not contain userinfo, use options.auth instead'); + } + + if (requestURL.protocol !== 'http:') { + throw new ERR_INVALID_ARG_VALUE( + 'options.path', requestURL.protocol, 'must use http: scheme when specified as an absolute URL'); + } + + if (requestBase.host !== requestURL.host) { + throw new ERR_INVALID_ARG_VALUE( + 'options.path', requestURL, `must match the request authority (${requestBase.host})`); + } + + return result; +} + // When proxying a HTTP request, the following needs to be done: -// https://datatracker.ietf.org/doc/html/rfc7230#section-5.3.2 +// https://datatracker.ietf.org/doc/html/rfc9112#section-3.2.2 // 1. Rewrite the request path to absolute-form. // 2. Add proxy-connection and proxy-authorization headers appropriately. // // This function checks whether the request should be rewritten for proxying // and modifies the headers as well as req.path if necessary. // The handling of the proxy server connection is done in createConnection. -function rewriteForProxiedHttp(req, reqOptions) { +// It also validates that the Host header and absolute-form path authority match the +// request authority specified by reqOptions. +function rewriteForProxiedHttp(req, reqOptions, proxyAuthority, userHostHeader, headerArray) { if (req._header) { debug('request._header is already sent, skipping rewriteForProxiedHttp', reqOptions); return false; @@ -165,6 +285,25 @@ function rewriteForProxiedHttp(req, reqOptions) { if (!shouldUseProxy) { return false; } + + // Per RFC 9112 Section 3.2.2, we don't need to rewrite CONNECT or OPTIONS * requests. + let requestURL; + if (req.method !== 'CONNECT' && !(req.method === 'OPTIONS' && req.path === '*')) { + // Validate Host header values agree with the request authority before mutating req, + // so a rejected request doesn't leave proxy-* headers stuck on the outgoing header store. + // XXX(joyeecheung): This validates whether the request conforms to the RFC, but here + // we only do it for proxied requests for backward compatibility. For non-proxied requests, + // ensuring thst the request is well formed has been entirely left to the user. + const result = validateRequestAuthority(req.path, proxyAuthority, userHostHeader, headerArray); + if (headerArray === undefined) { + const currentHost = req.getHeader('host'); + if (currentHost !== undefined && currentHost !== result.requestBase.host) { + req.setHeader('Host', result.requestBase.host); + } + } + requestURL = result.requestURL; + } + // Add proxy headers. const { auth, href } = agent[kProxyConfig]; if (auth) { @@ -176,15 +315,10 @@ function rewriteForProxiedHttp(req, reqOptions) { req.setHeader('proxy-connection', 'close'); } - // Convert the path to absolute-form. - // https://datatracker.ietf.org/doc/html/rfc7230#section-5.3.2 - const requestHost = req.getHeader('host') || 'localhost'; - const requestBase = `http://${requestHost}`; - const requestURL = new URL(req.path, requestBase); - if (reqOptions.port) { - requestURL.port = reqOptions.port; + if (requestURL !== undefined) { + // Convert the path to absolute-form. The authority is built from options. + req.path = requestURL.href; } - req.path = requestURL.href; debug(`updated request for HTTP proxy ${href} with ${req.path} `, req[kOutHeaders]); return true; }; @@ -360,6 +494,21 @@ function ClientRequest(input, options, cb) { } } + let hostHeaderFromOptions = host; + // For the Host header, ensure that IPv6 addresses are enclosed + // in square brackets, as defined by URI formatting + // https://tools.ietf.org/html/rfc3986#section-3.2.2 + const posColon = hostHeaderFromOptions.indexOf(':'); + if (posColon !== -1 && + hostHeaderFromOptions.includes(':', posColon + 1) && + hostHeaderFromOptions.charCodeAt(0) !== 91/* '[' */) { + hostHeaderFromOptions = `[${hostHeaderFromOptions}]`; + } + const proxyAuthority = { host: hostHeaderFromOptions, port }; + + if (port && +port !== defaultPort) { + hostHeaderFromOptions += ':' + port; + } const headersArray = ArrayIsArray(options.headers); if (!headersArray) { if (options.headers) { @@ -372,23 +521,12 @@ function ClientRequest(input, options, cb) { } } - if (host && !this.getHeader('host') && setHost) { - let hostHeader = host; - - // For the Host header, ensure that IPv6 addresses are enclosed - // in square brackets, as defined by URI formatting - // https://tools.ietf.org/html/rfc3986#section-3.2.2 - const posColon = hostHeader.indexOf(':'); - if (posColon !== -1 && - hostHeader.includes(':', posColon + 1) && - hostHeader.charCodeAt(0) !== 91/* '[' */) { - hostHeader = `[${hostHeader}]`; - } + // Save the Host header before the implicit auto-set below, so the + // proxy validator can tell user-explicit values from Node-generated ones. + const userHostHeader = this.getHeader('host'); - if (port && +port !== defaultPort) { - hostHeader += ':' + port; - } - this.setHeader('Host', hostHeader); + if (host && !this.getHeader('host') && setHost) { + this.setHeader('Host', hostHeaderFromOptions); } if (options.auth && !this.getHeader('Authorization')) { @@ -401,14 +539,14 @@ function ClientRequest(input, options, cb) { throw new ERR_HTTP_HEADERS_SENT('render'); } - rewriteForProxiedHttp(this, optsWithoutSignal); + rewriteForProxiedHttp(this, optsWithoutSignal, proxyAuthority, userHostHeader); this._storeHeader(this.method + ' ' + this.path + ' HTTP/1.1\r\n', this[kOutHeaders]); } else { - rewriteForProxiedHttp(this, optsWithoutSignal); + rewriteForProxiedHttp(this, optsWithoutSignal, proxyAuthority, userHostHeader); } } else { - rewriteForProxiedHttp(this, optsWithoutSignal); + rewriteForProxiedHttp(this, optsWithoutSignal, proxyAuthority, undefined, options.headers); this._storeHeader(this.method + ' ' + this.path + ' HTTP/1.1\r\n', options.headers); } diff --git a/src/crypto/crypto_common.cc b/src/crypto/crypto_common.cc index 4f117f160a9673..0db5d0eaac8fc3 100644 --- a/src/crypto/crypto_common.cc +++ b/src/crypto/crypto_common.cc @@ -215,13 +215,15 @@ MaybeLocal GetEphemeralKey(Environment* env, const SSLPointer& ssl) { Undefined(env->isolate()), // name Undefined(env->isolate()), // size }; - EVPKeyPointer key = ssl.getPeerTempKey(); + + bool found = false; if (EVPKeyPointer key = ssl.getPeerTempKey()) { int kid = key.id(); switch (kid) { case EVP_PKEY_DH: { values[0] = env->dh_string(); values[2] = Integer::New(env->isolate(), key.bits()); + found = true; break; } case EVP_PKEY_EC: @@ -237,10 +239,17 @@ MaybeLocal GetEphemeralKey(Environment* env, const SSLPointer& ssl) { values[0] = env->ecdh_string(); values[1] = OneByteString(env->isolate(), curve_name); values[2] = Integer::New(env->isolate(), key.bits()); + found = true; break; } } } + if (!found) { + if (auto name = ssl.getNegotiatedGroup()) { + values[0] = env->tls_group_string(); + values[1] = OneByteString(env->isolate(), name.value()); + } + } return scope.EscapeMaybe(NewDictionaryInstance(env->context(), tmpl, values)); } diff --git a/src/env_properties.h b/src/env_properties.h index 2e6bc65f89641c..e3179287dce443 100644 --- a/src/env_properties.h +++ b/src/env_properties.h @@ -363,6 +363,7 @@ V(target_string, "target") \ V(thread_id_string, "threadId") \ V(thread_name_string, "threadName") \ + V(tls_group_string, "TLSGroup") \ V(ticketkeycallback_string, "onticketkeycallback") \ V(timeout_string, "timeout") \ V(time_to_first_byte_string, "timeToFirstByte") \ diff --git a/test/client-proxy/test-http-proxy-request-absolute-path-authority-match.mjs b/test/client-proxy/test-http-proxy-request-absolute-path-authority-match.mjs new file mode 100644 index 00000000000000..d544f0d2c6286f --- /dev/null +++ b/test/client-proxy/test-http-proxy-request-absolute-path-authority-match.mjs @@ -0,0 +1,70 @@ +// This tests that proxied HTTP requests succeed end-to-end when the +// absolute-form request-target matches the request authority derived from options. + +import * as common from '../common/index.mjs'; +import assert from 'node:assert'; +import http from 'node:http'; +import { once } from 'events'; +import { createProxyServer } from '../common/proxy-server.js'; + +const server = http.createServer(common.mustCall((req, res) => { + res.end('Hello world'); +}, 6)); +server.on('error', common.mustNotCall()); +server.listen(0); +await once(server, 'listening'); + +const { proxy, logs } = createProxyServer(); +proxy.listen(0); +await once(proxy, 'listening'); + +const port = server.address().port; +const serverHost = `localhost:${port}`; +const requestUrl = `http://${serverHost}/test`; + +const agent = new http.Agent({ + proxyEnv: { + HTTP_PROXY: `http://localhost:${proxy.address().port}`, + }, +}); + +async function roundTrip(options) { + const req = http.request({ agent, ...options }); + req.end(); + const [res] = await once(req, 'response'); + res.setEncoding('utf8'); + let body = ''; + for await (const chunk of res) body += chunk; + assert.strictEqual(body, 'Hello world'); +} + +const baseAbsolute = { host: 'localhost', port, path: requestUrl }; + +const options = [ + // No user-supplied headers. + baseAbsolute, + // Object form with an explicit Host that matches. + { ...baseAbsolute, headers: { Host: serverHost } }, + // Flat array form. + { ...baseAbsolute, headers: ['Host', serverHost] }, + // Array-of-pairs form. + { ...baseAbsolute, headers: [['Host', serverHost]] }, + // Contains defaultPort that matches options.port. + { host: 'localhost', port, defaultPort: port, path: '/test' }, + // Stringifiable non-string path object. + { host: 'localhost', port, path: { toString() { return '/test'; } } }, +]; + +for (const opts of options) { + await roundTrip(opts); + // Check what the proxy server received. + const log = logs.pop(); + assert.strictEqual(logs.length, 0); + assert.strictEqual(log.method, 'GET'); + assert.strictEqual(log.url, requestUrl); + assert.strictEqual(log.headers.host, serverHost); +} + +server.close(); +proxy.close(); +agent.destroy(); diff --git a/test/client-proxy/test-http-proxy-request-authority-construction.mjs b/test/client-proxy/test-http-proxy-request-authority-construction.mjs new file mode 100644 index 00000000000000..873c3ee3ae104b --- /dev/null +++ b/test/client-proxy/test-http-proxy-request-authority-construction.mjs @@ -0,0 +1,82 @@ +// Verify that the request path and Host header are constructed correctly +// for proxied requests across different option combinations. + +import '../common/index.mjs'; +import assert from 'node:assert'; +import http from 'node:http'; + +function makeAgent() { + return new http.Agent({ + proxyEnv: { HTTP_PROXY: 'http://localhost:1' }, + }); +} + +function check(options, expectedPath, expectedHost) { + const agent = makeAgent(); + const req = http.request({ agent, ...options }); + req.on('error', () => {}); + assert.strictEqual(req.path, expectedPath, `path for ${JSON.stringify(options)}`); + assert.strictEqual(req.getHeader('host'), expectedHost, `Host for ${JSON.stringify(options)}`); + req.destroy(); + agent.destroy(); +} + +const cases = [ + // [options, expectedPath, expectedHost] + + // OPTIONS * and CONNECT bypass path rewriting. + [{ host: 'localhost', port: 3000, method: 'OPTIONS', path: '*' }, + '*', 'localhost:3000'], + [{ host: 'example.com', port: 443, method: 'CONNECT', path: 'example.com:443' }, + 'example.com:443', 'example.com:443'], + + // Basic cases: implicit Host, various port/defaultPort combos. + [{ host: 'localhost', port: 3000, path: '/a' }, + 'http://localhost:3000/a', 'localhost:3000'], + [{ host: 'localhost', port: 80, path: '/b' }, + 'http://localhost/b', 'localhost'], + [{ host: 'example.com', path: '/c' }, + 'http://example.com/c', 'example.com'], + [{ host: 'localhost', port: '3000', path: '/d' }, + 'http://localhost:3000/d', 'localhost:3000'], + + // defaultPort suppresses port in auto-set Host, canonicalization corrects it. + [{ host: 'localhost', port: 80, defaultPort: 8080, path: '/e' }, + 'http://localhost/e', 'localhost'], + [{ host: 'localhost', port: 3000, defaultPort: 3000, path: '/f' }, + 'http://localhost:3000/f', 'localhost:3000'], + + // User-explicit Host header: canonicalized to match request target. + [{ host: 'localhost', port: 80, defaultPort: 8080, path: '/g', headers: { Host: 'localhost:80' } }, + 'http://localhost/g', 'localhost'], + [{ host: 'localhost', port: 80, defaultPort: 8080, path: '/h', headers: { Host: 'localhost' } }, + 'http://localhost/h', 'localhost'], + [{ host: 'localhost', port: 80, path: '/i', headers: { Host: 'localhost:80' } }, + 'http://localhost/i', 'localhost'], + [{ host: 'localhost', port: 3000, defaultPort: 3000, path: '/j', headers: { Host: 'localhost:3000' } }, + 'http://localhost:3000/j', 'localhost:3000'], + [{ host: 'localhost', port: 3000, path: '/k', headers: { Host: 'LOCALHOST:3000' } }, + 'http://localhost:3000/k', 'localhost:3000'], + + // setHost=false with user-provided Host. + [{ host: 'localhost', port: 3000, setHost: false, path: '/l', headers: { Host: 'localhost:3000' } }, + 'http://localhost:3000/l', 'localhost:3000'], + + // IPv6. + [{ host: '::1', port: 3000, path: '/m' }, + 'http://[::1]:3000/m', '[::1]:3000'], + [{ host: '::1', port: 80, path: '/n' }, + 'http://[::1]/n', '[::1]'], + + // Mixed-case host option: URL normalizes to lowercase. + [{ host: 'LocalHost', port: 3000, path: '/o' }, + 'http://localhost:3000/o', 'localhost:3000'], + + // Absolute-form path matching authority. + [{ host: 'localhost', port: 3000, path: 'http://localhost:3000/p', headers: { Host: 'localhost:3000' } }, + 'http://localhost:3000/p', 'localhost:3000'], +]; + +for (const [options, expectedPath, expectedHost] of cases) { + check(options, expectedPath, expectedHost); +} diff --git a/test/client-proxy/test-http-proxy-request-origin-form-path.mjs b/test/client-proxy/test-http-proxy-request-origin-form-path.mjs new file mode 100644 index 00000000000000..011276de03895a --- /dev/null +++ b/test/client-proxy/test-http-proxy-request-origin-form-path.mjs @@ -0,0 +1,45 @@ +// This tests that origin-form paths are preserved when proxied, including +// paths starting with //. + +import * as common from '../common/index.mjs'; +import assert from 'node:assert'; +import http from 'node:http'; +import { once } from 'events'; +import { createProxyServer } from '../common/proxy-server.js'; + +const server = http.createServer(common.mustCall((req, res) => { + res.end('Hello world'); +}, 1)); +server.on('error', common.mustNotCall()); +server.listen(0); +await once(server, 'listening'); + +const { proxy, logs } = createProxyServer(); +proxy.listen(0); +await once(proxy, 'listening'); + +const port = server.address().port; +const serverHost = `localhost:${port}`; + +const agent = new http.Agent({ + proxyEnv: { + HTTP_PROXY: `http://localhost:${proxy.address().port}`, + }, +}); + +const req = http.request({ agent, host: 'localhost', port, path: '//foo/bar' }); +req.end(); +const [res] = await once(req, 'response'); +res.setEncoding('utf8'); +let body = ''; +for await (const chunk of res) body += chunk; +assert.strictEqual(body, 'Hello world'); + +assert.strictEqual(logs.length, 1); +assert.strictEqual(logs[0].method, 'GET'); +assert.strictEqual(logs[0].url, `http://${serverHost}//foo/bar`); +assert.strictEqual(logs[0].headers.host, serverHost); + +server.close(); +proxy.close(); +agent.destroy(); diff --git a/test/client-proxy/test-http-proxy-request-validation-errors.mjs b/test/client-proxy/test-http-proxy-request-validation-errors.mjs new file mode 100644 index 00000000000000..8607b60fb3875c --- /dev/null +++ b/test/client-proxy/test-http-proxy-request-validation-errors.mjs @@ -0,0 +1,119 @@ +// This tests that proxied HTTP requests fail validation before sending any data. + +import * as common from '../common/index.mjs'; +import assert from 'node:assert'; +import http from 'node:http'; +import { once } from 'events'; +import { createProxyServer } from '../common/proxy-server.js'; + +const target = http.createServer(common.mustNotCall()); +target.listen(0); +await once(target, 'listening'); +target.on('error', common.mustNotCall()); + +const { proxy, logs } = createProxyServer(); +proxy.listen(0); +await once(proxy, 'listening'); + +const agent = new http.Agent({ + proxyEnv: { + HTTP_PROXY: `http://localhost:${proxy.address().port}`, + }, +}); + +const port = target.address().port; +const base = { host: 'localhost', port, path: '/test', agent }; + +function throwsWith(message, cases) { + for (const { name, options } of cases) { + assert.throws(() => { + http.request(options, common.mustNotCall()); + }, { code: 'ERR_INVALID_ARG_VALUE', message }, name); + } +} + +// Path authority or Host header disagrees with options.host:port. +throwsWith(/must match the request authority/, [ + { name: 'absolute path with different host', + options: { ...base, path: 'http://example.test/test' } }, + { name: 'object Host header with different host', + options: { ...base, headers: { Host: 'bad.example' } } }, + { name: 'object Host header with different port', + options: { ...base, headers: { Host: 'localhost:1' } } }, + { name: 'array Host header with different host', + options: { ...base, headers: ['Host', 'bad.example'] } }, + { name: 'array-of-pairs Host header with different host', + options: { ...base, headers: [['Host', 'bad.example']] } }, + { name: 'array Host header omitting port for non-default port', + options: { ...base, headers: ['Host', 'localhost'] } }, + { name: 'absolute path omitting port for non-default port', + options: { ...base, path: 'http://localhost/test' } }, + { name: 'object Host header omitting port when defaultPort suppresses it', + options: { ...base, defaultPort: port, headers: { Host: 'localhost' } } }, +]); + +// Absolute-form path uses a non-http scheme. +throwsWith(/must use http: scheme/, [ + { name: 'absolute path with https: scheme', + options: { ...base, path: `https://localhost:${port}/test` } }, +]); + +// Absolute-form path contains userinfo. +throwsWith(/must not contain userinfo/, [ + { name: 'absolute path with userinfo', + options: { ...base, path: `http://user:pass@localhost:${port}/test` } }, +]); + +// Origin-form path must start with /. +throwsWith(/must be in absolute-form or start with \//, [ + { name: 'path without leading slash but includes @', + options: { ...base, path: '@other.example/test' } }, +]); + +// Host header value is not a bare authority (contains userinfo, path, query, or fragment). +throwsWith(/must match the request authority/, [ + { name: 'Host with userinfo', + options: { ...base, headers: { Host: `user@localhost:${port}` } } }, + { name: 'Host with path', + options: { ...base, headers: { Host: `localhost:${port}/path` } } }, + { name: 'Host with query', + options: { ...base, headers: { Host: `localhost:${port}?x` } } }, + { name: 'Host with fragment', + options: { ...base, headers: { Host: `localhost:${port}#x` } } }, +]); + +// Multiple Host headers (invalid per RFC 9110 Section 5.3). +throwsWith(/must not contain duplicate Host headers/, [ + { name: 'flat array with duplicate Host', + options: { ...base, headers: ['Host', `localhost:${port}`, 'Host', 'bad.example'] } }, + { name: 'array-of-pairs with duplicate Host', + options: { ...base, headers: [['Host', `localhost:${port}`], ['Host', 'bad.example']] } }, + { name: 'case-insensitive duplicate Host', + options: { ...base, headers: ['host', `localhost:${port}`, 'HOST', 'bad.example'] } }, +]); + +// Non-array entry in array-of-pairs form. +throwsWith(/must be an array when headers is passed as an array of pairs/, [ + { name: 'object with numeric keys smuggling a Host', + options: { ...base, headers: [['Host', `localhost:${port}`], { 0: 'Host', 1: 'bad.example' }] } }, +]); + +// Invalid port. +for (const { name, badPort } of [ + { name: 'port >= 65536', badPort: 99999 }, + { name: 'port === 65536', badPort: 65536 }, +]) { + assert.throws(() => { + http.request({ ...base, port: badPort }, common.mustNotCall()); + }, { code: 'ERR_SOCKET_BAD_PORT' }, name); +} + +assert.throws(() => { + http.request({ ...base, method: 'GET', path: '*' }, common.mustNotCall()); +}, { code: 'ERR_INVALID_ARG_VALUE', message: /must be in absolute-form or start with \// }); + +assert.deepStrictEqual(logs, []); + +target.close(); +proxy.close(); +agent.destroy(); diff --git a/test/common/proxy-server.js b/test/common/proxy-server.js index c4cd19fe610b81..a2f8bd12e6257e 100644 --- a/test/common/proxy-server.js +++ b/test/common/proxy-server.js @@ -34,13 +34,11 @@ function createProxyServer(options = {}) { } proxy.on('request', (req, res) => { logRequest(logs, req); - const { hostname, port } = new URL(`http://${req.headers.host}`); - const targetPort = port || 80; - + // Route based on the absolute-form request-target. const url = new URL(req.url); const options = { - hostname: hostname.startsWith('[') ? hostname.slice(1, -1) : hostname, - port: targetPort, + hostname: url.hostname.startsWith('[') ? url.hostname.slice(1, -1) : url.hostname, + port: url.port || 80, path: url.pathname + url.search, // Convert back to relative URL. method: req.method, headers: { diff --git a/test/parallel/test-tls-client-getephemeralkeyinfo.js b/test/parallel/test-tls-client-getephemeralkeyinfo.js index 2107d024012c4d..ea6dec7bdc4629 100644 --- a/test/parallel/test-tls-client-getephemeralkeyinfo.js +++ b/test/parallel/test-tls-client-getephemeralkeyinfo.js @@ -18,9 +18,6 @@ const tls = require('tls'); const key = fixtures.readKey('agent2-key.pem'); const cert = fixtures.readKey('agent2-cert.pem'); -// TODO(@sam-github) test works with TLS1.3, rework test to add -// 'ECDH' with 'TLS_AES_128_GCM_SHA256', - function loadDHParam(n) { return fixtures.readKey(`dh${n}.pem`); } @@ -89,3 +86,57 @@ test(256, 'ECDH', 'prime256v1', 'ECDHE-RSA-AES256-GCM-SHA384'); test(521, 'ECDH', 'secp521r1', 'ECDHE-RSA-AES256-GCM-SHA384'); test(253, 'ECDH', 'X25519', 'ECDHE-RSA-AES256-GCM-SHA384'); test(448, 'ECDH', 'X448', 'ECDHE-RSA-AES256-GCM-SHA384'); + +function testTLS13Group(size, type, name) { + const options = { + key, + cert, + ecdhCurve: name, + minVersion: 'TLSv1.3', + maxVersion: 'TLSv1.3', + }; + + const server = tls.createServer(options, common.mustCall((conn) => { + assert.strictEqual(conn.getEphemeralKeyInfo(), null); + conn.end(); + })); + + server.on('close', common.mustSucceed()); + + server.listen(0, common.mustCall(() => { + const client = tls.connect({ + port: server.address().port, + rejectUnauthorized: false, + ecdhCurve: name, + minVersion: 'TLSv1.3', + maxVersion: 'TLSv1.3', + }, common.mustCall(() => { + const ekeyinfo = client.getEphemeralKeyInfo(); + assert.strictEqual(ekeyinfo.type, type); + assert.strictEqual(ekeyinfo.size, size); + assert.strictEqual(ekeyinfo.name, name); + server.close(); + })); + client.on('secureConnect', common.mustCall()); + })); +} + +testTLS13Group(253, 'ECDH', 'X25519'); + +if (hasOpenSSL(3, 5)) { + const tls13Groups = [ + 'MLKEM512', + 'MLKEM768', + 'MLKEM1024', + 'SecP256r1MLKEM768', + 'X25519MLKEM768', + 'SecP384r1MLKEM1024', + ]; + + if (hasOpenSSL(4, 0)) { + tls13Groups.push('curveSM2'); + tls13Groups.push('curveSM2MLKEM768'); + } + + tls13Groups.forEach((name) => testTLS13Group(undefined, 'TLSGroup', name)); +} diff --git a/tools/dep_updaters/c-ares.kbx b/tools/dep_updaters/c-ares.kbx new file mode 100644 index 00000000000000..720f1df36917d7 Binary files /dev/null and b/tools/dep_updaters/c-ares.kbx differ diff --git a/tools/dep_updaters/update-c-ares.sh b/tools/dep_updaters/update-c-ares.sh index f990430d9f6aee..ca77ee52183d64 100755 --- a/tools/dep_updaters/update-c-ares.sh +++ b/tools/dep_updaters/update-c-ares.sh @@ -1,5 +1,5 @@ #!/bin/sh -set -e +set -ex # Shell script to update c-ares in the source tree to a specific version BASE_DIR=$(cd "$(dirname "$0")/../.." && pwd) @@ -22,11 +22,11 @@ if (!res.ok) throw new Error(`FetchError: ${res.status} ${res.statusText}`, { ca const { tag_name, assets } = await res.json(); const { browser_download_url, name } = assets.find(({ name }) => name.endsWith('.tar.gz')); if(!browser_download_url || !name) throw new Error('No tarball found'); -console.log(`${tag_name} ${browser_download_url} ${name}`); +console.log(`${tag_name.replace(/^v/, '')} ${browser_download_url}`); EOF )" -IFS=' ' read -r NEW_VERSION NEW_VERSION_URL ARES_TARBALL < /dev/null || mktemp -d -t 'tmp') +ARES_TARBALL="$WORKSPACE/c-ares.tgz" cleanup () { EXIT_CODE=$? @@ -47,29 +48,29 @@ cleanup () { trap cleanup INT TERM EXIT -cd "$WORKSPACE" - echo "Fetching c-ares source archive" curl -sL -o "$ARES_TARBALL" "$NEW_VERSION_URL" -log_and_verify_sha256sum "c-ares" "$ARES_TARBALL" -gzip -dc "$ARES_TARBALL" | tar xf - -rm -- "$ARES_TARBALL" -FOLDER=$(ls -d -- */) +echo "Verifying PGP signature" +curl -sL -o "$ARES_TARBALL.asc" "$NEW_VERSION_URL.asc" +gpgv --keyring "$BASE_DIR/tools/dep_updaters/c-ares.kbx" "${ARES_TARBALL}.asc" "${ARES_TARBALL}" + +log_and_verify_sha256sum "c-ares" "$ARES_TARBALL" +tar -C "$WORKSPACE" -xzf "$ARES_TARBALL" -mv -- "$FOLDER" cares +ARES_FOLDER=$(find "$WORKSPACE" -mindepth 1 -maxdepth 1 -type d -print -quit) echo "Removing tests" -rm -rf "$WORKSPACE/cares/test" +rm -rf "$ARES_FOLDER/test" echo "Copying existing .gitignore, config, gyp and gn files" -cp -R "$DEPS_DIR/cares/config" "$WORKSPACE/cares" -cp "$DEPS_DIR/cares/.gitignore" "$WORKSPACE/cares" -cp "$DEPS_DIR/cares/cares.gyp" "$WORKSPACE/cares" -cp "$DEPS_DIR/cares/"*.gn "$DEPS_DIR/cares/"*.gni "$WORKSPACE/cares" +cp -R "$DEPS_DIR/cares/config" "$ARES_FOLDER" +cp "$DEPS_DIR/cares/.gitignore" "$ARES_FOLDER" +cp "$DEPS_DIR/cares/cares.gyp" "$ARES_FOLDER" +cp "$DEPS_DIR/cares/"*.gn "$DEPS_DIR/cares/"*.gni "$ARES_FOLDER" echo "Replacing existing c-ares" -replace_dir "$DEPS_DIR/cares" "$WORKSPACE/cares" +replace_dir "$DEPS_DIR/cares" "$ARES_FOLDER" echo "Updating cares.gyp" "$NODE" "$ROOT/tools/dep_updaters/update-c-ares.mjs" diff --git a/tools/v8_gypfiles/v8.gyp b/tools/v8_gypfiles/v8.gyp index 38732b4b34cf68..193b8aebdd28f1 100644 --- a/tools/v8_gypfiles/v8.gyp +++ b/tools/v8_gypfiles/v8.gyp @@ -671,6 +671,11 @@ '