mirror of
https://github.com/nodejs/node.git
synced 2026-10-28 18:25:13 +08:00
When `options.path` passed to `http.request()` contains an absolute URL, `http.request` has been sending it directly as the request target in the HTTP 1.1 message. If the receiving server is a proxy, the proxy server typically forwards the request to the destination specified in the request target and ignores the Host header. This means eventually the request can be forwarded to a destination that is not consistent with `options.host`, depending on how the receiving server behaves. Mimatched Host header and request target also violates RFC 9112 Section 3.2, which we have been entirely leaving to the users to verify. This patch documents this behavior and warns that the user needs to ensure the `path`, `option` and `headers` conform to the RFC. If the receiving server is known to be a proxy server because the request is routed by Node.js' built-in HTTP proxy support, we now do a best-effort check to verify that the authority in `options.path` (if absolute), Host headers and `options.host` agree at request construction time. Node.js will give up on the require target rewriting and throw an error when they don't match at request construction. Signed-off-by: Joyee Cheung <joyeec9h3@gmail.com> PR-URL: https://github.com/nodejs/node/pull/64108 Reviewed-By: Matteo Collina <matteo.collina@gmail.com> Reviewed-By: Tim Perry <pimterry@gmail.com> Reviewed-By: Ulises Gascón <ulisesgascongonzalez@gmail.com>
120 lines
4.5 KiB
JavaScript
120 lines
4.5 KiB
JavaScript
// 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();
|