Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/site-url-reverse-proxy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"emdash": minor
---

Adds `siteUrl` config option to fix reverse-proxy origin mismatch. Replaces `passkeyPublicOrigin` with a single setting that covers all origin-dependent features: passkeys, CSRF, OAuth, auth redirects, MCP discovery, snapshots, sitemap, robots.txt, and JSON-LD.

Supports `EMDASH_SITE_URL` / `SITE_URL` environment variables for container deployments where the domain is only known at runtime.

Disables Astro's `security.checkOrigin` (EmDash's own CSRF layer handles origin validation with dual-origin support and runtime siteUrl resolution). When `siteUrl` is set in config, also sets `security.allowedDomains` so `Astro.url` reflects the public origin in templates.

**Breaking:** `passkeyPublicOrigin` is removed. Rename to `siteUrl` in your `astro.config.mjs`.
4 changes: 2 additions & 2 deletions demos/simple/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ export default defineConfig({
baseUrl: "/_emdash/api/media/file",
}),
plugins: [auditLogPlugin()],
// HTTPS reverse proxy: uncomment so passkey verify matches browser origin
// passkeyPublicOrigin: "https://emdash.local:8443",
// HTTPS reverse proxy: uncomment so all origin-dependent features match browser
// siteUrl: "https://emdash.local:8443",
}),
],
devToolbar: { enabled: false },
Expand Down
25 changes: 16 additions & 9 deletions docs/src/content/docs/reference/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -200,13 +200,13 @@ Use Cloudflare Access as the authentication provider instead of passkeys.
magic links, and self-signup are disabled.
</Aside>

### `passkeyPublicOrigin`
### `siteUrl`

**Optional.** Pass a full **browser-facing origin** (scheme + host + optional port, **no path**) so WebAuthn **`rpId`** and **`origin`** match what the user’s browser sends in `clientData.origin`.
**Optional.** The public browser-facing origin for the site (scheme + host + optional port, **no path**).

By default, passkeys follow **`Astro.url`** / **`request.url`**. Behind a **TLS-terminating reverse proxy**, the app often still sees **`http://`** on the internal hop while the tab is **`https://`**, or the reconstructed host does not match the public name — which breaks passkey verification. Set `passkeyPublicOrigin` to the origin users type in the address bar (for example `https://cms.example.com` or `https://cms.example.com:8443`).
Behind a **TLS-terminating reverse proxy**, `Astro.url` returns the internal address (`http://localhost:4321`) instead of the public one (`https://cms.example.com`). This breaks passkeys, CSRF origin matching, OAuth redirects, login redirects, MCP discovery, snapshot exports, sitemap, robots.txt, and JSON-LD structured data. Set `siteUrl` to fix all of these at once.

The integration **validates** this value at load time: it must be a valid URL with **`http:`** or **`https:`** protocol and is normalized to **`origin`**.
The integration **validates** this value at load time: it must be a valid URL with **`http:`** or **`https:`** protocol and is normalized to **origin** (path is stripped).

```js
emdash({
Expand All @@ -215,17 +215,23 @@ emdash({
directory: "./uploads",
baseUrl: "/_emdash/api/media/file",
}),
passkeyPublicOrigin: "https://cms.example.com",
siteUrl: "https://cms.example.com",
});
```

#### Reverse proxy and passkeys
When `siteUrl` is not set in config, EmDash checks environment variables in order: `EMDASH_SITE_URL`, then `SITE_URL`. This is useful for container deployments where the public URL is set at runtime.

<Aside type="tip">
`siteUrl` replaces `passkeyPublicOrigin` (removed). If you were using `passkeyPublicOrigin`, rename it to `siteUrl` -- it now covers passkeys and all other origin-dependent features.
</Aside>

#### Reverse proxy setup

Astro only reflects **`X-Forwarded-*`** when the public host is allowed. Configure [**`security.allowedDomains`**](https://docs.astro.build/en/reference/configuration-reference/#securityalloweddomains) for the hostname (and schemes) your users hit. In **`astro dev`**, add matching **`vite.server.allowedHosts`** so Vite accepts the proxy **`Host`** header.

Prefer fixing **`allowedDomains`** (and forwarded headers) first; use **`passkeyPublicOrigin`** when the reconstructed URL **still** diverges from the browser origin (typical when TLS is terminated in front and the upstream request stays **`http://`**).
Prefer fixing **`allowedDomains`** (and forwarded headers) first; use **`siteUrl`** when the reconstructed URL **still** diverges from the browser origin (typical when TLS is terminated in front and the upstream request stays **`http://`**).

With TLS in front, binding the dev server to loopback (**`astro dev --host 127.0.0.1`**) is often enough: the proxy connects locally while **`passkeyPublicOrigin`** matches the public HTTPS origin.
With TLS in front, binding the dev server to loopback (**`astro dev --host 127.0.0.1`**) is often enough: the proxy connects locally while **`siteUrl`** matches the public HTTPS origin.

<Aside type="caution">
Your reverse proxy should forward a **port-aware** `Host` / `X-Forwarded-Host` when you use non-default ports. If the proxy strips the port, **`rpId`** and Astro’s rebuilt URL can be wrong.
Expand Down Expand Up @@ -255,7 +261,7 @@ export default defineConfig({
directory: "./uploads",
baseUrl: "/_emdash/api/media/file",
}),
passkeyPublicOrigin: "https://cms.example.com",
siteUrl: "https://cms.example.com",
}),
],
});
Expand Down Expand Up @@ -435,6 +441,7 @@ EmDash respects these environment variables:

