current commit
260701.3 · http: document and validate options.path when it's in absolute-form
// 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();