Skip to content

Commit 9d9699c

Browse files
authored
Harden Astro.clientAddress when using from Node adapter (#15742)
* Harden X-Forwarded-For to respect allowedDomains for clientAddress * fix(test): add allowedDomains to client-address-node fixture for XFF trust
1 parent fabb710 commit 9d9699c

4 files changed

Lines changed: 174 additions & 14 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'astro': patch
3+
---
4+
5+
Hardens `clientAddress` resolution to respect `security.allowedDomains` for `X-Forwarded-For`, consistent with the existing handling of `X-Forwarded-Host`, `X-Forwarded-Proto`, and `X-Forwarded-Port`. The `X-Forwarded-For` header is now only used to determine `Astro.clientAddress` when the request's host has been validated against an `allowedDomains` entry. Without a matching domain, `clientAddress` falls back to the socket's remote address.

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,8 +141,14 @@ export function createRequest(
141141
}
142142

143143
// Get the IP of end client behind the proxy.
144+
// Only trust X-Forwarded-For when the request's host was validated against allowedDomains,
145+
// meaning it arrived through a trusted proxy. Without this check, any client can spoof
146+
// their IP via this header.
144147
// @example "1.1.1.1,8.8.8.8" => "1.1.1.1"
145-
const forwardedClientIp = getFirstForwardedValue(req.headers['x-forwarded-for']);
148+
const hostValidated = validated.host !== undefined || validatedHostname !== undefined;
149+
const forwardedClientIp = hostValidated
150+
? getFirstForwardedValue(req.headers['x-forwarded-for'])
151+
: undefined;
146152
const clientIp = forwardedClientIp || req.socket?.remoteAddress;
147153
if (clientIp) {
148154
Reflect.set(request, clientAddressSymbol, clientIp);

packages/astro/test/fixtures/client-address-node/astro.config.mjs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,7 @@ import { defineConfig } from 'astro/config';
55
export default defineConfig({
66
output: 'server',
77
adapter: node({ mode: 'middleware' }),
8+
security: {
9+
allowedDomains: [{ hostname: 'localhost' }],
10+
},
811
});

packages/astro/test/units/app/node.test.js

Lines changed: 159 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,40 +19,186 @@ describe('node', () => {
1919
describe('createRequest', () => {
2020
describe('x-forwarded-for', () => {
2121
it('parses client IP from single-value x-forwarded-for header', () => {
22-
const result = createRequest({
23-
...mockNodeRequest,
24-
headers: {
25-
'x-forwarded-for': '1.1.1.1',
22+
const result = createRequest(
23+
{
24+
...mockNodeRequest,
25+
headers: {
26+
host: 'example.com',
27+
'x-forwarded-for': '1.1.1.1',
28+
},
2629
},
27-
});
30+
{ allowedDomains: [{ hostname: 'example.com' }] },
31+
);
2832
assert.equal(result[Symbol.for('astro.clientAddress')], '1.1.1.1');
2933
});
3034

3135
it('parses client IP from multi-value x-forwarded-for header', () => {
32-
const result = createRequest({
33-
...mockNodeRequest,
34-
headers: {
35-
'x-forwarded-for': '1.1.1.1,8.8.8.8',
36+
const result = createRequest(
37+
{
38+
...mockNodeRequest,
39+
headers: {
40+
host: 'example.com',
41+
'x-forwarded-for': '1.1.1.1,8.8.8.8',
42+
},
3643
},
37-
});
44+
{ allowedDomains: [{ hostname: 'example.com' }] },
45+
);
3846
assert.equal(result[Symbol.for('astro.clientAddress')], '1.1.1.1');
3947
});
4048

4149
it('parses client IP from multi-value x-forwarded-for header with spaces', () => {
50+
const result = createRequest(
51+
{
52+
...mockNodeRequest,
53+
headers: {
54+
host: 'example.com',
55+
'x-forwarded-for': ' 1.1.1.1, 8.8.8.8, 8.8.8.2',
56+
},
57+
},
58+
{ allowedDomains: [{ hostname: 'example.com' }] },
59+
);
60+
assert.equal(result[Symbol.for('astro.clientAddress')], '1.1.1.1');
61+
});
62+
63+
it('fallbacks to remoteAddress when no x-forwarded-for header is present', () => {
64+
const result = createRequest(
65+
{
66+
...mockNodeRequest,
67+
headers: {
68+
host: 'example.com',
69+
},
70+
},
71+
{ allowedDomains: [{ hostname: 'example.com' }] },
72+
);
73+
assert.equal(result[Symbol.for('astro.clientAddress')], '2.2.2.2');
74+
});
75+
76+
it('ignores x-forwarded-for when no allowedDomains is configured (default)', () => {
4277
const result = createRequest({
4378
...mockNodeRequest,
4479
headers: {
45-
'x-forwarded-for': ' 1.1.1.1, 8.8.8.8, 8.8.8.2',
80+
host: 'example.com',
81+
'x-forwarded-for': '1.1.1.1',
4682
},
4783
});
84+
// Without allowedDomains, x-forwarded-for should NOT be trusted
85+
// Falls back to socket remoteAddress
86+
assert.equal(result[Symbol.for('astro.clientAddress')], '2.2.2.2');
87+
});
88+
89+
it('ignores x-forwarded-for when allowedDomains is empty', () => {
90+
const result = createRequest(
91+
{
92+
...mockNodeRequest,
93+
headers: {
94+
host: 'example.com',
95+
'x-forwarded-for': '1.1.1.1',
96+
},
97+
},
98+
{ allowedDomains: [] },
99+
);
100+
// Empty allowedDomains means no proxy trust, use socket address
101+
assert.equal(result[Symbol.for('astro.clientAddress')], '2.2.2.2');
102+
});
103+
104+
it('trusts x-forwarded-for when host matches allowedDomains', () => {
105+
const result = createRequest(
106+
{
107+
...mockNodeRequest,
108+
headers: {
109+
host: 'example.com',
110+
'x-forwarded-for': '1.1.1.1',
111+
},
112+
},
113+
{ allowedDomains: [{ hostname: 'example.com' }] },
114+
);
115+
// Host matches allowedDomains, so x-forwarded-for is trusted
48116
assert.equal(result[Symbol.for('astro.clientAddress')], '1.1.1.1');
49117
});
50118

51-
it('fallbacks to remoteAddress when no x-forwarded-for header is present', () => {
119+
it('ignores x-forwarded-for when host does not match allowedDomains', () => {
120+
const result = createRequest(
121+
{
122+
...mockNodeRequest,
123+
headers: {
124+
host: 'attacker.com',
125+
'x-forwarded-for': '1.1.1.1',
126+
},
127+
},
128+
{ allowedDomains: [{ hostname: 'example.com' }] },
129+
);
130+
// Host does not match allowedDomains, so x-forwarded-for is NOT trusted
131+
assert.equal(result[Symbol.for('astro.clientAddress')], '2.2.2.2');
132+
});
133+
134+
it('trusts x-forwarded-for when x-forwarded-host matches allowedDomains', () => {
135+
const result = createRequest(
136+
{
137+
...mockNodeRequest,
138+
headers: {
139+
'x-forwarded-host': 'example.com',
140+
'x-forwarded-for': '1.1.1.1',
141+
},
142+
},
143+
{ allowedDomains: [{ hostname: 'example.com' }] },
144+
);
145+
// X-Forwarded-Host validated against allowedDomains, so XFF is trusted
146+
assert.equal(result[Symbol.for('astro.clientAddress')], '1.1.1.1');
147+
});
148+
149+
it('trusts multi-value x-forwarded-for when host matches allowedDomains', () => {
150+
const result = createRequest(
151+
{
152+
...mockNodeRequest,
153+
headers: {
154+
host: 'example.com',
155+
'x-forwarded-for': '1.1.1.1, 8.8.8.8',
156+
},
157+
},
158+
{ allowedDomains: [{ hostname: 'example.com' }] },
159+
);
160+
assert.equal(result[Symbol.for('astro.clientAddress')], '1.1.1.1');
161+
});
162+
163+
it('falls back to remoteAddress when host matches allowedDomains but no x-forwarded-for', () => {
164+
const result = createRequest(
165+
{
166+
...mockNodeRequest,
167+
headers: {
168+
host: 'example.com',
169+
},
170+
},
171+
{ allowedDomains: [{ hostname: 'example.com' }] },
172+
);
173+
assert.equal(result[Symbol.for('astro.clientAddress')], '2.2.2.2');
174+
});
175+
176+
it('prevents IP spoofing: attacker cannot override clientAddress without allowedDomains', () => {
177+
// Simulates an attacker injecting x-forwarded-for to spoof 127.0.0.1
52178
const result = createRequest({
53179
...mockNodeRequest,
54-
headers: {},
180+
headers: {
181+
host: 'example.com',
182+
'x-forwarded-for': '127.0.0.1',
183+
},
55184
});
185+
// Without allowedDomains, the spoofed IP must be ignored
186+
assert.equal(result[Symbol.for('astro.clientAddress')], '2.2.2.2');
187+
});
188+
189+
it('prevents IP spoofing: attacker cannot override clientAddress when host does not match', () => {
190+
// Simulates attacker sending direct request with XFF and mismatched host
191+
const result = createRequest(
192+
{
193+
...mockNodeRequest,
194+
headers: {
195+
host: 'evil.com',
196+
'x-forwarded-for': '127.0.0.1',
197+
},
198+
},
199+
{ allowedDomains: [{ hostname: 'example.com' }] },
200+
);
201+
// Host doesn't match allowedDomains, so XFF is not trusted
56202
assert.equal(result[Symbol.for('astro.clientAddress')], '2.2.2.2');
57203
});
58204
});

0 commit comments

Comments
 (0)