| Variable | Description |
| ------------------------- | ---------------------------------------- |
| `EMDASH_SITE_URL` | Public browser-facing origin (falls back to `SITE_URL`) |
| `EMDASH_DATABASE_URL` | Override database URL |
| `EMDASH_AUTH_SECRET` | Secret for passkey authentication |
| `EMDASH_PREVIEW_SECRET` | Secret for preview token generation |
Expand Down
15 changes: 13 additions & 2 deletions packages/core/src/api/csrf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,24 @@ import { apiError } from "./error.js";
*
* State-changing requests (POST/PUT/DELETE) to public endpoints must either:
* 1. Include the X-EmDash-Request: 1 header (custom header blocked cross-origin), OR
* 2. Have an Origin header matching the request origin
* 2. Have an Origin header matching the request origin (or the configured public origin)
*
* This prevents cross-origin form submissions (which can't set custom headers)
* and cross-origin fetch (blocked by CORS unless allowed). Same-origin requests
* always include a matching Origin header.
*
* Returns a 403 Response if the check fails, or null if allowed.
*
* @param request The incoming request
* @param url The request URL (internal origin)
* @param publicOrigin The public-facing origin from config.siteUrl. Must be
* `undefined` when absent — never `null` or `""` (security invariant H-1a).
*/
export function checkPublicCsrf(request: Request, url: URL): Response | null {
export function checkPublicCsrf(
request: Request,
url: URL,
publicOrigin?: string,
): Response | null {
// Custom header present — browser blocks cross-origin custom headers
const csrfHeader = request.headers.get("X-EmDash-Request");
if (csrfHeader === "1") return null;
Expand All @@ -33,7 +42,9 @@ export function checkPublicCsrf(request: Request, url: URL): Response | null {
if (origin) {
try {
const originUrl = new URL(origin);
// Accept if Origin matches either the internal or public origin
if (originUrl.origin === url.origin) return null;
if (publicOrigin && originUrl.origin === publicOrigin) return null;
} catch {
// Malformed Origin — fall through to reject
}
Expand Down
84 changes: 84 additions & 0 deletions packages/core/src/api/public-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* Public URL helpers for reverse-proxy deployments.
*
* Behind a TLS-terminating proxy the internal request URL
* (`http://localhost:4321`) differs from the browser-facing origin
* (`https://mysite.example.com`). These pure helpers resolve the
* correct public origin from config, falling back to the request URL.
*
* Workers-safe: no Node.js imports.
*/

import type { EmDashConfig } from "../astro/integration/runtime.js";

/**
* Resolve siteUrl from runtime environment variables.
*
* Uses process.env (not import.meta.env) because Vite statically replaces
* import.meta.env at build time, baking out any env vars not present during
* the build. Container deployments set env vars at runtime, so we must read
* process.env which Vite leaves untouched.
*
* On Cloudflare Workers process.env is unavailable (returns undefined),
* so the fallback chain continues to url.origin.
*
* Caches after first call.
*/
let _envSiteUrl: string | undefined | null = null;

/** @internal Reset cached env value — test-only. */
export function _resetEnvSiteUrlCache(): void {
_envSiteUrl = null;
}

function getEnvSiteUrl(): string | undefined {
if (_envSiteUrl !== null) return _envSiteUrl || undefined;
try {
// process.env is available on Node.js; undefined on Workers
const value =
(typeof process !== "undefined" && process.env?.EMDASH_SITE_URL) ||
(typeof process !== "undefined" && process.env?.SITE_URL) ||
"";
if (value) {
const parsed = new URL(value);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
_envSiteUrl = "";
return undefined;
}
_envSiteUrl = parsed.origin;
} else {
_envSiteUrl = "";
}
} catch {
_envSiteUrl = "";
}
return _envSiteUrl || undefined;
}

/**
* Return the public-facing origin for the site.
*
* Resolution order:
* 1. `config.siteUrl` (set in astro.config.mjs, origin-normalized at startup)
* 2. `EMDASH_SITE_URL` or `SITE_URL` env var (resolved at runtime for containers)
* 3. `url.origin` (internal request URL — correct when no proxy)
*
* @param url The request URL (`new URL(request.url)` or `Astro.url`)
* @param config The EmDash config (from `locals.emdash?.config`)
* @returns Origin string, e.g. `"https://mysite.example.com"`
*/
export function getPublicOrigin(url: URL, config?: EmDashConfig): string {
return config?.siteUrl || getEnvSiteUrl() || url.origin;
}

/**
* Build a full public URL by appending a path to the public origin.
*
* @param url The request URL
* @param config The EmDash config
* @param path Path to append (must start with `/`)
* @returns Full URL string, e.g. `"https://mysite.example.com/_emdash/admin/login"`
*/
export function getPublicUrl(url: URL, config: EmDashConfig | undefined, path: string): string {
return `${getPublicOrigin(url, config)}${path}`;
}
37 changes: 30 additions & 7 deletions packages/core/src/astro/integration/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,17 +90,22 @@ export function emdash(config: EmDashConfig = {}): AstroIntegration {
}
}

if (resolvedConfig.passkeyPublicOrigin) {
const raw = resolvedConfig.passkeyPublicOrigin;
// Validate siteUrl if provided in astro.config.mjs.
// Env-var fallback (EMDASH_SITE_URL / SITE_URL) is handled at runtime by
// getPublicOrigin() in api/public-url.ts — NOT here — so Docker images built
// without a domain can pick it up at container start via process.env.
if (resolvedConfig.siteUrl) {
const raw = resolvedConfig.siteUrl;
try {
const parsed = new URL(raw);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error(`passkeyPublicOrigin must be http or https (got ${parsed.protocol})`);
throw new Error(`siteUrl must be http or https (got ${parsed.protocol})`);
}
resolvedConfig.passkeyPublicOrigin = parsed.origin;
// Always store origin-normalized value (no path) — security invariant L-1
resolvedConfig.siteUrl = parsed.origin;
} catch (e) {
if (e instanceof TypeError) {
throw new Error(`Invalid passkeyPublicOrigin: "${raw}"`, { cause: e });
throw new Error(`Invalid siteUrl: "${raw}"`, { cause: e });
}
throw e;
}
Expand Down Expand Up @@ -152,7 +157,7 @@ export function emdash(config: EmDashConfig = {}): AstroIntegration {
storage: resolvedConfig.storage,
auth: resolvedConfig.auth,
marketplace: resolvedConfig.marketplace,
passkeyPublicOrigin: resolvedConfig.passkeyPublicOrigin,
siteUrl: resolvedConfig.siteUrl,
};

// Determine auth mode for route injection
Expand Down Expand Up @@ -184,8 +189,26 @@ export function emdash(config: EmDashConfig = {}): AstroIntegration {
};
}

// Update Vite config with virtual modules and other settings
// Disable Astro's built-in checkOrigin -- EmDash's own CSRF
// layer (checkPublicCsrf in api/csrf.ts) handles origin
// validation with dual-origin support: it accepts both the
// internal origin AND the public origin from getPublicOrigin(),
// which resolves siteUrl from config or env vars at runtime.
// Astro's check can't do this because allowedDomains is baked
// at build time, which breaks Docker deployments where the
// domain is only known at container start via EMDASH_SITE_URL.
//
// When siteUrl is known at build time, also set allowedDomains
// so Astro.url reflects the public origin (helps user template
// code that reads Astro.url directly).
const securityConfig: Record<string, unknown> = {
checkOrigin: false,
...(resolvedConfig.siteUrl
? { allowedDomains: [{ hostname: new URL(resolvedConfig.siteUrl).hostname }] }
: {}),
};
updateConfig({
security: securityConfig,
vite: createViteConfig(
{
serializableConfig,
Expand Down
12 changes: 7 additions & 5 deletions packages/core/src/astro/integration/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,17 +263,19 @@ export interface EmDashConfig {
marketplace?: string;

/**
* Public browser origin for passkey verification (rpId + expected WebAuthn origin).
* Public browser-facing origin for the site.
*
* Use when `Astro.url` / `request.url` do not match what users open — common with a
* **TLS-terminating reverse proxy**: the app often sees `http://` on the internal hop
* while the browser uses `https://`, which breaks WebAuthn origin checks.
* while the browser uses `https://`, which breaks WebAuthn, CSRF, OAuth, and redirect URLs.
*
* Set to the full origin users type in the address bar (no path), e.g.
* `https://emdash.local:8443`. Prefer fixing `security.allowedDomains` for the proxy first;
* use this when the reconstructed URL still diverges from the browser.
* `https://mysite.example.com`. When not set, falls back to environment variables
* `EMDASH_SITE_URL` > `SITE_URL`, then to the request URL's origin.
*
* Replaces `passkeyPublicOrigin` (which only fixed passkeys).
*/
passkeyPublicOrigin?: string;
siteUrl?: string;

/**
* Enable playground mode for ephemeral "try EmDash" sites.
Expand Down
20 changes: 13 additions & 7 deletions packages/core/src/astro/middleware/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { authenticate as virtualAuthenticate } from "virtual:emdash/auth";

import { checkPublicCsrf } from "../../api/csrf.js";
import { apiError } from "../../api/error.js";
import { getPublicOrigin } from "../../api/public-url.js";

/** Cache headers for middleware error responses (matches API_CACHE_HEADERS in api/error.ts) */
const MW_CACHE_HEADERS = {
Expand Down Expand Up @@ -134,7 +135,8 @@ export const onRequest = defineMiddleware(async (context, next) => {
if (isPublicApiRoute) {
const method = context.request.method.toUpperCase();
if (method !== "GET" && method !== "HEAD" && method !== "OPTIONS") {
const csrfError = checkPublicCsrf(context.request, url);
const publicOrigin = getPublicOrigin(url, context.locals.emdash?.config);
const csrfError = checkPublicCsrf(context.request, url, publicOrigin);
if (csrfError) return csrfError;
}
return next();
Expand All @@ -148,7 +150,8 @@ export const onRequest = defineMiddleware(async (context, next) => {
if (isPluginRoute) {
const method = context.request.method.toUpperCase();
if (method !== "GET" && method !== "HEAD" && method !== "OPTIONS") {
const csrfError = checkPublicCsrf(context.request, url);
const publicOrigin = getPublicOrigin(url, context.locals.emdash?.config);
const csrfError = checkPublicCsrf(context.request, url, publicOrigin);
if (csrfError) return csrfError;
}
return handlePluginRouteAuth(context, next);
Expand Down Expand Up @@ -192,8 +195,9 @@ export const onRequest = defineMiddleware(async (context, next) => {
};
// Add WWW-Authenticate header on MCP endpoint 401s to trigger OAuth discovery
if (url.pathname === "/_emdash/api/mcp") {
const origin = getPublicOrigin(url, context.locals.emdash?.config);
headers["WWW-Authenticate"] =
`Bearer resource_metadata="${url.origin}/.well-known/oauth-protected-resource"`;
`Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource"`;
}
return new Response(
JSON.stringify({ error: { code: "INVALID_TOKEN", message: "Invalid or expired token" } }),
Expand Down Expand Up @@ -589,15 +593,16 @@ async function handlePasskeyAuth(
const headers: Record<string, string> = { ...MW_CACHE_HEADERS };
// Add WWW-Authenticate on MCP endpoint 401s to trigger OAuth discovery
if (url.pathname === "/_emdash/api/mcp") {
const origin = getPublicOrigin(url, emdash?.config);
headers["WWW-Authenticate"] =
`Bearer resource_metadata="${url.origin}/.well-known/oauth-protected-resource"`;
`Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource"`;
}
return Response.json(
{ error: { code: "NOT_AUTHENTICATED", message: "Not authenticated" } },
{ status: 401, headers },
);
}
const loginUrl = new URL("/_emdash/admin/login", url.origin);
const loginUrl = new URL("/_emdash/admin/login", getPublicOrigin(url, emdash?.config));
loginUrl.searchParams.set("redirect", url.pathname);
return context.redirect(loginUrl.toString());
}
Expand All @@ -615,7 +620,8 @@ async function handlePasskeyAuth(
{ status: 401, headers: MW_CACHE_HEADERS },
);
}
return context.redirect("/_emdash/admin/login");
const loginUrl = new URL("/_emdash/admin/login", getPublicOrigin(url, emdash?.config));
return context.redirect(loginUrl.toString());
}

// Check if user is disabled
Expand All @@ -624,7 +630,7 @@ async function handlePasskeyAuth(
if (isApiRoute) {
return apiError("ACCOUNT_DISABLED", "Account disabled", 403);
}
const loginUrl = new URL("/_emdash/admin/login", url.origin);
const loginUrl = new URL("/_emdash/admin/login", getPublicOrigin(url, emdash?.config));
loginUrl.searchParams.set("error", "account_disabled");
return context.redirect(loginUrl.toString());
}
Expand Down
Loading
Loading