Skip to content

Commit e96bcae

Browse files
thehansysematipico
andauthored
Fixed parsing of x-forwarded-* headers (#12130)
* Fixed parsing of x-forwarded-* headers * changeset * remotePort * port fix * port fix * port fix * Update .changeset/slimy-buses-agree.md Co-authored-by: Emanuele Stoppa <[email protected]> * Update packages/astro/src/core/app/node.ts Co-authored-by: Emanuele Stoppa <[email protected]> * Reverted formating change --------- Co-authored-by: Emanuele Stoppa <[email protected]>
1 parent 22c70a2 commit e96bcae

File tree

4 files changed

+255
-29
lines changed

4 files changed

+255
-29
lines changed

.changeset/slimy-buses-agree.md

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'astro': patch
3+
---
4+
5+
Fixes a bug in the parsing of `x-forwarded-\*` `Request` headers, where multiple values assigned to those headers were not correctly parsed.
6+
7+
Now, headers like `x-forwarded-proto: https,http` are correctly parsed.

packages/astro/src/core/app/node.ts

+33-13
Original file line numberDiff line numberDiff line change
@@ -61,16 +61,33 @@ export class NodeApp extends App {
6161
* ```
6262
*/
6363
static createRequest(req: NodeRequest, { skipBody = false } = {}): Request {
64-
const protocol =
65-
req.headers['x-forwarded-proto'] ??
66-
('encrypted' in req.socket && req.socket.encrypted ? 'https' : 'http');
67-
const hostname =
68-
req.headers['x-forwarded-host'] ?? req.headers.host ?? req.headers[':authority'];
69-
const port = req.headers['x-forwarded-port'];
64+
const isEncrypted = 'encrypted' in req.socket && req.socket.encrypted;
65+
66+
// Parses multiple header and returns first value if available.
67+
const getFirstForwardedValue = (multiValueHeader?: string | string[]) => {
68+
return multiValueHeader
69+
?.toString()
70+
?.split(',')
71+
.map((e) => e.trim())?.[0];
72+
};
73+
74+
// Get the used protocol between the end client and first proxy.
75+
// NOTE: Some proxies append values with spaces and some do not.
76+
// We need to handle it here and parse the header correctly.
77+
// @example "https, http,http" => "http"
78+
const forwardedProtocol = getFirstForwardedValue(req.headers['x-forwarded-proto']);
79+
const protocol = forwardedProtocol ?? (isEncrypted ? 'https' : 'http');
80+
81+
// @example "example.com,www2.example.com" => "example.com"
82+
const forwardedHostname = getFirstForwardedValue(req.headers['x-forwarded-host']);
83+
const hostname = forwardedHostname ?? req.headers.host ?? req.headers[':authority'];
7084

71-
const portInHostname =
72-
typeof hostname === 'string' && typeof port === 'string' && hostname.endsWith(port);
73-
const hostnamePort = portInHostname ? hostname : hostname + (port ? `:${port}` : '');
85+
// @example "443,8080,80" => "443"
86+
const forwardedPort = getFirstForwardedValue(req.headers['x-forwarded-port']);
87+
const port = forwardedPort ?? req.socket?.remotePort?.toString() ?? (isEncrypted ? '443' : '80');
88+
89+
const portInHostname = typeof hostname === 'string' && /:\d+$/.test(hostname);
90+
const hostnamePort = portInHostname ? hostname : `${hostname}:${port}`
7491

7592
const url = `${protocol}://${hostnamePort}${req.url}`;
7693
const options: RequestInit = {
@@ -81,14 +98,17 @@ export class NodeApp extends App {
8198
if (bodyAllowed) {
8299
Object.assign(options, makeRequestBody(req));
83100
}
101+
84102
const request = new Request(url, options);
85103

86-
const clientIp = req.headers['x-forwarded-for'];
87-
if (clientIp) {
104+
// Get the IP of end client behind the proxy.
105+
// @example "1.1.1.1,8.8.8.8" => "1.1.1.1"
106+
const forwardedClientIp = getFirstForwardedValue(req.headers['x-forwarded-for']);
107+
const clientIp = forwardedClientIp || req.socket?.remoteAddress;
108+
if (clientIp) {
88109
Reflect.set(request, clientAddressSymbol, clientIp);
89-
} else if (req.socket?.remoteAddress) {
90-
Reflect.set(request, clientAddressSymbol, req.socket.remoteAddress);
91110
}
111+
92112
return request;
93113
}
94114

packages/astro/test/client-address-node.test.js

+40-16
Original file line numberDiff line numberDiff line change
@@ -5,23 +5,47 @@ import { loadFixture } from './test-utils.js';
55
import { createRequestAndResponse } from './units/test-utils.js';
66

77
describe('NodeClientAddress', () => {
8-
it('clientAddress is 1.1.1.1', async () => {
9-
const fixture = await loadFixture({
10-
root: './fixtures/client-address-node/',
8+
describe('single value', () => {
9+
it('clientAddress is 1.1.1.1', async () => {
10+
const fixture = await loadFixture({
11+
root: './fixtures/client-address-node/',
12+
});
13+
await fixture.build();
14+
const handle = await fixture.loadNodeAdapterHandler();
15+
const { req, res, text } = createRequestAndResponse({
16+
method: 'GET',
17+
url: '/',
18+
headers: {
19+
'x-forwarded-for': '1.1.1.1',
20+
},
21+
});
22+
handle(req, res);
23+
const html = await text();
24+
const $ = cheerio.load(html);
25+
assert.equal(res.statusCode, 200);
26+
assert.equal($('#address').text(), '1.1.1.1');
1127
});
12-
await fixture.build();
13-
const handle = await fixture.loadNodeAdapterHandler();
14-
const { req, res, text } = createRequestAndResponse({
15-
method: 'GET',
16-
url: '/',
17-
headers: {
18-
'x-forwarded-for': '1.1.1.1',
19-
},
28+
});
29+
30+
describe('multiple values', () => {
31+
it('clientAddress is 1.1.1.1', async () => {
32+
const fixture = await loadFixture({
33+
root: './fixtures/client-address-node/',
34+
});
35+
await fixture.build();
36+
const handle = await fixture.loadNodeAdapterHandler();
37+
const { req, res, text } = createRequestAndResponse({
38+
method: 'GET',
39+
url: '/',
40+
headers: {
41+
'x-forwarded-for': '1.1.1.1,8.8.8.8, 8.8.8.2',
42+
},
43+
});
44+
handle(req, res);
45+
const html = await text();
46+
const $ = cheerio.load(html);
47+
assert.equal(res.statusCode, 200);
48+
assert.equal($('#address').text(), '1.1.1.1');
2049
});
21-
handle(req, res);
22-
const html = await text();
23-
const $ = cheerio.load(html);
24-
assert.equal(res.statusCode, 200);
25-
assert.equal($('#address').text(), '1.1.1.1');
2650
});
2751
});
+175
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import * as assert from 'node:assert/strict';
2+
import { describe, it } from 'node:test';
3+
import { NodeApp } from '../../../dist/core/app/node.js';
4+
5+
const mockNodeRequest = {
6+
url: '/',
7+
method: 'GET',
8+
headers: {
9+
host: 'example.com',
10+
},
11+
socket: {
12+
encrypted: true,
13+
remoteAddress: '2.2.2.2',
14+
},
15+
};
16+
17+
describe('NodeApp', () => {
18+
describe('createRequest', () => {
19+
describe('x-forwarded-for', () => {
20+
it('parses client IP from single-value x-forwarded-for header', () => {
21+
const result = NodeApp.createRequest({
22+
...mockNodeRequest,
23+
headers: {
24+
'x-forwarded-for': '1.1.1.1',
25+
},
26+
});
27+
assert.equal(result[Symbol.for('astro.clientAddress')], '1.1.1.1');
28+
});
29+
30+
it('parses client IP from multi-value x-forwarded-for header', () => {
31+
const result = NodeApp.createRequest({
32+
...mockNodeRequest,
33+
headers: {
34+
'x-forwarded-for': '1.1.1.1,8.8.8.8',
35+
},
36+
});
37+
assert.equal(result[Symbol.for('astro.clientAddress')], '1.1.1.1');
38+
});
39+
40+
it('parses client IP from multi-value x-forwarded-for header with spaces', () => {
41+
const result = NodeApp.createRequest({
42+
...mockNodeRequest,
43+
headers: {
44+
'x-forwarded-for': ' 1.1.1.1, 8.8.8.8, 8.8.8.2',
45+
},
46+
});
47+
assert.equal(result[Symbol.for('astro.clientAddress')], '1.1.1.1');
48+
});
49+
50+
it('fallbacks to remoteAddress when no x-forwarded-for header is present', () => {
51+
const result = NodeApp.createRequest({
52+
...mockNodeRequest,
53+
headers: {},
54+
});
55+
assert.equal(result[Symbol.for('astro.clientAddress')], '2.2.2.2');
56+
});
57+
});
58+
59+
describe('x-forwarded-host', () => {
60+
it('parses host from single-value x-forwarded-host header', () => {
61+
const result = NodeApp.createRequest({
62+
...mockNodeRequest,
63+
headers: {
64+
'x-forwarded-host': 'www2.example.com',
65+
},
66+
});
67+
assert.equal(result.url, 'https://www2.example.com/');
68+
});
69+
70+
it('parses host from multi-value x-forwarded-host header', () => {
71+
const result = NodeApp.createRequest({
72+
...mockNodeRequest,
73+
headers: {
74+
'x-forwarded-host': 'www2.example.com,www3.example.com',
75+
},
76+
});
77+
assert.equal(result.url, 'https://www2.example.com/');
78+
});
79+
80+
it('fallbacks to host header when no x-forwarded-host header is present', () => {
81+
const result = NodeApp.createRequest({
82+
...mockNodeRequest,
83+
headers: {
84+
host: 'example.com',
85+
},
86+
});
87+
assert.equal(result.url, 'https://example.com/');
88+
});
89+
});
90+
91+
describe('x-forwarded-proto', () => {
92+
it('parses protocol from single-value x-forwarded-proto header', () => {
93+
const result = NodeApp.createRequest({
94+
...mockNodeRequest,
95+
headers: {
96+
host: 'example.com',
97+
'x-forwarded-proto': 'http',
98+
'x-forwarded-port': '80',
99+
},
100+
});
101+
assert.equal(result.url, 'http://example.com/');
102+
});
103+
104+
it('parses protocol from multi-value x-forwarded-proto header', () => {
105+
const result = NodeApp.createRequest({
106+
...mockNodeRequest,
107+
headers: {
108+
host: 'example.com',
109+
'x-forwarded-proto': 'http,https',
110+
'x-forwarded-port': '80,443',
111+
},
112+
});
113+
assert.equal(result.url, 'http://example.com/');
114+
});
115+
116+
it('fallbacks to encrypted property when no x-forwarded-proto header is present', () => {
117+
const result = NodeApp.createRequest({
118+
...mockNodeRequest,
119+
headers: {
120+
host: 'example.com',
121+
},
122+
});
123+
assert.equal(result.url, 'https://example.com/');
124+
});
125+
});
126+
127+
describe('x-forwarded-port', () => {
128+
it('parses port from single-value x-forwarded-port header', () => {
129+
const result = NodeApp.createRequest({
130+
...mockNodeRequest,
131+
headers: {
132+
host: 'example.com',
133+
'x-forwarded-port': '8443',
134+
},
135+
});
136+
assert.equal(result.url, 'https://example.com:8443/');
137+
});
138+
139+
it('parses port from multi-value x-forwarded-port header', () => {
140+
const result = NodeApp.createRequest({
141+
...mockNodeRequest,
142+
headers: {
143+
host: 'example.com',
144+
'x-forwarded-port': '8443,3000',
145+
},
146+
});
147+
assert.equal(result.url, 'https://example.com:8443/');
148+
});
149+
150+
it('prefers port from host', () => {
151+
const result = NodeApp.createRequest({
152+
...mockNodeRequest,
153+
headers: {
154+
host: 'example.com:3000',
155+
'x-forwarded-port': '443',
156+
},
157+
});
158+
assert.equal(result.url, 'https://example.com:3000/');
159+
});
160+
161+
162+
it('prefers port from x-forwarded-host', () => {
163+
const result = NodeApp.createRequest({
164+
...mockNodeRequest,
165+
headers: {
166+
host: 'example.com:443',
167+
'x-forwarded-host': 'example.com:3000',
168+
'x-forwarded-port': '443',
169+
},
170+
});
171+
assert.equal(result.url, 'https://example.com:3000/');
172+
});
173+
});
174+
});
175+
});

0 commit comments

Comments
 (0)