Skip to content

ikadix/astro-allowed-domain-example

Repository files navigation

Astro 6 CSRF + Reverse Proxy — MRE

Minimal reproducible example for a regression in Astro 6 where the dev server returns 403 Cross-site POST form submissions are forbidden for every form submission when running behind a TLS-terminating reverse proxy (Caddy, Traefik, nginx, etc.).

The bug

Astro's checkOrigin CSRF middleware does a strict equality check:

request.headers.get("origin")  ===  context.url.origin

The browser sends Origin: https://mre.local (the public HTTPS URL). The dev server builds context.url.origin as http://mre.local.

They never match → every form POST is rejected with 403.

Root cause

In Astro 6, vite-plugin-app/app.ts constructs the request URL using loader.isHttps(), which returns !!vite.server.https. Unless you configure Vite's own HTTPS server, this is always false, so the origin is always built with http:// regardless of what the upstream proxy sends in X-Forwarded-Proto.

In Astro 5, vite-plugin-astro-server/request.ts read X-Forwarded-Proto directly and used it to set the protocol. That behaviour was dropped as part of the Vite Environments rewrite introduced in Astro 6.

Affected setup

Any Astro 6 dev server running behind a proxy that terminates TLS:

Browser  ──HTTPS──▶  Caddy / Traefik / nginx  ──HTTP──▶  astro dev

Caddy (and others) forward X-Forwarded-Proto: https, but the dev server ignores it.

Reproduce

Prerequisites: Docker, Docker Compose, and mre.local resolving to 127.0.0.1 (add to /etc/hosts if needed).

echo "127.0.0.1 mre.local" | sudo tee -a /etc/hosts

docker compose up --build -d

# Trust Caddy's local CA (needed once per machine)
docker compose exec caddy caddy trust

./test.sh

Expected output:

── GET https://mre.local/ ──────────────────────────────
Status: 200

── POST https://mre.local/ ─────────────────────────────
Status: 403
Body  : Cross-site POST form submissions are forbidden

PASS — bug confirmed.

You can also open https://mre.local/ in a browser and submit the form to see the 403 live.

What the test checks

Request Expected Why
GET / 200 No Origin header on GET — no CSRF check
POST / 403 Browser sends Origin: https://mre.local; dev server built url.origin = "http://mre.local" → mismatch

The diagnostic table on the page shows context.url.origin, the Origin request header, and X-Forwarded-Proto so you can see the mismatch directly.

Potential Fix

handleRequest in vite-plugin-app/app.ts should read X-Forwarded-Proto before falling back to isHttps:

const forwardedProto = (incomingRequest.headers['x-forwarded-proto'] as string | undefined)
  ?.split(',')[0]
  .trim();
const protocol = forwardedProto ?? (isHttps ? 'https' : 'http');
const origin = `${protocol}://${
  incomingRequest.headers[':authority'] ?? incomingRequest.headers.host
}`;

This restores the Astro 5 behaviour where the protocol is derived from the proxy-forwarded header rather than from Vite's own HTTPS configuration.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors