Skip to content

Commit d402485

Browse files
authored
fix(dev): header host validation (#16043)
1 parent 06fba3a commit d402485

6 files changed

Lines changed: 252 additions & 14 deletions

File tree

.changeset/full-pillows-greet.md

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+
Fixes `checkOrigin` CSRF protection in `astro dev` behind a TLS-terminating reverse proxy. The dev server now reads `X-Forwarded-Proto` (gated on `security.allowedDomains`, matching production behaviour) so the constructed request origin matches the `https://` origin the browser sends. Also ensures `security.allowedDomains` and `security.checkOrigin` are respected in dev.

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

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ import { createOutgoingHttpHeaders } from './createOutgoingHttpHeaders.js';
99
import type { RenderOptions } from './base.js';
1010
import { App } from './app.js';
1111
import type { NodeAppHeadersJson, SerializedSSRManifest, SSRManifest } from './types.js';
12-
import { validateForwardedHeaders, validateHost } from './validate-headers.js';
12+
import {
13+
getFirstForwardedValue,
14+
validateForwardedHeaders,
15+
validateHost,
16+
} from './validate-headers.js';
1317

1418
/**
1519
* Allow the request body to be explicitly overridden. For example, this
@@ -52,14 +56,6 @@ export function createRequest(
5256

5357
const isEncrypted = 'encrypted' in req.socket && req.socket.encrypted;
5458

55-
// Parses multiple header and returns first value if available.
56-
const getFirstForwardedValue = (multiValueHeader?: string | string[]) => {
57-
return multiValueHeader
58-
?.toString()
59-
?.split(',')
60-
.map((e) => e.trim())?.[0];
61-
};
62-
6359
const providedProtocol = isEncrypted ? 'https' : 'http';
6460
const untrustedHostname = req.headers.host ?? req.headers[':authority'];
6561

packages/astro/src/core/app/validate-headers.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,19 @@
11
import { matchPattern, type RemotePattern } from '@astrojs/internal-helpers/remote';
22

3+
/**
4+
* Parses a potentially comma-separated multi-value header (as produced by
5+
* proxy chains) and returns the first value, trimmed of whitespace.
6+
* Returns `undefined` when the header is absent or empty.
7+
*/
8+
export function getFirstForwardedValue(
9+
multiValueHeader: string | string[] | undefined,
10+
): string | undefined {
11+
return multiValueHeader
12+
?.toString()
13+
.split(',')
14+
.map((e) => e.trim())[0];
15+
}
16+
317
/**
418
* Sanitize a hostname by rejecting any with path separators.
519
* Prevents path injection attacks. Invalid hostnames return undefined.

packages/astro/src/vite-plugin-app/app.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type http from 'node:http';
22
import { removeTrailingForwardSlash } from '@astrojs/internal-helpers/path';
33
import { BaseApp, type RenderErrorOptions } from '../core/app/entrypoints/index.js';
4+
import { getFirstForwardedValue, validateForwardedHeaders } from '../core/app/validate-headers.js';
45
import { shouldAppendForwardSlash } from '../core/build/util.js';
56
import { clientLocalsSymbol } from '../core/constants.js';
67
import {
@@ -128,10 +129,27 @@ export class AstroServerApp extends BaseApp<RunnablePipeline> {
128129
incomingResponse,
129130
isHttps,
130131
}: HandleRequest): Promise<void> {
131-
const origin = `${isHttps ? 'https' : 'http'}://${
132-
incomingRequest.headers[':authority'] ?? incomingRequest.headers.host
133-
}`;
132+
// When the dev server runs behind a TLS-terminating reverse proxy (e.g.
133+
// Caddy, nginx, Traefik), the proxy connects to Vite over plain HTTP while
134+
// the browser communicates over HTTPS. In that setup isHttps is false, but
135+
// the proxy forwards the original scheme via X-Forwarded-Proto: https.
136+
// We trust that header only when security.allowedDomains is configured —
137+
// the same guard used in production (core/app/node.ts). Without it the
138+
// header is untrusted and we fall back to isHttps.
139+
const validated = validateForwardedHeaders(
140+
getFirstForwardedValue(incomingRequest.headers['x-forwarded-proto']),
141+
getFirstForwardedValue(incomingRequest.headers['x-forwarded-host']),
142+
getFirstForwardedValue(incomingRequest.headers['x-forwarded-port']),
143+
this.manifest.allowedDomains,
144+
);
145+
146+
const protocol = validated.protocol ?? (isHttps ? 'https' : 'http');
147+
const host =
148+
validated.host ??
149+
(incomingRequest.headers[':authority'] as string | undefined) ??
150+
incomingRequest.headers.host;
134151

152+
const origin = `${protocol}://${host}`;
135153
const url = new URL(origin + incomingRequest.url);
136154
let pathname: string;
137155
if (this.manifest.trailingSlash === 'never' && !incomingRequest.url) {

packages/astro/src/vite-plugin-astro-server/plugin.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -268,8 +268,8 @@ export async function createDevelopmentManifest(settings: AstroSettings): Promis
268268
componentMetadata: new Map(),
269269
inlinedScripts: new Map(),
270270
i18n: i18nManifest,
271-
checkOrigin:
272-
(settings.config.security?.checkOrigin && settings.buildOutput === 'server') ?? false,
271+
checkOrigin: settings.config.security?.checkOrigin ?? false,
272+
allowedDomains: settings.config.security?.allowedDomains,
273273
actionBodySizeLimit: settings.config.security?.actionBodySizeLimit
274274
? settings.config.security.actionBodySizeLimit
275275
: 1024 * 1024, // 1mb default
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import * as assert from 'node:assert/strict';
2+
import { describe, it } from 'node:test';
3+
import {
4+
getFirstForwardedValue,
5+
validateForwardedHeaders,
6+
} from '../../../dist/core/app/validate-headers.js';
7+
8+
/**
9+
* Mirrors the URL construction logic in AstroServerApp.handleRequest so that
10+
* the protocol and host derivation can be exercised in isolation.
11+
*
12+
* @param {object} opts
13+
* @param {Record<string, string>} opts.headers - Incoming request headers
14+
* @param {boolean} [opts.isHttps=false] - Whether Vite itself is running TLS
15+
* @param {import('../../../dist/core/app/types.js').SSRManifest['allowedDomains']} [opts.allowedDomains]
16+
* @param {string} [opts.requestUrl='/']
17+
* @returns {URL}
18+
*/
19+
function buildDevUrl({ headers, isHttps = false, allowedDomains, requestUrl = '/' }) {
20+
const validated = validateForwardedHeaders(
21+
getFirstForwardedValue(headers['x-forwarded-proto']),
22+
getFirstForwardedValue(headers['x-forwarded-host']),
23+
getFirstForwardedValue(headers['x-forwarded-port']),
24+
allowedDomains,
25+
);
26+
27+
const protocol = validated.protocol ?? (isHttps ? 'https' : 'http');
28+
const host = validated.host ?? headers[':authority'] ?? headers['host'];
29+
30+
return new URL(`${protocol}://${host}${requestUrl}`);
31+
}
32+
33+
describe('Dev server URL construction — X-Forwarded-Proto handling', () => {
34+
it('uses http when isHttps=false and no allowedDomains configured (default)', () => {
35+
const url = buildDevUrl({
36+
headers: { host: 'localhost:4321' },
37+
isHttps: false,
38+
});
39+
assert.equal(url.protocol, 'http:');
40+
assert.equal(url.origin, 'http://localhost:4321');
41+
});
42+
43+
it('ignores X-Forwarded-Proto when allowedDomains is not configured', () => {
44+
// Without allowedDomains the header must not be trusted — this is the
45+
// security guard that prevents an attacker from forcing the scheme used
46+
// in CSRF origin comparisons.
47+
const url = buildDevUrl({
48+
headers: { host: 'localhost:4321', 'x-forwarded-proto': 'https' },
49+
isHttps: false,
50+
});
51+
assert.equal(url.protocol, 'http:');
52+
assert.equal(url.origin, 'http://localhost:4321');
53+
});
54+
55+
it('ignores X-Forwarded-Proto when allowedDomains is an empty array', () => {
56+
const url = buildDevUrl({
57+
headers: { host: 'mre.local', 'x-forwarded-proto': 'https' },
58+
isHttps: false,
59+
allowedDomains: [],
60+
});
61+
assert.equal(url.protocol, 'http:');
62+
});
63+
64+
it('uses https from X-Forwarded-Proto when allowedDomains matches hostname', () => {
65+
// Behind a TLS-terminating proxy (Caddy, nginx, Traefik) the browser
66+
// sends Origin: https://host while the proxy connects to Vite over HTTP.
67+
// With allowedDomains configured, the dev server derives the same
68+
// https:// origin, so the CSRF Origin === url.origin comparison passes.
69+
const url = buildDevUrl({
70+
headers: { host: 'mre.local', 'x-forwarded-proto': 'https' },
71+
isHttps: false,
72+
allowedDomains: [{ hostname: 'mre.local' }],
73+
});
74+
assert.equal(url.protocol, 'https:');
75+
assert.equal(url.origin, 'https://mre.local');
76+
});
77+
78+
it('uses https from X-Forwarded-Proto with wildcard hostname pattern', () => {
79+
const url = buildDevUrl({
80+
headers: { host: 'app.example.com', 'x-forwarded-proto': 'https' },
81+
isHttps: false,
82+
allowedDomains: [{ protocol: 'https', hostname: '**.example.com' }],
83+
});
84+
assert.equal(url.protocol, 'https:');
85+
assert.equal(url.origin, 'https://app.example.com');
86+
});
87+
88+
it('trusts X-Forwarded-Proto even when host does not match allowedDomains pattern', () => {
89+
// validateForwardedHeaders validates protocol and host independently.
90+
// When allowedDomains is non-empty but has no `protocol` property,
91+
// any http/https value is accepted for the protocol. The host match is
92+
// only required for the X-Forwarded-Host to be trusted; the fallback
93+
// host header is used instead. This mirrors production (node.ts) behaviour.
94+
const url = buildDevUrl({
95+
headers: { host: 'localhost:4321', 'x-forwarded-proto': 'https' },
96+
isHttps: false,
97+
allowedDomains: [{ hostname: 'mre.local' }],
98+
});
99+
// Protocol is trusted (allowedDomains is non-empty); host falls back to
100+
// the Host header value.
101+
assert.equal(url.protocol, 'https:');
102+
assert.equal(url.origin, 'https://localhost:4321');
103+
});
104+
105+
it('rejects X-Forwarded-Proto that does not match explicit protocol in allowedDomains', () => {
106+
// When allowedDomains specifies a protocol, only that protocol is allowed.
107+
const url = buildDevUrl({
108+
headers: { host: 'mre.local', 'x-forwarded-proto': 'http' },
109+
isHttps: false,
110+
allowedDomains: [{ protocol: 'https', hostname: 'mre.local' }],
111+
});
112+
// 'http' is rejected because the pattern requires 'https'
113+
assert.equal(url.protocol, 'http:');
114+
});
115+
116+
it('falls back to isHttps=true when X-Forwarded-Proto is absent but Vite uses TLS', () => {
117+
// When the user configures Vite's own TLS (vite.server.https) without a
118+
// proxy, isHttps=true should still work.
119+
const url = buildDevUrl({
120+
headers: { host: 'localhost:4321' },
121+
isHttps: true,
122+
});
123+
assert.equal(url.protocol, 'https:');
124+
});
125+
126+
it('uses first value from comma-separated X-Forwarded-Proto', () => {
127+
const url = buildDevUrl({
128+
headers: { host: 'mre.local', 'x-forwarded-proto': 'https,http' },
129+
isHttps: false,
130+
allowedDomains: [{ hostname: 'mre.local' }],
131+
});
132+
assert.equal(url.protocol, 'https:');
133+
});
134+
135+
it('uses first value from comma-separated X-Forwarded-Proto with spaces', () => {
136+
const url = buildDevUrl({
137+
headers: { host: 'mre.local', 'x-forwarded-proto': ' https , http' },
138+
isHttps: false,
139+
allowedDomains: [{ hostname: 'mre.local' }],
140+
});
141+
assert.equal(url.protocol, 'https:');
142+
});
143+
144+
it('rejects malicious X-Forwarded-Proto with URL injection', () => {
145+
const url = buildDevUrl({
146+
headers: {
147+
host: 'mre.local',
148+
'x-forwarded-proto': 'https://evil.com/?x=',
149+
},
150+
isHttps: false,
151+
allowedDomains: [{ hostname: 'mre.local' }],
152+
});
153+
// validateForwardedHeaders rejects invalid protocol values
154+
assert.equal(url.protocol, 'http:');
155+
});
156+
157+
it('rejects javascript: scheme injection in X-Forwarded-Proto', () => {
158+
const url = buildDevUrl({
159+
headers: {
160+
host: 'mre.local',
161+
'x-forwarded-proto': 'javascript:alert(1)//',
162+
},
163+
isHttps: false,
164+
allowedDomains: [{ hostname: 'mre.local' }],
165+
});
166+
assert.equal(url.protocol, 'http:');
167+
});
168+
169+
it('rejects empty X-Forwarded-Proto and falls back to isHttps', () => {
170+
const url = buildDevUrl({
171+
headers: { host: 'mre.local', 'x-forwarded-proto': '' },
172+
isHttps: false,
173+
allowedDomains: [{ hostname: 'mre.local' }],
174+
});
175+
assert.equal(url.protocol, 'http:');
176+
});
177+
178+
it('produces an origin that matches the browser Origin header when proxy is configured', () => {
179+
// The CSRF check compares request.headers.origin === url.origin.
180+
// When the dev server runs behind a TLS-terminating proxy and
181+
// allowedDomains is configured, both sides of that comparison must
182+
// resolve to the same https:// origin.
183+
const url = buildDevUrl({
184+
headers: { host: 'mre.local', 'x-forwarded-proto': 'https' },
185+
isHttps: false,
186+
allowedDomains: [{ hostname: 'mre.local' }],
187+
});
188+
const browserOriginHeader = 'https://mre.local';
189+
assert.equal(url.origin, browserOriginHeader);
190+
});
191+
192+
it('produces a mismatched origin behind a proxy when allowedDomains is not configured', () => {
193+
// Without allowedDomains, X-Forwarded-Proto is untrusted and the URL
194+
// gets an http:// origin while the browser sends Origin: https://.
195+
// The CSRF check (Origin === url.origin) therefore returns false and
196+
// blocks the request with 403.
197+
const url = buildDevUrl({
198+
headers: { host: 'mre.local', 'x-forwarded-proto': 'https' },
199+
isHttps: false,
200+
// no allowedDomains
201+
});
202+
const browserOriginHeader = 'https://mre.local';
203+
assert.notEqual(url.origin, browserOriginHeader); // http:// vs https://
204+
});
205+
});

0 commit comments

Comments
 (0)