From 31af7146ce9b200e333b05d828c11fc197747f45 Mon Sep 17 00:00:00 2001 From: Timo Behrmann Date: Tue, 17 Feb 2026 21:14:54 +0100 Subject: [PATCH] fix: X-Forwarded-Proto rejected when allowedDomains includes protocol and hostname 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', () => {