From 170ed89ae2b0482ddc5a6f1244452490319ae99e Mon Sep 17 00:00:00 2001 From: Timo Behrmann Date: Wed, 18 Feb 2026 21:40:29 +0100 Subject: [PATCH] fix: X-Forwarded-Proto rejected when allowedDomains includes protocol and hostname (#15560) The protocol validation in validateForwardedHeaders() passed the full pattern object to matchPattern(), which also checked hostname against the hardcoded test URL (example.com). Pass only { protocol } to matchPattern() so that only the protocol field is validated; the host+proto combination is already checked in the host validation block below. Fixes withastro/astro#15559 --- .../fix-forwarded-proto-allowed-domains.md | 5 ++ .../astro/src/core/app/validate-headers.ts | 6 +- packages/astro/test/units/app/node.test.js | 67 +++++++++++++++++++ 3 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 .changeset/fix-forwarded-proto-allowed-domains.md diff --git a/.changeset/fix-forwarded-proto-allowed-domains.md b/.changeset/fix-forwarded-proto-allowed-domains.md new file mode 100644 index 000000000000..dfba53be7c3f --- /dev/null +++ b/.changeset/fix-forwarded-proto-allowed-domains.md @@ -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. diff --git a/packages/astro/src/core/app/validate-headers.ts b/packages/astro/src/core/app/validate-headers.ts index fe2138cc7a61..c5b452a12e09 100644 --- a/packages/astro/src/core/app/validate-headers.ts +++ b/packages/astro/src/core/app/validate-headers.ts @@ -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; } diff --git a/packages/astro/test/units/app/node.test.js b/packages/astro/test/units/app/node.test.js index f461e13d3e09..213408915ef5 100644 --- a/packages/astro/test/units/app/node.test.js +++ b/packages/astro/test/units/app/node.test.js @@ -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', () => {