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
5 changes: 5 additions & 0 deletions .changeset/fix-forwarded-proto-allowed-domains.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Fix X-Forwarded-Proto validation when allowedDomains includes both protocol and hostname fields. The protocol check no longer fails due to hostname mismatch against the hardcoded test URL.
6 changes: 4 additions & 2 deletions packages/astro/src/core/app/validate-headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,10 +90,12 @@ export function validateForwardedHeaders(
if (allowedDomains && allowedDomains.length > 0) {
const hasProtocolPatterns = allowedDomains.some((pattern) => pattern.protocol !== undefined);
if (hasProtocolPatterns) {
// Validate against allowedDomains patterns
// Only validate the protocol here; host+proto combination is checked in the host block below
try {
const testUrl = new URL(`${forwardedProtocol}://example.com`);
const isAllowed = allowedDomains.some((pattern) => matchPattern(testUrl, pattern));
const isAllowed = allowedDomains.some((pattern) =>
matchPattern(testUrl, { protocol: pattern.protocol }),
);
if (isAllowed) {
result.protocol = forwardedProtocol;
}
Expand Down
67 changes: 67 additions & 0 deletions packages/astro/test/units/app/node.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,73 @@ describe('node', () => {
);
assert.equal(result.url, 'https://example.com/');
});

it('accepts x-forwarded-proto when allowedDomains has protocol and hostname', () => {
const result = createRequest(
{
...mockNodeRequest,
socket: { encrypted: false, remoteAddress: '2.2.2.2' },
headers: {
host: 'myapp.example.com',
'x-forwarded-proto': 'https',
},
},
{ allowedDomains: [{ protocol: 'https', hostname: 'myapp.example.com' }] },
);
// Without the fix, protocol validation fails due to hostname mismatch
// and falls back to socket.encrypted (false → http)
assert.equal(result.url, 'https://myapp.example.com/');
});

it('rejects x-forwarded-proto when it does not match protocol in allowedDomains', () => {
const result = createRequest(
{
...mockNodeRequest,
socket: { encrypted: false, remoteAddress: '2.2.2.2' },
headers: {
host: 'myapp.example.com',
'x-forwarded-proto': 'http',
},
},
{ allowedDomains: [{ protocol: 'https', hostname: 'myapp.example.com' }] },
);
// http is not in allowedDomains (only https), protocol falls back to socket (false → http)
// Host validation also fails because http doesn't match the pattern's protocol: 'https'
assert.equal(result.url, 'http://localhost/');
});

it('accepts x-forwarded-proto with wildcard hostname pattern in allowedDomains', () => {
const result = createRequest(
{
...mockNodeRequest,
socket: { encrypted: false, remoteAddress: '2.2.2.2' },
headers: {
host: 'myapp.example.com',
'x-forwarded-proto': 'https',
},
},
{ allowedDomains: [{ protocol: 'https', hostname: '**.example.com' }] },
);
assert.equal(result.url, 'https://myapp.example.com/');
});

it('constructs correct URL behind reverse proxy with all forwarded headers', () => {
// Simulates: Reverse proxy terminates TLS, connects to Astro via HTTP,
// forwards original protocol/host/port via X-Forwarded-* headers
const result = createRequest(
{
...mockNodeRequest,
socket: { encrypted: false, remoteAddress: '2.2.2.2' },
headers: {
host: 'myapp.example.com',
'x-forwarded-proto': 'https',
'x-forwarded-host': 'myapp.example.com',
},
},
{ allowedDomains: [{ protocol: 'https', hostname: 'myapp.example.com' }] },
);
assert.equal(result.url, 'https://myapp.example.com/');
});
});

describe('x-forwarded-port', () => {
Expand Down
Loading