Skip to content

fix: add siteUrl to resolve origin mismatch behind reverse proxies#382

Open
UpperM wants to merge 4 commits intoemdash-cms:mainfrom
UpperM:fix/site-url-reverse-proxy-origin
Open

fix: add siteUrl to resolve origin mismatch behind reverse proxies#382
UpperM wants to merge 4 commits intoemdash-cms:mainfrom
UpperM:fix/site-url-reverse-proxy-origin

Conversation

@UpperM
Copy link
Copy Markdown

@UpperM UpperM commented Apr 8, 2026

What does this PR do?

Adds a siteUrl config option that fixes a class of reverse-proxy origin mismatches affecting Node.js self-hosted deployments. When EmDash runs behind a TLS-terminating proxy (nginx, Traefik, Caddy -- typical for Coolify, Dokploy, or any Docker setup), the server sees http://localhost:4321 while browsers reach it via https://mysite.example.com. PR #225 fixed this for passkeys; this PR extends the same fix to all affected call sites.

Affected subsystems (before this fix):

  • CSRF checks (form-like POSTs return 403 behind a proxy)
  • Auth and setup wizard redirects (point to the internal address)
  • OAuth redirect URIs (don't match the registered public URL)
  • MCP/CLI well-known discovery endpoints (advertise internal URLs)
  • Secure cookie flag (omitted when TLS is terminated by the proxy)
  • Snapshot export, theme preview, sitemap, robots.txt, JSON-LD

What this adds:

// astro.config.mjs
emdash({
  siteUrl: "https://mysite.example.com",
})

A single getPublicOrigin(url, config) helper wraps every affected url.origin call. When siteUrl is set, it returns that; otherwise it falls back to url.origin unchanged, so non-proxied deployments are not affected. The public origin comes from operator config or environment variables, not from request headers.

Also supports EMDASH_SITE_URL / SITE_URL env vars for container deployments where the domain is only known at runtime (using process.env rather than import.meta.env so it works after the build).

Breaking: passkeyPublicOrigin is removed. Since this is pre-release, renaming felt cleaner than maintaining both. Migration is just renaming the key in astro.config.mjs.

Decision point: checkOrigin: false

This PR disables Astro's built-in security.checkOrigin and relies on EmDash's own CSRF layer (checkPublicCsrf + X-EmDash-Request header). I want to be transparent about why and open this to discussion.

The problem: Astro's checkOrigin and allowedDomains are baked into the build manifest. Behind a reverse proxy, url.origin is the internal address and Astro's origin check blocks form-like POSTs and requests without content-type with "Cross-site POST form submissions are forbidden" -- before EmDash middleware runs. (Note: application/json requests are not affected by Astro's check, but some EmDash flows use form-like content types.) The only Astro-native solutions are:

  • allowedDomains scoped to a hostname -- works, but requires the domain at build time, which breaks Docker "build once, deploy anywhere" images where SITE_URL is set at runtime
  • allowedDomains: [{}] wildcard -- enables host header poisoning on GET responses (login redirects, OAuth, WWW-Authenticate) when SITE_URL isn't configured

The approach: Disable checkOrigin and let EmDash handle CSRF entirely. EmDash's CSRF layer handles the proxy case that Astro's check cannot: dual-origin support and runtime siteUrl resolution via env vars. When siteUrl is known at build time, allowedDomains is still set so Astro.url reflects the public origin for user template code.

The gap: User-authored form-handling routes on the same Astro site would lose Astro's CSRF protection for form submissions. (Astro's checkOrigin only covers form-like content types and requests without content-type -- it does not protect application/json API routes regardless.) In practice, EmDash sites typically don't add custom form POST endpoints (all state changes go through EmDash's API), but this is worth flagging.

Alternatives I considered but rejected:

  • Middleware interception (Astro's origin check is prepended via unshift before user middleware in the pipeline)
  • Runtime manifest patching (integration can't control the adapter)
  • Custom adapter (too invasive for this fix)

If you'd prefer a different approach -- keeping checkOrigin enabled and documenting that Docker users must set siteUrl at build time, or another path I haven't considered -- I'm happy to rework this.

Discussion #315 was opened before this implementation. I'm happy to adjust scope, naming, or approach based on maintainer feedback -- this can be split, renamed, or otherwise reshaped.

Related: #210, #225, #253, #393

Type of change

  • Bug fix
  • Feature (requires approved Discussion)
  • Refactor (no behavior change)
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

AI-generated code disclosure

  • This PR includes AI-generated code

Screenshots / test output

Test output
115 test files passed (2132 tests)
✓ packages/core/tests/unit/api/public-url.test.ts
✓ packages/core/tests/unit/api/csrf.test.ts
✓ packages/core/tests/unit/auth/passkey-config.test.ts
✓ packages/core/tests/unit/auth/discovery-endpoints.test.ts

UpperM added 3 commits April 8, 2026 14:27
Add `siteUrl` to EmDashConfig, replacing `passkeyPublicOrigin`. Create
`getPublicOrigin(url, config)` pure helper in api/public-url.ts that
resolves the public origin from config, EMDASH_SITE_URL / SITE_URL env
vars (at runtime via process.env), or falls back to url.origin. Both
config and env var paths validate http/https protocol only.

Extend checkPublicCsrf() with dual-origin matching so the Origin header
can match either the internal or public origin behind a reverse proxy.

Disable Astro's checkOrigin (EmDash's CSRF handles origin validation
with dual-origin and runtime siteUrl support that Astro's build-time
check cannot provide). When siteUrl is known at build time, also set
allowedDomains so Astro.url reflects the public origin in templates.

Discussion: emdash-cms#315
Replace url.origin with getPublicOrigin() across 25 files:
- Auth middleware: CSRF checks, login redirects, WWW-Authenticate
- Setup wizard + dev-bypass: store public origin as emdash:site_url
- Passkey routes (8 files): use siteUrl for rpId and origin
- OAuth: provider redirects, callback, authorize, device code
- Well-known endpoints: RFC 8414 and RFC 9728 metadata
- Snapshot export, theme preview HMAC, sitemap, robots.txt
- Page context + JSON-LD: pass siteUrl through for structured data

OAuth Secure cookie flag now checks siteUrl protocol when set,
preserving the existing fallback for non-proxy deployments.
Update public docs, skills reference, and demo config to document
siteUrl replacing passkeyPublicOrigin. Add EMDASH_SITE_URL / SITE_URL
to env vars table. Changeset: minor bump with breaking-change note.
@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Apr 8, 2026

🦋 Changeset detected

Latest commit: 3cb6035

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 9 packages
Name Type
emdash Minor
@emdash-cms/cloudflare Patch
@emdash-cms/plugin-ai-moderation Major
@emdash-cms/plugin-atproto Patch
@emdash-cms/plugin-audit-log Patch
@emdash-cms/plugin-color Major
@emdash-cms/plugin-embeds Major
@emdash-cms/plugin-forms Major
@emdash-cms/plugin-webhook-notifier Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 8, 2026

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 8, 2026

Scope check

This PR changes 552 lines across 37 files. Large PRs are harder to review and more likely to be closed without review.

If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs.

See CONTRIBUTING.md for contribution guidelines.

@UpperM
Copy link
Copy Markdown
Author

UpperM commented Apr 8, 2026

I have read the CLA Document and I hereby sign the CLA

github-actions bot added a commit that referenced this pull request Apr 8, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 8, 2026

Overlapping PRs

This PR modifies files that are also changed by other open PRs:

This may cause merge conflicts or duplicated work. A maintainer will coordinate.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants