Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fixed parsing of x-forwarded-* headers #12130

Merged
merged 13 commits into from
Oct 9, 2024
7 changes: 7 additions & 0 deletions .changeset/slimy-buses-agree.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'astro': patch
---

Fixes a bug in the parsing of `x-forwarded-\*` `Request` headers, where multiple values assigned to those headers were not correctly parsed.

Now, headers like `x-forwarded-proto: https,http` are correctly parsed.
46 changes: 33 additions & 13 deletions packages/astro/src/core/app/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,16 +61,33 @@ export class NodeApp extends App {
* ```
*/
static createRequest(req: NodeRequest, { skipBody = false } = {}): Request {
const protocol =
req.headers['x-forwarded-proto'] ??
('encrypted' in req.socket && req.socket.encrypted ? 'https' : 'http');
const hostname =
req.headers['x-forwarded-host'] ?? req.headers.host ?? req.headers[':authority'];
const port = req.headers['x-forwarded-port'];
const isEncrypted = 'encrypted' in req.socket && req.socket.encrypted;

// Parses multiple header and returns first value if available.
const getFirstForwardedValue = (multiValueHeader?: string | string[]) => {
return multiValueHeader
?.toString()
?.split(',')
.map((e) => e.trim())?.[0];
};

// Get the used protocol between the end client and first proxy.
// NOTE: Some proxies append values with spaces and some do not.
// We need to handle it here and parse the header correctly.
// @example "https, http,http" => "http"
const forwardedProtocol = getFirstForwardedValue(req.headers['x-forwarded-proto']);
const protocol = forwardedProtocol ?? (isEncrypted ? 'https' : 'http');

// @example "example.com,www2.example.com" => "example.com"
const forwardedHostname = getFirstForwardedValue(req.headers['x-forwarded-host']);
const hostname = forwardedHostname ?? req.headers.host ?? req.headers[':authority'];

const portInHostname =
typeof hostname === 'string' && typeof port === 'string' && hostname.endsWith(port);
const hostnamePort = portInHostname ? hostname : hostname + (port ? `:${port}` : '');
// @example "443,8080,80" => "443"
const forwardedPort = getFirstForwardedValue(req.headers['x-forwarded-port']);
const port = forwardedPort ?? req.socket?.remotePort?.toString() ?? (isEncrypted ? '443' : '80');

const portInHostname = typeof hostname === 'string' && /:\d+$/.test(hostname);
const hostnamePort = portInHostname ? hostname : `${hostname}:${port}`
Comment on lines +89 to +90
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't part of the PR description; why was this changed? What are we fixing?

Copy link
Contributor Author

@thehansys thehansys Oct 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The old code was adding port to URL only when x-forwarded-port header was present and part of host header. This change handles edge-case when the port isn't part of the "host" header, the server listens on a non-standard port and there's no x-forward-port header in req. In that case it should fallback to req.socket.remotePort, default port for protocol in the following order and add it to URL (default ports such as 80 and 443 are hidden in the URL).

Althought, it's unlikely this would happen in a real-world scenario without broken proxy config / missing request headers.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the thorough explanation!


const url = `${protocol}://${hostnamePort}${req.url}`;
const options: RequestInit = {
Expand All @@ -81,14 +98,17 @@ export class NodeApp extends App {
if (bodyAllowed) {
Object.assign(options, makeRequestBody(req));
}

const request = new Request(url, options);

const clientIp = req.headers['x-forwarded-for'];
if (clientIp) {
// Get the IP of end client behind the proxy.
// @example "1.1.1.1,8.8.8.8" => "1.1.1.1"
const forwardedClientIp = getFirstForwardedValue(req.headers['x-forwarded-for']);
const clientIp = forwardedClientIp || req.socket?.remoteAddress;
if (clientIp) {
Reflect.set(request, clientAddressSymbol, clientIp);
} else if (req.socket?.remoteAddress) {
Reflect.set(request, clientAddressSymbol, req.socket.remoteAddress);
}

return request;
}

Expand Down
56 changes: 40 additions & 16 deletions packages/astro/test/client-address-node.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,47 @@ import { loadFixture } from './test-utils.js';
import { createRequestAndResponse } from './units/test-utils.js';

describe('NodeClientAddress', () => {
it('clientAddress is 1.1.1.1', async () => {
const fixture = await loadFixture({
root: './fixtures/client-address-node/',
describe('single value', () => {
it('clientAddress is 1.1.1.1', async () => {
const fixture = await loadFixture({
root: './fixtures/client-address-node/',
});
await fixture.build();
const handle = await fixture.loadNodeAdapterHandler();
const { req, res, text } = createRequestAndResponse({
method: 'GET',
url: '/',
headers: {
'x-forwarded-for': '1.1.1.1',
},
});
handle(req, res);
const html = await text();
const $ = cheerio.load(html);
assert.equal(res.statusCode, 200);
assert.equal($('#address').text(), '1.1.1.1');
});
await fixture.build();
const handle = await fixture.loadNodeAdapterHandler();
const { req, res, text } = createRequestAndResponse({
method: 'GET',
url: '/',
headers: {
'x-forwarded-for': '1.1.1.1',
},
});

describe('multiple values', () => {
it('clientAddress is 1.1.1.1', async () => {
const fixture = await loadFixture({
root: './fixtures/client-address-node/',
});
await fixture.build();
const handle = await fixture.loadNodeAdapterHandler();
const { req, res, text } = createRequestAndResponse({
method: 'GET',
url: '/',
headers: {
'x-forwarded-for': '1.1.1.1,8.8.8.8, 8.8.8.2',
},
});
handle(req, res);
const html = await text();
const $ = cheerio.load(html);
assert.equal(res.statusCode, 200);
assert.equal($('#address').text(), '1.1.1.1');
});
handle(req, res);
const html = await text();
const $ = cheerio.load(html);
assert.equal(res.statusCode, 200);
assert.equal($('#address').text(), '1.1.1.1');
});
});
175 changes: 175 additions & 0 deletions packages/astro/test/units/app/node.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import * as assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { NodeApp } from '../../../dist/core/app/node.js';

const mockNodeRequest = {
url: '/',
method: 'GET',
headers: {
host: 'example.com',
},
socket: {
encrypted: true,
remoteAddress: '2.2.2.2',
},
};

describe('NodeApp', () => {
describe('createRequest', () => {
describe('x-forwarded-for', () => {
it('parses client IP from single-value x-forwarded-for header', () => {
const result = NodeApp.createRequest({
...mockNodeRequest,
headers: {
'x-forwarded-for': '1.1.1.1',
},
});
assert.equal(result[Symbol.for('astro.clientAddress')], '1.1.1.1');
});

it('parses client IP from multi-value x-forwarded-for header', () => {
const result = NodeApp.createRequest({
...mockNodeRequest,
headers: {
'x-forwarded-for': '1.1.1.1,8.8.8.8',
},
});
assert.equal(result[Symbol.for('astro.clientAddress')], '1.1.1.1');
});

it('parses client IP from multi-value x-forwarded-for header with spaces', () => {
const result = NodeApp.createRequest({
...mockNodeRequest,
headers: {
'x-forwarded-for': ' 1.1.1.1, 8.8.8.8, 8.8.8.2',
},
});
assert.equal(result[Symbol.for('astro.clientAddress')], '1.1.1.1');
});

it('fallbacks to remoteAddress when no x-forwarded-for header is present', () => {
const result = NodeApp.createRequest({
...mockNodeRequest,
headers: {},
});
assert.equal(result[Symbol.for('astro.clientAddress')], '2.2.2.2');
});
});

describe('x-forwarded-host', () => {
it('parses host from single-value x-forwarded-host header', () => {
const result = NodeApp.createRequest({
...mockNodeRequest,
headers: {
'x-forwarded-host': 'www2.example.com',
},
});
assert.equal(result.url, 'https://www2.example.com/');
});

it('parses host from multi-value x-forwarded-host header', () => {
const result = NodeApp.createRequest({
...mockNodeRequest,
headers: {
'x-forwarded-host': 'www2.example.com,www3.example.com',
},
});
assert.equal(result.url, 'https://www2.example.com/');
});

it('fallbacks to host header when no x-forwarded-host header is present', () => {
const result = NodeApp.createRequest({
...mockNodeRequest,
headers: {
host: 'example.com',
},
});
assert.equal(result.url, 'https://example.com/');
});
});

describe('x-forwarded-proto', () => {
it('parses protocol from single-value x-forwarded-proto header', () => {
const result = NodeApp.createRequest({
...mockNodeRequest,
headers: {
host: 'example.com',
'x-forwarded-proto': 'http',
'x-forwarded-port': '80',
},
});
assert.equal(result.url, 'http://example.com/');
});

it('parses protocol from multi-value x-forwarded-proto header', () => {
const result = NodeApp.createRequest({
...mockNodeRequest,
headers: {
host: 'example.com',
'x-forwarded-proto': 'http,https',
'x-forwarded-port': '80,443',
},
});
assert.equal(result.url, 'http://example.com/');
});

it('fallbacks to encrypted property when no x-forwarded-proto header is present', () => {
const result = NodeApp.createRequest({
...mockNodeRequest,
headers: {
host: 'example.com',
},
});
assert.equal(result.url, 'https://example.com/');
});
});

describe('x-forwarded-port', () => {
it('parses port from single-value x-forwarded-port header', () => {
const result = NodeApp.createRequest({
...mockNodeRequest,
headers: {
host: 'example.com',
'x-forwarded-port': '8443',
},
});
assert.equal(result.url, 'https://example.com:8443/');
});

it('parses port from multi-value x-forwarded-port header', () => {
const result = NodeApp.createRequest({
...mockNodeRequest,
headers: {
host: 'example.com',
'x-forwarded-port': '8443,3000',
},
});
assert.equal(result.url, 'https://example.com:8443/');
});

it('prefers port from host', () => {
const result = NodeApp.createRequest({
...mockNodeRequest,
headers: {
host: 'example.com:3000',
'x-forwarded-port': '443',
},
});
assert.equal(result.url, 'https://example.com:3000/');
});


it('prefers port from x-forwarded-host', () => {
const result = NodeApp.createRequest({
...mockNodeRequest,
headers: {
host: 'example.com:443',
'x-forwarded-host': 'example.com:3000',
'x-forwarded-port': '443',
},
});
assert.equal(result.url, 'https://example.com:3000/');
});
});
});
});
Loading