Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 14 additions & 8 deletions lib/request.js
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ exports = module.exports = internals.Request = class {
return url;
}

const host = this.info.host || this._formatIpv6Host(this._core.info.host, this._core.info.port);
const host = this.info.host || internals.Request.formatHost(this._core.info.host, this._core.info.port);

this._url = new Url.URL(`${this._core.info.protocol}://${host}${source}`);
}
Expand All @@ -204,16 +204,11 @@ exports = module.exports = internals.Request = class {
return this._url;
}

_isBareIpv6(host) {
static formatHost(host, port) {

// An IPv6 address contains at least two colons.

return /:[^:]*:/.test(host);
}

_formatIpv6Host(host, port) {

return this._isBareIpv6(host) ? `[${host}]:${port}` : `${host}:${port}`;
return /:[^:]*:/.test(host) ? `[${host}]:${port}` : `${host}:${port}`;
}

_normalizePath(url, options) {
Expand Down Expand Up @@ -694,6 +689,12 @@ internals.Info = class {
request._hostError = Boom.badRequest('Host header does not match :authority pseudo-header');
}

// RFC 9112 3.2 - an invalid host is a 400, otherwise a '/', '?' or '#' in it shifts request.url's path

if (!internals.hostRx.test(host)) {
request._hostError = Boom.badRequest('Invalid host header');
}

this.received = received;
this.referrer = req.headers.referrer || req.headers.referer || '';
this.host = host;
Expand Down Expand Up @@ -761,6 +762,11 @@ internals.Info = class {
};


// RFC 3986 3.2.2 - IP-literal or reg-name, IPv4 being a subset of reg-name, with an optional port

internals.hostRx = /^(?:\[[\w:.%~-]+\]|[\w.~!$&'()*+,;=%-]*)(?::\d*)?$/;


internals.isSameAuthority = function (host, authority, scheme) {

if (!authority ||
Expand Down
3 changes: 2 additions & 1 deletion lib/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const Core = require('./core');
const Cors = require('./cors');
const Ext = require('./ext');
const Package = require('../package.json');
const Request = require('./request');
const Route = require('./route');
const Toolkit = require('./toolkit');
const Validation = require('./validation');
Expand Down Expand Up @@ -322,7 +323,7 @@ internals.Server = class {
delete settings.plugins;
delete settings.allowInternals;

settings.authority = settings.authority ?? this._core.info.host + ':' + this._core.info.port;
settings.authority = settings.authority ?? Request.formatHost(this._core.info.host, this._core.info.port);
}

Hoek.assert(!options.credentials, 'options.credentials no longer supported (use options.auth)');
Expand Down
56 changes: 56 additions & 0 deletions test/request.js
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,62 @@ describe('Request', () => {
expect(res.result.message).to.equal('Host header does not match :authority pseudo-header');
});

it('rejects a host header that would move the boundary between authority and path', async () => {

const server = Hapi.server();
server.ext('onRequest', (request, h) => {

if (request.url.pathname.startsWith('/admin')) {
return h.response('denied').code(401).takeover();
}

return h.continue;
});

server.route({ method: 'GET', path: '/admin/secret', handler: () => 'SECRET' });

await server.start();

const send = async (host) => {

const socket = Net.createConnection(server.info.port, '127.0.0.1', () => {

socket.write(`GET /admin/secret HTTP/1.1\r\nHost: ${host}\r\nConnection: close\r\n\r\n`);
});

let response = '';
socket.on('data', (chunk) => {

response += chunk.toString();
});

await Events.once(socket, 'close');
return response;
};

for (const host of ['evil.com/abc?x=', 'evil.com?/abc', 'evil.com#/abc', 'evil.com:fake@127.0.0.1', 'evil.com\\abc']) {
const response = await send(host);
expect(response, host).to.startWith('HTTP/1.1 400 ');
expect(response, host).to.not.contain('SECRET');
}

expect(await send('127.0.0.1')).to.startWith('HTTP/1.1 401 ');

await server.stop();
});

it('accepts valid host headers', async () => {

const server = Hapi.server();
server.route({ method: 'GET', path: '/', handler: (request) => `${request.info.hostname}|${request.url.pathname}` });

for (const host of ['example.com', 'example.com:8080', 'EXAMPLE.com', 'localhost', '[::1]:8080', '[2001:db8::1]', 'xn--bcher-kva.example', 'my_host.example', 'a.b.c.example', '127.0.0.1:80', '']) {
const res = await server.inject({ url: '/', headers: { host } });
expect(res.statusCode, host).to.equal(200);
expect(res.result, host).to.endWith('|/');
}
});

it('rejects a request with conflicting host and :authority headers on a vhost route', async () => {

const server = Hapi.server();
Expand Down
Loading