From 82f6499ab929fd2d3ccc27c3904f94959109f7cd Mon Sep 17 00:00:00 2001 From: Efe Karasakal Date: Fri, 31 Jul 2026 18:32:40 +0200 Subject: [PATCH 1/3] http: normalize CONNECT request paths Signed-off-by: Efe Karasakal --- doc/api/http.md | 3 + lib/_http_client.js | 40 +++++++++- .../test-http-request-connect-path.js | 75 +++++++++++++++++++ 3 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 test/parallel/test-http-request-connect-path.js diff --git a/doc/api/http.md b/doc/api/http.md index 92dcee74a6f0..8d9965e7a0f5 100644 --- a/doc/api/http.md +++ b/doc/api/http.md @@ -4113,6 +4113,9 @@ 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:** `'/'`. + With `method` set to `'CONNECT'`, `path` must be an authority in the form + `host:port`, such as `'www.example.com:80'` or `'[2001:db8::1]:443'`. + Invalid values throw an `ERR_INVALID_ARG_VALUE` error. 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 diff --git a/lib/_http_client.js b/lib/_http_client.js index 6a070b0f0a10..b7e0154b5268 100644 --- a/lib/_http_client.js +++ b/lib/_http_client.js @@ -121,6 +121,7 @@ let debug = require('internal/util/debuglog').debuglog('http', (fn) => { }); const INVALID_PATH_REGEX = /[^\u0021-\u00ff]/; +const CONNECT_PATH_REGEX = /^(\[[^\]]+\]|[^:]+):(\d+)$/; const kError = Symbol('kError'); const kPath = Symbol('kPath'); const kAuthority = Symbol('kAuthority'); @@ -193,6 +194,27 @@ function authoritiesMatch(canonicalHost, hostFromHeader) { return parsed.host === canonicalHost; } +function isValidConnectPath(path) { + const match = CONNECT_PATH_REGEX.exec(path); + if (match === null) { + return false; + } + + try { + validatePort(match[2], 'options.path', false); + const url = new URL(`http://${path}`); + + return url.hostname !== '' && + url.username === '' && + url.password === '' && + url.pathname === '/' && + url.search === '' && + url.hash === ''; + } catch { + return false; + } +} + // 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 @@ -466,7 +488,23 @@ function ClientRequest(input, options, cb) { this.joinDuplicateHeaders = options.joinDuplicateHeaders; - this[kPath] = options.path || '/'; + let path = options.path || '/'; + if (method === 'CONNECT' && options.path != null) { + path = String(options.path); + if (path[0] === '/') { + path = path.slice(1) || '/'; + } + + if (!isValidConnectPath(path)) { + throw new ERR_INVALID_ARG_VALUE( + 'options.path', + path, + 'must be a valid host:port combo', + ); + } + } + + this[kPath] = path; if (cb) { this.once('response', cb); } diff --git a/test/parallel/test-http-request-connect-path.js b/test/parallel/test-http-request-connect-path.js new file mode 100644 index 000000000000..a807ae7255d9 --- /dev/null +++ b/test/parallel/test-http-request-connect-path.js @@ -0,0 +1,75 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const http = require('http'); + +for (const path of [ + '', + 'example.com', + 'example.com:0', + 'example.com:65536', + 'example.com:8080/example', + 'evil.com:666/good.org:777', + '/example.com', +]) { + assert.throws(() => http.request({ + method: 'CONNECT', + path, + }), { + code: 'ERR_INVALID_ARG_VALUE', + name: 'TypeError', + message: /^The property 'options\.path' must be a valid host:port combo\./, + }); +} + +{ + const server = http.createServer(common.mustNotCall()); + + server.on('connect', common.mustCall((req, socket) => { + assert.strictEqual(req.url, 'example.com:80'); + socket.end('HTTP/1.1 501 Not Implemented\r\n\r\n'); + })); + + server.listen(0, common.mustCall(() => { + const port = server.address().port; + const req = http.request( + new URL(`http://localhost:${port}/example.com:80`), + { method: 'CONNECT' }, + ); + + req.on('connect', common.mustCall((res, socket) => { + assert.strictEqual(res.statusCode, 501); + socket.destroy(); + server.close(); + })); + + req.end(); + })); +} + +{ + const server = http.createServer(common.mustNotCall()); + + server.on('connect', common.mustCall((req, socket) => { + assert.strictEqual(req.url, '[2001:db8::1]:111'); + socket.end('HTTP/1.1 501 Not Implemented\r\n\r\n'); + })); + + server.listen(0, common.mustCall(() => { + const req = http.request({ + host: 'localhost', + port: server.address().port, + method: 'CONNECT', + path: '[2001:db8::1]:111', + }); + + req.on('connect', common.mustCall((res, socket) => { + assert.strictEqual(res.statusCode, 501); + socket.destroy(); + server.close(); + })); + + req.end(); + })); +} From 673924c2b21e549aff0df28d8530596150834278 Mon Sep 17 00:00:00 2001 From: Efe Karasakal Date: Sat, 1 Aug 2026 14:44:23 +0200 Subject: [PATCH 2/3] http: don't throw in isValidConnectPath --- lib/_http_client.js | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/lib/_http_client.js b/lib/_http_client.js index b7e0154b5268..7d7e75379839 100644 --- a/lib/_http_client.js +++ b/lib/_http_client.js @@ -60,7 +60,7 @@ const { const Agent = require('_http_agent'); const { Buffer } = require('buffer'); const { defaultTriggerAsyncIdScope } = require('internal/async_hooks'); -const { URL, urlToHttpOptions, isURL } = require('internal/url'); +const { URL, URLParse, urlToHttpOptions, isURL } = require('internal/url'); const { kOutHeaders, kNeedDrain, @@ -196,23 +196,18 @@ function authoritiesMatch(canonicalHost, hostFromHeader) { function isValidConnectPath(path) { const match = CONNECT_PATH_REGEX.exec(path); - if (match === null) { + if (match === null || +match[2] === 0) { return false; } - try { - validatePort(match[2], 'options.path', false); - const url = new URL(`http://${path}`); - - return url.hostname !== '' && - url.username === '' && - url.password === '' && - url.pathname === '/' && - url.search === '' && - url.hash === ''; - } catch { - return false; - } + const url = URLParse(`http://${path}`); + return url !== null && + url.hostname !== '' && + url.username === '' && + url.password === '' && + url.pathname === '/' && + url.search === '' && + url.hash === ''; } // https://datatracker.ietf.org/doc/html/rfc9112#section-3.2 From be15e6624e2ce9ac6bd0c40b136f69801c59968e Mon Sep 17 00:00:00 2001 From: Efe Karasakal Date: Wed, 16 Sep 2026 20:32:47 +0200 Subject: [PATCH 3/3] http: simplify the approach --- doc/api/http.md | 3 -- lib/_http_client.js | 46 ++++++------------- .../test-http-request-connect-path.js | 27 ++--------- 3 files changed, 19 insertions(+), 57 deletions(-) diff --git a/doc/api/http.md b/doc/api/http.md index 8d9965e7a0f5..92dcee74a6f0 100644 --- a/doc/api/http.md +++ b/doc/api/http.md @@ -4113,9 +4113,6 @@ 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:** `'/'`. - With `method` set to `'CONNECT'`, `path` must be an authority in the form - `host:port`, such as `'www.example.com:80'` or `'[2001:db8::1]:443'`. - Invalid values throw an `ERR_INVALID_ARG_VALUE` error. 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 diff --git a/lib/_http_client.js b/lib/_http_client.js index 7d7e75379839..cad31bc676fa 100644 --- a/lib/_http_client.js +++ b/lib/_http_client.js @@ -29,6 +29,7 @@ const { ObjectAssign, ObjectDefineProperty, ObjectKeys, + ObjectPrototypeHasOwnProperty, ObjectSetPrototypeOf, ReflectApply, String, @@ -60,7 +61,7 @@ const { const Agent = require('_http_agent'); const { Buffer } = require('buffer'); const { defaultTriggerAsyncIdScope } = require('internal/async_hooks'); -const { URL, URLParse, urlToHttpOptions, isURL } = require('internal/url'); +const { URL, urlToHttpOptions, isURL } = require('internal/url'); const { kOutHeaders, kNeedDrain, @@ -121,7 +122,6 @@ let debug = require('internal/util/debuglog').debuglog('http', (fn) => { }); const INVALID_PATH_REGEX = /[^\u0021-\u00ff]/; -const CONNECT_PATH_REGEX = /^(\[[^\]]+\]|[^:]+):(\d+)$/; const kError = Symbol('kError'); const kPath = Symbol('kPath'); const kAuthority = Symbol('kAuthority'); @@ -194,22 +194,6 @@ function authoritiesMatch(canonicalHost, hostFromHeader) { return parsed.host === canonicalHost; } -function isValidConnectPath(path) { - const match = CONNECT_PATH_REGEX.exec(path); - if (match === null || +match[2] === 0) { - return false; - } - - const url = URLParse(`http://${path}`); - return url !== null && - url.hostname !== '' && - url.username === '' && - url.password === '' && - url.pathname === '/' && - url.search === '' && - url.hash === ''; -} - // 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 @@ -348,12 +332,15 @@ function rewriteForProxiedHttp(req, reqOptions, proxyAuthority, userHostHeader, function ClientRequest(input, options, cb) { OutgoingMessage.call(this); + let pathIsFromURL = false; if (typeof input === 'string') { const urlStr = input; input = urlToHttpOptions(new URL(urlStr)); + pathIsFromURL = true; } else if (isURL(input)) { // url.URL instance input = urlToHttpOptions(input); + pathIsFromURL = true; } else { cb = options; options = input; @@ -364,6 +351,13 @@ function ClientRequest(input, options, cb) { cb = options; options = input || kEmptyObject; } else { + const hasPathOverride = pathIsFromURL && + options != null && + ObjectPrototypeHasOwnProperty(options, 'path'); + if (hasPathOverride) { + pathIsFromURL = false; + } + options = ObjectAssign({ __proto__: null }, input, options); } @@ -484,19 +478,9 @@ function ClientRequest(input, options, cb) { this.joinDuplicateHeaders = options.joinDuplicateHeaders; let path = options.path || '/'; - if (method === 'CONNECT' && options.path != null) { - path = String(options.path); - if (path[0] === '/') { - path = path.slice(1) || '/'; - } - - if (!isValidConnectPath(path)) { - throw new ERR_INVALID_ARG_VALUE( - 'options.path', - path, - 'must be a valid host:port combo', - ); - } + // Strip the leading slash added when the CONNECT target comes from a URL. + if (method === 'CONNECT' && pathIsFromURL && path[0] === '/') { + path = path.slice(1) || '/'; } this[kPath] = path; diff --git a/test/parallel/test-http-request-connect-path.js b/test/parallel/test-http-request-connect-path.js index a807ae7255d9..91b3cc739431 100644 --- a/test/parallel/test-http-request-connect-path.js +++ b/test/parallel/test-http-request-connect-path.js @@ -4,37 +4,18 @@ const common = require('../common'); const assert = require('assert'); const http = require('http'); -for (const path of [ - '', - 'example.com', - 'example.com:0', - 'example.com:65536', - 'example.com:8080/example', - 'evil.com:666/good.org:777', - '/example.com', -]) { - assert.throws(() => http.request({ - method: 'CONNECT', - path, - }), { - code: 'ERR_INVALID_ARG_VALUE', - name: 'TypeError', - message: /^The property 'options\.path' must be a valid host:port combo\./, - }); -} - { const server = http.createServer(common.mustNotCall()); server.on('connect', common.mustCall((req, socket) => { - assert.strictEqual(req.url, 'example.com:80'); + assert.strictEqual(req.url, 'example.com'); socket.end('HTTP/1.1 501 Not Implemented\r\n\r\n'); })); server.listen(0, common.mustCall(() => { const port = server.address().port; const req = http.request( - new URL(`http://localhost:${port}/example.com:80`), + new URL(`http://localhost:${port}/example.com`), { method: 'CONNECT' }, ); @@ -52,7 +33,7 @@ for (const path of [ const server = http.createServer(common.mustNotCall()); server.on('connect', common.mustCall((req, socket) => { - assert.strictEqual(req.url, '[2001:db8::1]:111'); + assert.strictEqual(req.url, '/example.com'); socket.end('HTTP/1.1 501 Not Implemented\r\n\r\n'); })); @@ -61,7 +42,7 @@ for (const path of [ host: 'localhost', port: server.address().port, method: 'CONNECT', - path: '[2001:db8::1]:111', + path: '/example.com', }); req.on('connect', common.mustCall((res, socket) => {