From 91d4456c256703adbc3da3c85b9b8f6d3fed671b Mon Sep 17 00:00:00 2001 From: UpperM Date: Wed, 8 Apr 2026 14:27:54 +0200 Subject: [PATCH 1/3] fix: add siteUrl config option with getPublicOrigin() helper 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: https://github.com/emdash-cms/emdash/discussions/315 --- packages/core/src/api/csrf.ts | 15 ++- packages/core/src/api/public-url.ts | 84 ++++++++++++ packages/core/src/astro/integration/index.ts | 37 +++++- .../core/src/astro/integration/runtime.ts | 12 +- packages/core/src/auth/passkey-config.ts | 16 +-- packages/core/tests/unit/api/csrf.test.ts | 36 +++++ .../core/tests/unit/api/public-url.test.ts | 125 ++++++++++++++++++ .../tests/unit/auth/passkey-config.test.ts | 10 +- 8 files changed, 305 insertions(+), 30 deletions(-) create mode 100644 packages/core/src/api/public-url.ts create mode 100644 packages/core/tests/unit/api/public-url.test.ts diff --git a/packages/core/src/api/csrf.ts b/packages/core/src/api/csrf.ts index f8e433434..d190c01e3 100644 --- a/packages/core/src/api/csrf.ts +++ b/packages/core/src/api/csrf.ts @@ -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; @@ -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 } diff --git a/packages/core/src/api/public-url.ts b/packages/core/src/api/public-url.ts new file mode 100644 index 000000000..cb5afb9b7 --- /dev/null +++ b/packages/core/src/api/public-url.ts @@ -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}`; +} diff --git a/packages/core/src/astro/integration/index.ts b/packages/core/src/astro/integration/index.ts index 3ed69a2d2..217686a4c 100644 --- a/packages/core/src/astro/integration/index.ts +++ b/packages/core/src/astro/integration/index.ts @@ -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; } @@ -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 @@ -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 = { + checkOrigin: false, + ...(resolvedConfig.siteUrl + ? { allowedDomains: [{ hostname: new URL(resolvedConfig.siteUrl).hostname }] } + : {}), + }; updateConfig({ + security: securityConfig, vite: createViteConfig( { serializableConfig, diff --git a/packages/core/src/astro/integration/runtime.ts b/packages/core/src/astro/integration/runtime.ts index 7b000d738..2b25d6867 100644 --- a/packages/core/src/astro/integration/runtime.ts +++ b/packages/core/src/astro/integration/runtime.ts @@ -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. diff --git a/packages/core/src/auth/passkey-config.ts b/packages/core/src/auth/passkey-config.ts index d37391625..a17b2eadc 100644 --- a/packages/core/src/auth/passkey-config.ts +++ b/packages/core/src/auth/passkey-config.ts @@ -17,21 +17,17 @@ export interface PasskeyConfig { * * @param url The request URL (typically `new URL(Astro.request.url)` or `new URL(request.url)`) * @param siteName Optional site name for rpName (defaults to hostname from `url` or public origin) - * @param passkeyPublicOrigin Optional browser-facing origin (see `EmDashConfig.passkeyPublicOrigin`). + * @param siteUrl Optional browser-facing origin (see `EmDashConfig.siteUrl`). * When set, **origin** and **rpId** are taken from this URL so they match WebAuthn `clientData.origin`. - * @throws If `passkeyPublicOrigin` is non-empty but not parseable by `new URL()`. + * @throws If `siteUrl` is non-empty but not parseable by `new URL()`. */ -export function getPasskeyConfig( - url: URL, - siteName?: string, - passkeyPublicOrigin?: string, -): PasskeyConfig { - if (passkeyPublicOrigin) { +export function getPasskeyConfig(url: URL, siteName?: string, siteUrl?: string): PasskeyConfig { + if (siteUrl) { let publicUrl: URL; try { - publicUrl = new URL(passkeyPublicOrigin); + publicUrl = new URL(siteUrl); } catch (e) { - throw new Error(`Invalid passkeyPublicOrigin: "${passkeyPublicOrigin}"`, { cause: e }); + throw new Error(`Invalid siteUrl: "${siteUrl}"`, { cause: e }); } return { rpName: siteName || publicUrl.hostname, diff --git a/packages/core/tests/unit/api/csrf.test.ts b/packages/core/tests/unit/api/csrf.test.ts index 8a0e043d4..2573f81fe 100644 --- a/packages/core/tests/unit/api/csrf.test.ts +++ b/packages/core/tests/unit/api/csrf.test.ts @@ -113,6 +113,42 @@ describe("checkPublicCsrf", () => { }); }); + describe("dual-origin matching (reverse proxy)", () => { + it("accepts Origin matching public origin when behind proxy", () => { + const request = makeRequest("POST", { + Origin: "https://mysite.example.com", + }); + // Internal URL is http, public is https — proxy scenario + const url = new URL("http://localhost:4321/_emdash/api/comments/posts/abc"); + expect(checkPublicCsrf(request, url, "https://mysite.example.com")).toBeNull(); + }); + + it("still accepts Origin matching internal origin when publicOrigin is set", () => { + const request = makeRequest("POST", { + Origin: "http://localhost:4321", + }); + const url = new URL("http://localhost:4321/_emdash/api/comments/posts/abc"); + expect(checkPublicCsrf(request, url, "https://mysite.example.com")).toBeNull(); + }); + + it("rejects Origin matching neither internal nor public", () => { + const request = makeRequest("POST", { + Origin: "http://evil.com", + }); + const url = new URL("http://localhost:4321/_emdash/api/comments/posts/abc"); + const response = checkPublicCsrf(request, url, "https://mysite.example.com"); + expect(response).not.toBeNull(); + expect(response!.status).toBe(403); + }); + + it("unchanged behavior when publicOrigin is undefined", () => { + const request = makeRequest("POST", { + Origin: "http://example.com", + }); + expect(checkPublicCsrf(request, makeUrl(), undefined)).toBeNull(); + }); + }); + describe("allows requests without Origin header", () => { it("allows POST without any Origin (non-browser client)", () => { const request = makeRequest("POST"); diff --git a/packages/core/tests/unit/api/public-url.test.ts b/packages/core/tests/unit/api/public-url.test.ts new file mode 100644 index 000000000..78a8524e1 --- /dev/null +++ b/packages/core/tests/unit/api/public-url.test.ts @@ -0,0 +1,125 @@ +import { afterEach, beforeEach, describe, it, expect } from "vitest"; + +import { + getPublicOrigin, + getPublicUrl, + _resetEnvSiteUrlCache, +} from "../../../src/api/public-url.js"; +import type { EmDashConfig } from "../../../src/astro/integration/runtime.js"; + +// Snapshot env vars we'll mutate, and restore after every test. +const origEmdashSiteUrl = process.env.EMDASH_SITE_URL; +const origSiteUrl = process.env.SITE_URL; + +afterEach(() => { + _resetEnvSiteUrlCache(); + // Restore original env state (delete if originally absent) + if (origEmdashSiteUrl === undefined) delete process.env.EMDASH_SITE_URL; + else process.env.EMDASH_SITE_URL = origEmdashSiteUrl; + if (origSiteUrl === undefined) delete process.env.SITE_URL; + else process.env.SITE_URL = origSiteUrl; +}); + +// Ensure clean state before every test (no cache, no test env vars). +beforeEach(() => { + _resetEnvSiteUrlCache(); + delete process.env.EMDASH_SITE_URL; + delete process.env.SITE_URL; +}); + +describe("getPublicOrigin()", () => { + it("returns config.siteUrl when set", () => { + const url = new URL("http://localhost:4321/admin"); + const config: EmDashConfig = { siteUrl: "https://mysite.example.com" }; + expect(getPublicOrigin(url, config)).toBe("https://mysite.example.com"); + }); + + it("returns url.origin when config has no siteUrl", () => { + const url = new URL("http://localhost:4321/admin"); + const config: EmDashConfig = {}; + expect(getPublicOrigin(url, config)).toBe("http://localhost:4321"); + }); + + it("returns url.origin when config is undefined", () => { + const url = new URL("https://example.com:8443/setup"); + expect(getPublicOrigin(url)).toBe("https://example.com:8443"); + }); + + it("returns url.origin when config.siteUrl is undefined", () => { + const url = new URL("http://127.0.0.1:4321/api"); + expect(getPublicOrigin(url, { siteUrl: undefined })).toBe("http://127.0.0.1:4321"); + }); + + it("does not return empty string siteUrl (falsy)", () => { + const url = new URL("http://localhost:4321/x"); + // Empty string should fall through to url.origin + expect(getPublicOrigin(url, { siteUrl: "" })).toBe("http://localhost:4321"); + }); +}); + +describe("getPublicOrigin() env var fallback", () => { + it("falls back to EMDASH_SITE_URL when config has no siteUrl", () => { + process.env.EMDASH_SITE_URL = "https://env.example.com"; + const url = new URL("http://localhost:4321/x"); + expect(getPublicOrigin(url, {})).toBe("https://env.example.com"); + }); + + it("falls back to SITE_URL when EMDASH_SITE_URL is absent", () => { + process.env.SITE_URL = "https://site-url.example.com"; + const url = new URL("http://localhost:4321/x"); + expect(getPublicOrigin(url, {})).toBe("https://site-url.example.com"); + }); + + it("prefers EMDASH_SITE_URL over SITE_URL", () => { + process.env.EMDASH_SITE_URL = "https://emdash.example.com"; + process.env.SITE_URL = "https://site.example.com"; + const url = new URL("http://localhost:4321/x"); + expect(getPublicOrigin(url, {})).toBe("https://emdash.example.com"); + }); + + it("normalizes env var to origin (strips path)", () => { + process.env.EMDASH_SITE_URL = "https://env.example.com/some/path"; + const url = new URL("http://localhost:4321/x"); + expect(getPublicOrigin(url, {})).toBe("https://env.example.com"); + }); + + it("falls through to url.origin when env var is invalid URL", () => { + process.env.EMDASH_SITE_URL = "not-a-url"; + const url = new URL("http://localhost:4321/x"); + expect(getPublicOrigin(url, {})).toBe("http://localhost:4321"); + }); + + it("config.siteUrl takes precedence over env var", () => { + process.env.EMDASH_SITE_URL = "https://env.example.com"; + const url = new URL("http://localhost:4321/x"); + const config: EmDashConfig = { siteUrl: "https://config.example.com" }; + expect(getPublicOrigin(url, config)).toBe("https://config.example.com"); + }); + + it("cache is invalidated by _resetEnvSiteUrlCache()", () => { + process.env.EMDASH_SITE_URL = "https://first.example.com"; + const url = new URL("http://localhost:4321/x"); + expect(getPublicOrigin(url, {})).toBe("https://first.example.com"); + + _resetEnvSiteUrlCache(); + process.env.EMDASH_SITE_URL = "https://second.example.com"; + expect(getPublicOrigin(url, {})).toBe("https://second.example.com"); + }); +}); + +describe("getPublicUrl()", () => { + it("builds full URL from siteUrl + path", () => { + const url = new URL("http://localhost:4321/x"); + const config: EmDashConfig = { siteUrl: "https://mysite.example.com" }; + expect(getPublicUrl(url, config, "/_emdash/admin/login")).toBe( + "https://mysite.example.com/_emdash/admin/login", + ); + }); + + it("builds full URL from request origin when no siteUrl", () => { + const url = new URL("http://localhost:4321/x"); + expect(getPublicUrl(url, undefined, "/_emdash/admin/login")).toBe( + "http://localhost:4321/_emdash/admin/login", + ); + }); +}); diff --git a/packages/core/tests/unit/auth/passkey-config.test.ts b/packages/core/tests/unit/auth/passkey-config.test.ts index bf942fb14..1b6d65cc9 100644 --- a/packages/core/tests/unit/auth/passkey-config.test.ts +++ b/packages/core/tests/unit/auth/passkey-config.test.ts @@ -28,7 +28,7 @@ describe("passkey-config", () => { expect(config.origin).toBe("http://emdash.local:8080"); }); - it("HTTPS listener on proxy with HTTP upstream: passkeyPublicOrigin aligns origin with browser", () => { + it("HTTPS listener on proxy with HTTP upstream: siteUrl aligns origin with browser", () => { const urlAstroSeesFromForwardedHttp = urlAfterTrustedProxy( "/_emdash/api/setup/admin", "emdash.local:8080", @@ -43,11 +43,9 @@ describe("passkey-config", () => { }); describe("getPasskeyConfig()", () => { - it("throws when passkeyPublicOrigin is not a valid URL", () => { + it("throws when siteUrl is not a valid URL", () => { const url = new URL("http://localhost:4321/admin"); - expect(() => getPasskeyConfig(url, "Site", "::not-a-url")).toThrow( - "Invalid passkeyPublicOrigin", - ); + expect(() => getPasskeyConfig(url, "Site", "::not-a-url")).toThrow("Invalid siteUrl"); }); it("extracts rpId from localhost URL", () => { @@ -139,7 +137,7 @@ describe("passkey-config", () => { expect(fromServer.origin).not.toBe(fromBrowser.origin); }); - it("passkeyPublicOrigin overrides origin and rpId (TLS termination and loopback request URL)", () => { + it("siteUrl overrides origin and rpId (TLS termination and loopback request URL)", () => { const fromForwardedHttp = getPasskeyConfig( new URL("http://emdash.local:8443/_emdash/api/setup/admin"), "My Site", From 2922e868227838f770b5f30d76ab9ecb45c53c6d Mon Sep 17 00:00:00 2001 From: UpperM Date: Wed, 8 Apr 2026 14:27:54 +0200 Subject: [PATCH 2/3] fix: wire getPublicOrigin() through all affected call sites 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. --- packages/core/src/astro/middleware/auth.ts | 20 ++++++++++++------- .../astro/routes/api/auth/invite/complete.ts | 5 +++-- .../astro/routes/api/auth/oauth/[provider].ts | 3 ++- .../api/auth/oauth/[provider]/callback.ts | 3 ++- .../astro/routes/api/auth/passkey/options.ts | 5 +++-- .../api/auth/passkey/register/options.ts | 5 +++-- .../api/auth/passkey/register/verify.ts | 5 +++-- .../astro/routes/api/auth/passkey/verify.ts | 5 +++-- .../astro/routes/api/auth/signup/complete.ts | 5 +++-- .../src/astro/routes/api/oauth/authorize.ts | 17 ++++++++++------ .../src/astro/routes/api/oauth/device/code.ts | 6 +++++- .../astro/routes/api/setup/admin-verify.ts | 5 +++-- .../core/src/astro/routes/api/setup/admin.ts | 5 +++-- .../src/astro/routes/api/setup/dev-bypass.ts | 3 ++- .../core/src/astro/routes/api/setup/index.ts | 5 +++-- .../core/src/astro/routes/api/snapshot.ts | 3 ++- .../src/astro/routes/api/themes/preview.ts | 3 ++- .../well-known/oauth-authorization-server.ts | 5 +++-- .../well-known/oauth-protected-resource.ts | 5 +++-- packages/core/src/astro/routes/robots.txt.ts | 6 +++++- packages/core/src/astro/routes/sitemap.xml.ts | 6 +++++- packages/core/src/page/context.ts | 3 +++ packages/core/src/page/jsonld.ts | 14 ++++++++----- packages/core/src/plugins/types.ts | 2 ++ .../unit/auth/discovery-endpoints.test.ts | 7 ++++--- 25 files changed, 100 insertions(+), 51 deletions(-) diff --git a/packages/core/src/astro/middleware/auth.ts b/packages/core/src/astro/middleware/auth.ts index 76228d1c6..9fbd4f2da 100644 --- a/packages/core/src/astro/middleware/auth.ts +++ b/packages/core/src/astro/middleware/auth.ts @@ -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 = { @@ -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(); @@ -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); @@ -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" } }), @@ -589,15 +593,16 @@ async function handlePasskeyAuth( const headers: Record = { ...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()); } @@ -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 @@ -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()); } diff --git a/packages/core/src/astro/routes/api/auth/invite/complete.ts b/packages/core/src/astro/routes/api/auth/invite/complete.ts index 8333afb4c..410018d9c 100644 --- a/packages/core/src/astro/routes/api/auth/invite/complete.ts +++ b/packages/core/src/astro/routes/api/auth/invite/complete.ts @@ -15,6 +15,7 @@ import { verifyRegistrationResponse, registerPasskey } from "@emdash-cms/auth/pa import { apiError, apiSuccess, handleError } from "#api/error.js"; import { isParseError, parseBody } from "#api/parse.js"; +import { getPublicOrigin } from "#api/public-url.js"; import { inviteCompleteBody } from "#api/schemas.js"; import { createChallengeStore } from "#auth/challenge-store.js"; import { getPasskeyConfig } from "#auth/passkey-config.js"; @@ -22,7 +23,6 @@ import { OptionsRepository } from "#db/repositories/options.js"; export const POST: APIRoute = async ({ request, locals, session }) => { const { emdash } = locals; - const passkeyPublicOrigin = emdash?.config.passkeyPublicOrigin; if (!emdash?.db) { return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); @@ -38,7 +38,8 @@ export const POST: APIRoute = async ({ request, locals, session }) => { const url = new URL(request.url); const options = new OptionsRepository(emdash.db); const siteName = (await options.get("emdash:site_title")) ?? undefined; - const passkeyConfig = getPasskeyConfig(url, siteName, passkeyPublicOrigin); + const siteUrl = getPublicOrigin(url, emdash?.config); + const passkeyConfig = getPasskeyConfig(url, siteName, siteUrl); // Verify the passkey registration response const challengeStore = createChallengeStore(emdash.db); diff --git a/packages/core/src/astro/routes/api/auth/oauth/[provider].ts b/packages/core/src/astro/routes/api/auth/oauth/[provider].ts index d8150d5c6..58e6ce174 100644 --- a/packages/core/src/astro/routes/api/auth/oauth/[provider].ts +++ b/packages/core/src/astro/routes/api/auth/oauth/[provider].ts @@ -10,6 +10,7 @@ export const prerender = false; import { createAuthorizationUrl, type OAuthConsumerConfig } from "@emdash-cms/auth"; +import { getPublicOrigin } from "#api/public-url.js"; import { createOAuthStateStore } from "#auth/oauth-state-store.js"; type ProviderName = "github" | "google"; @@ -101,7 +102,7 @@ export const GET: APIRoute = async ({ params, request, locals, redirect }) => { } const config: OAuthConsumerConfig = { - baseUrl: `${url.origin}/_emdash`, + baseUrl: `${getPublicOrigin(url, emdash?.config)}/_emdash`, providers, }; diff --git a/packages/core/src/astro/routes/api/auth/oauth/[provider]/callback.ts b/packages/core/src/astro/routes/api/auth/oauth/[provider]/callback.ts index 7c69cd613..c74994cd3 100644 --- a/packages/core/src/astro/routes/api/auth/oauth/[provider]/callback.ts +++ b/packages/core/src/astro/routes/api/auth/oauth/[provider]/callback.ts @@ -17,6 +17,7 @@ import { } from "@emdash-cms/auth"; import { createKyselyAdapter } from "@emdash-cms/auth/adapters/kysely"; +import { getPublicOrigin } from "#api/public-url.js"; import { createOAuthStateStore } from "#auth/oauth-state-store.js"; type ProviderName = "github" | "google"; @@ -126,7 +127,7 @@ export const GET: APIRoute = async ({ params, request, locals, session, redirect } const config: OAuthConsumerConfig = { - baseUrl: `${url.origin}/_emdash`, + baseUrl: `${getPublicOrigin(url, emdash?.config)}/_emdash`, providers, canSelfSignup: async (email: string) => { // Extract domain from email diff --git a/packages/core/src/astro/routes/api/auth/passkey/options.ts b/packages/core/src/astro/routes/api/auth/passkey/options.ts index 51fb533b4..b228798c1 100644 --- a/packages/core/src/astro/routes/api/auth/passkey/options.ts +++ b/packages/core/src/astro/routes/api/auth/passkey/options.ts @@ -15,6 +15,7 @@ import { generateAuthenticationOptions } from "@emdash-cms/auth/passkey"; import { apiError, apiSuccess, handleError } from "#api/error.js"; import { isParseError, parseOptionalBody } from "#api/parse.js"; +import { getPublicOrigin } from "#api/public-url.js"; import { passkeyOptionsBody } from "#api/schemas.js"; import { createChallengeStore, cleanupExpiredChallenges } from "#auth/challenge-store.js"; import { getPasskeyConfig } from "#auth/passkey-config.js"; @@ -23,7 +24,6 @@ import { OptionsRepository } from "#db/repositories/options.js"; export const POST: APIRoute = async ({ request, locals }) => { const { emdash } = locals; - const passkeyPublicOrigin = emdash?.config.passkeyPublicOrigin; if (!emdash?.db) { return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); @@ -63,7 +63,8 @@ export const POST: APIRoute = async ({ request, locals }) => { const url = new URL(request.url); const options = new OptionsRepository(emdash.db); const siteName = (await options.get("emdash:site_title")) ?? undefined; - const passkeyConfig = getPasskeyConfig(url, siteName, passkeyPublicOrigin); + const siteUrl = getPublicOrigin(url, emdash?.config); + const passkeyConfig = getPasskeyConfig(url, siteName, siteUrl); // Generate authentication options const challengeStore = createChallengeStore(emdash.db); diff --git a/packages/core/src/astro/routes/api/auth/passkey/register/options.ts b/packages/core/src/astro/routes/api/auth/passkey/register/options.ts index b4b2befa8..376101014 100644 --- a/packages/core/src/astro/routes/api/auth/passkey/register/options.ts +++ b/packages/core/src/astro/routes/api/auth/passkey/register/options.ts @@ -13,6 +13,7 @@ import { generateRegistrationOptions } from "@emdash-cms/auth/passkey"; import { apiError, apiSuccess, handleError } from "#api/error.js"; import { isParseError, parseOptionalBody } from "#api/parse.js"; +import { getPublicOrigin } from "#api/public-url.js"; import { passkeyRegisterOptionsBody } from "#api/schemas.js"; import { createChallengeStore } from "#auth/challenge-store.js"; import { getPasskeyConfig } from "#auth/passkey-config.js"; @@ -22,7 +23,6 @@ const MAX_PASSKEYS = 10; export const POST: APIRoute = async ({ request, locals }) => { const { emdash, user } = locals; - const passkeyPublicOrigin = emdash?.config.passkeyPublicOrigin; if (!emdash?.db) { return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); @@ -53,7 +53,8 @@ export const POST: APIRoute = async ({ request, locals }) => { const url = new URL(request.url); const optionsRepo = new OptionsRepository(emdash.db); const siteName = (await optionsRepo.get("emdash:site_title")) ?? undefined; - const passkeyConfig = getPasskeyConfig(url, siteName, passkeyPublicOrigin); + const siteUrl = getPublicOrigin(url, emdash?.config); + const passkeyConfig = getPasskeyConfig(url, siteName, siteUrl); // Generate registration options const challengeStore = createChallengeStore(emdash.db); diff --git a/packages/core/src/astro/routes/api/auth/passkey/register/verify.ts b/packages/core/src/astro/routes/api/auth/passkey/register/verify.ts index 56eb123f4..367b32f4b 100644 --- a/packages/core/src/astro/routes/api/auth/passkey/register/verify.ts +++ b/packages/core/src/astro/routes/api/auth/passkey/register/verify.ts @@ -13,6 +13,7 @@ import { verifyRegistrationResponse, registerPasskey } from "@emdash-cms/auth/pa import { apiError, apiSuccess } from "#api/error.js"; import { isParseError, parseBody } from "#api/parse.js"; +import { getPublicOrigin } from "#api/public-url.js"; import { passkeyRegisterVerifyBody } from "#api/schemas.js"; import { createChallengeStore } from "#auth/challenge-store.js"; import { getPasskeyConfig } from "#auth/passkey-config.js"; @@ -31,7 +32,6 @@ interface PasskeyResponse { export const POST: APIRoute = async ({ request, locals }) => { const { emdash, user } = locals; - const passkeyPublicOrigin = emdash?.config.passkeyPublicOrigin; if (!emdash?.db) { return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); @@ -59,7 +59,8 @@ export const POST: APIRoute = async ({ request, locals }) => { const url = new URL(request.url); const optionsRepo = new OptionsRepository(emdash.db); const siteName = (await optionsRepo.get("emdash:site_title")) ?? undefined; - const passkeyConfig = getPasskeyConfig(url, siteName, passkeyPublicOrigin); + const siteUrl = getPublicOrigin(url, emdash?.config); + const passkeyConfig = getPasskeyConfig(url, siteName, siteUrl); // Verify the registration response const challengeStore = createChallengeStore(emdash.db); diff --git a/packages/core/src/astro/routes/api/auth/passkey/verify.ts b/packages/core/src/astro/routes/api/auth/passkey/verify.ts index f8949bffe..fd13ec497 100644 --- a/packages/core/src/astro/routes/api/auth/passkey/verify.ts +++ b/packages/core/src/astro/routes/api/auth/passkey/verify.ts @@ -13,6 +13,7 @@ import { authenticateWithPasskey } from "@emdash-cms/auth/passkey"; import { apiError, apiSuccess, handleError } from "#api/error.js"; import { isParseError, parseBody } from "#api/parse.js"; +import { getPublicOrigin } from "#api/public-url.js"; import { passkeyVerifyBody } from "#api/schemas.js"; import { createChallengeStore } from "#auth/challenge-store.js"; import { getPasskeyConfig } from "#auth/passkey-config.js"; @@ -20,7 +21,6 @@ import { OptionsRepository } from "#db/repositories/options.js"; export const POST: APIRoute = async ({ request, locals, session }) => { const { emdash } = locals; - const passkeyPublicOrigin = emdash?.config.passkeyPublicOrigin; if (!emdash?.db) { return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); @@ -34,7 +34,8 @@ export const POST: APIRoute = async ({ request, locals, session }) => { const url = new URL(request.url); const options = new OptionsRepository(emdash.db); const siteName = (await options.get("emdash:site_title")) ?? undefined; - const passkeyConfig = getPasskeyConfig(url, siteName, passkeyPublicOrigin); + const siteUrl = getPublicOrigin(url, emdash?.config); + const passkeyConfig = getPasskeyConfig(url, siteName, siteUrl); // Authenticate with passkey const adapter = createKyselyAdapter(emdash.db); diff --git a/packages/core/src/astro/routes/api/auth/signup/complete.ts b/packages/core/src/astro/routes/api/auth/signup/complete.ts index 1af7d6786..2ff865a17 100644 --- a/packages/core/src/astro/routes/api/auth/signup/complete.ts +++ b/packages/core/src/astro/routes/api/auth/signup/complete.ts @@ -15,6 +15,7 @@ import { verifyRegistrationResponse, registerPasskey } from "@emdash-cms/auth/pa import { apiError, apiSuccess, handleError } from "#api/error.js"; import { isParseError, parseBody } from "#api/parse.js"; +import { getPublicOrigin } from "#api/public-url.js"; import { signupCompleteBody } from "#api/schemas.js"; import { createChallengeStore } from "#auth/challenge-store.js"; import { getPasskeyConfig } from "#auth/passkey-config.js"; @@ -22,7 +23,6 @@ import { OptionsRepository } from "#db/repositories/options.js"; export const POST: APIRoute = async ({ request, locals, session }) => { const { emdash } = locals; - const passkeyPublicOrigin = emdash?.config.passkeyPublicOrigin; if (!emdash?.db) { return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); @@ -38,7 +38,8 @@ export const POST: APIRoute = async ({ request, locals, session }) => { const url = new URL(request.url); const options = new OptionsRepository(emdash.db); const siteName = (await options.get("emdash:site_title")) ?? undefined; - const passkeyConfig = getPasskeyConfig(url, siteName, passkeyPublicOrigin); + const siteUrl = getPublicOrigin(url, emdash?.config); + const passkeyConfig = getPasskeyConfig(url, siteName, siteUrl); // Verify the passkey registration response const challengeStore = createChallengeStore(emdash.db); diff --git a/packages/core/src/astro/routes/api/oauth/authorize.ts b/packages/core/src/astro/routes/api/oauth/authorize.ts index 2fad3f06e..2da1b15a7 100644 --- a/packages/core/src/astro/routes/api/oauth/authorize.ts +++ b/packages/core/src/astro/routes/api/oauth/authorize.ts @@ -22,6 +22,7 @@ import { validateRedirectUri, } from "#api/handlers/oauth-authorization.js"; import { lookupOAuthClient, validateClientRedirectUri } from "#api/handlers/oauth-clients.js"; +import { getPublicOrigin } from "#api/public-url.js"; import { VALID_SCOPES } from "#auth/api-tokens.js"; export const prerender = false; @@ -40,13 +41,17 @@ function generateCsrfToken(): string { } /** Build the Set-Cookie header value for the CSRF token. */ -function csrfCookieHeader(token: string, request: Request): string { +function csrfCookieHeader(token: string, request: Request, siteUrl?: string): string { // SameSite=Strict prevents cross-site form submission. // HttpOnly: the token value is embedded in the form hidden field server-side, // so JS never needs to read the cookie. HttpOnly adds defense-in-depth. - // Secure is only set over HTTPS — omitting it on localhost allows the cookie - // to be sent over plain HTTP during development. - const secure = new URL(request.url).protocol === "https:" ? "; Secure" : ""; + // Secure is set when: + // - siteUrl is configured and uses https (proxy case — request may be http internally), OR + // - the actual request is over https (non-proxy case, preserve existing behavior — H-2) + const isSecure = siteUrl + ? siteUrl.startsWith("https:") + : new URL(request.url).protocol === "https:"; + const secure = isSecure ? "; Secure" : ""; return `${CSRF_COOKIE_NAME}=${token}; Path=/_emdash/api/oauth/authorize; HttpOnly; SameSite=Strict${secure}`; } @@ -130,7 +135,7 @@ export const GET: APIRoute = async ({ url, request, locals }) => { // If not authenticated, redirect to login with return URL if (!user) { - 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 + url.search); return Response.redirect(loginUrl.toString(), 302); } @@ -169,7 +174,7 @@ export const GET: APIRoute = async ({ url, request, locals }) => { return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8", - "Set-Cookie": csrfCookieHeader(csrfToken, request), + "Set-Cookie": csrfCookieHeader(csrfToken, request, getPublicOrigin(url, emdash?.config)), }, }); }; diff --git a/packages/core/src/astro/routes/api/oauth/device/code.ts b/packages/core/src/astro/routes/api/oauth/device/code.ts index b4d0395a6..74e62a7fc 100644 --- a/packages/core/src/astro/routes/api/oauth/device/code.ts +++ b/packages/core/src/astro/routes/api/oauth/device/code.ts @@ -13,6 +13,7 @@ import { z } from "zod"; import { apiError, handleError, unwrapResult } from "#api/error.js"; import { handleDeviceCodeRequest } from "#api/handlers/device-flow.js"; import { isParseError, parseBody } from "#api/parse.js"; +import { getPublicOrigin } from "#api/public-url.js"; import { checkRateLimit, getClientIp, rateLimitResponse } from "#auth/rate-limit.js"; export const prerender = false; @@ -41,7 +42,10 @@ export const POST: APIRoute = async ({ request, locals, url }) => { } // Build the verification URI — device page lives inside the admin SPA - const verificationUri = new URL("/_emdash/admin/device", url.origin).toString(); + const verificationUri = new URL( + "/_emdash/admin/device", + getPublicOrigin(url, emdash?.config), + ).toString(); const result = await handleDeviceCodeRequest(emdash.db, body, verificationUri); return unwrapResult(result); diff --git a/packages/core/src/astro/routes/api/setup/admin-verify.ts b/packages/core/src/astro/routes/api/setup/admin-verify.ts index ca9bd74b0..b8197ffad 100644 --- a/packages/core/src/astro/routes/api/setup/admin-verify.ts +++ b/packages/core/src/astro/routes/api/setup/admin-verify.ts @@ -14,6 +14,7 @@ import { verifyRegistrationResponse, registerPasskey } from "@emdash-cms/auth/pa import { apiError, apiSuccess, handleError } from "#api/error.js"; import { isParseError, parseBody } from "#api/parse.js"; +import { getPublicOrigin } from "#api/public-url.js"; import { setupAdminVerifyBody } from "#api/schemas.js"; import { createChallengeStore } from "#auth/challenge-store.js"; import { getPasskeyConfig } from "#auth/passkey-config.js"; @@ -21,7 +22,6 @@ import { OptionsRepository } from "#db/repositories/options.js"; export const POST: APIRoute = async ({ request, locals }) => { const { emdash } = locals; - const passkeyPublicOrigin = emdash?.config.passkeyPublicOrigin; if (!emdash?.db) { return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); @@ -58,7 +58,8 @@ export const POST: APIRoute = async ({ request, locals }) => { // Get passkey config const url = new URL(request.url); const siteName = (await options.get("emdash:site_title")) ?? undefined; - const passkeyConfig = getPasskeyConfig(url, siteName, passkeyPublicOrigin); + const siteUrl = getPublicOrigin(url, emdash?.config); + const passkeyConfig = getPasskeyConfig(url, siteName, siteUrl); // Verify the registration response const challengeStore = createChallengeStore(emdash.db); diff --git a/packages/core/src/astro/routes/api/setup/admin.ts b/packages/core/src/astro/routes/api/setup/admin.ts index fe091cba1..6827e728a 100644 --- a/packages/core/src/astro/routes/api/setup/admin.ts +++ b/packages/core/src/astro/routes/api/setup/admin.ts @@ -13,6 +13,7 @@ import { generateRegistrationOptions } from "@emdash-cms/auth/passkey"; import { apiError, apiSuccess, handleError } from "#api/error.js"; import { isParseError, parseBody } from "#api/parse.js"; +import { getPublicOrigin } from "#api/public-url.js"; import { setupAdminBody } from "#api/schemas.js"; import { createChallengeStore } from "#auth/challenge-store.js"; import { getPasskeyConfig } from "#auth/passkey-config.js"; @@ -20,7 +21,6 @@ import { OptionsRepository } from "#db/repositories/options.js"; export const POST: APIRoute = async ({ request, locals }) => { const { emdash } = locals; - const passkeyPublicOrigin = emdash?.config.passkeyPublicOrigin; if (!emdash?.db) { return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); @@ -57,7 +57,8 @@ export const POST: APIRoute = async ({ request, locals }) => { // Get passkey config const url = new URL(request.url); const siteName = (await options.get("emdash:site_title")) ?? undefined; - const passkeyConfig = getPasskeyConfig(url, siteName, passkeyPublicOrigin); + const siteUrl = getPublicOrigin(url, emdash?.config); + const passkeyConfig = getPasskeyConfig(url, siteName, siteUrl); // Generate registration options const challengeStore = createChallengeStore(emdash.db); diff --git a/packages/core/src/astro/routes/api/setup/dev-bypass.ts b/packages/core/src/astro/routes/api/setup/dev-bypass.ts index d8a915551..d3adabb1a 100644 --- a/packages/core/src/astro/routes/api/setup/dev-bypass.ts +++ b/packages/core/src/astro/routes/api/setup/dev-bypass.ts @@ -24,6 +24,7 @@ import { ulid } from "ulidx"; import { apiError, apiSuccess, handleError } from "#api/error.js"; import { escapeHtml } from "#api/escape.js"; import { handleApiTokenCreate } from "#api/handlers/api-tokens.js"; +import { getPublicOrigin } from "#api/public-url.js"; import { isSafeRedirect } from "#api/redirect.js"; import { runMigrations } from "#db/migrations/runner.js"; import { OptionsRepository } from "#db/repositories/options.js"; @@ -120,7 +121,7 @@ async function handleDevBypass(context: Parameters[0]): Promise { +export const POST: APIRoute = async ({ request, url, locals }) => { const { emdash } = locals; if (!emdash?.db) { @@ -89,7 +90,7 @@ export const POST: APIRoute = async ({ request, locals }) => { // Store the canonical site URL from the setup request. // This is trusted because setup runs on the real domain. - const siteUrl = new URL(request.url).origin; + const siteUrl = getPublicOrigin(url, emdash.config); await options.set("emdash:site_url", siteUrl); if (useExternalAuth) { diff --git a/packages/core/src/astro/routes/api/snapshot.ts b/packages/core/src/astro/routes/api/snapshot.ts index b634b60bb..555f92d5f 100644 --- a/packages/core/src/astro/routes/api/snapshot.ts +++ b/packages/core/src/astro/routes/api/snapshot.ts @@ -16,6 +16,7 @@ import { parsePreviewSignatureHeader, verifyPreviewSignature, } from "#api/handlers/snapshot.js"; +import { getPublicOrigin } from "#api/public-url.js"; export const prerender = false; @@ -65,7 +66,7 @@ export const GET: APIRoute = async ({ request, locals, url }) => { const includeDrafts = url.searchParams.get("drafts") === "true"; const snapshot = await generateSnapshot(emdash.db, { includeDrafts, - origin: url.origin, + origin: getPublicOrigin(url, emdash.config), }); return apiSuccess(snapshot); diff --git a/packages/core/src/astro/routes/api/themes/preview.ts b/packages/core/src/astro/routes/api/themes/preview.ts index d0d81e898..5213567b8 100644 --- a/packages/core/src/astro/routes/api/themes/preview.ts +++ b/packages/core/src/astro/routes/api/themes/preview.ts @@ -11,6 +11,7 @@ import type { APIRoute } from "astro"; import { requirePerm } from "#api/authorize.js"; import { apiError, apiSuccess } from "#api/error.js"; +import { getPublicOrigin } from "#api/public-url.js"; export const prerender = false; @@ -52,7 +53,7 @@ export const POST: APIRoute = async ({ request, url, locals }) => { return apiError("INVALID_REQUEST", "previewUrl must use HTTPS", 400); } - const source = url.origin; + const source = getPublicOrigin(url, emdash?.config); const ttl = 3600; // 1 hour const exp = Math.floor(Date.now() / 1000) + ttl; diff --git a/packages/core/src/astro/routes/api/well-known/oauth-authorization-server.ts b/packages/core/src/astro/routes/api/well-known/oauth-authorization-server.ts index a4616bb18..2c0d08455 100644 --- a/packages/core/src/astro/routes/api/well-known/oauth-authorization-server.ts +++ b/packages/core/src/astro/routes/api/well-known/oauth-authorization-server.ts @@ -9,12 +9,13 @@ import type { APIRoute } from "astro"; +import { getPublicOrigin } from "#api/public-url.js"; import { VALID_SCOPES } from "#auth/api-tokens.js"; export const prerender = false; -export const GET: APIRoute = async ({ url }) => { - const origin = url.origin; +export const GET: APIRoute = async ({ url, locals }) => { + const origin = getPublicOrigin(url, locals.emdash?.config); const issuer = `${origin}/_emdash`; return Response.json( diff --git a/packages/core/src/astro/routes/api/well-known/oauth-protected-resource.ts b/packages/core/src/astro/routes/api/well-known/oauth-protected-resource.ts index b141929d9..820745e2d 100644 --- a/packages/core/src/astro/routes/api/well-known/oauth-protected-resource.ts +++ b/packages/core/src/astro/routes/api/well-known/oauth-protected-resource.ts @@ -13,12 +13,13 @@ import type { APIRoute } from "astro"; +import { getPublicOrigin } from "#api/public-url.js"; import { VALID_SCOPES } from "#auth/api-tokens.js"; export const prerender = false; -export const GET: APIRoute = async ({ url }) => { - const origin = url.origin; +export const GET: APIRoute = async ({ url, locals }) => { + const origin = getPublicOrigin(url, locals.emdash?.config); return Response.json( { diff --git a/packages/core/src/astro/routes/robots.txt.ts b/packages/core/src/astro/routes/robots.txt.ts index b87670ac0..467206d40 100644 --- a/packages/core/src/astro/routes/robots.txt.ts +++ b/packages/core/src/astro/routes/robots.txt.ts @@ -10,6 +10,7 @@ import type { APIRoute } from "astro"; +import { getPublicOrigin } from "#api/public-url.js"; import { getSiteSettingsWithDb } from "#settings/index.js"; export const prerender = false; @@ -29,7 +30,10 @@ export const GET: APIRoute = async ({ locals, url }) => { try { const settings = await getSiteSettingsWithDb(emdash.db); - const siteUrl = (settings.url || url.origin).replace(TRAILING_SLASH_RE, ""); + const siteUrl = (settings.url || getPublicOrigin(url, emdash?.config)).replace( + TRAILING_SLASH_RE, + "", + ); const sitemapUrl = `${siteUrl}/sitemap.xml`; // Use custom robots.txt if configured diff --git a/packages/core/src/astro/routes/sitemap.xml.ts b/packages/core/src/astro/routes/sitemap.xml.ts index 9fb853515..9bcfee541 100644 --- a/packages/core/src/astro/routes/sitemap.xml.ts +++ b/packages/core/src/astro/routes/sitemap.xml.ts @@ -13,6 +13,7 @@ import type { APIRoute } from "astro"; import { handleSitemapData } from "#api/handlers/seo.js"; +import { getPublicOrigin } from "#api/public-url.js"; import { getSiteSettingsWithDb } from "#settings/index.js"; export const prerender = false; @@ -37,7 +38,10 @@ export const GET: APIRoute = async ({ locals, url }) => { try { // Determine site URL from settings or request origin const settings = await getSiteSettingsWithDb(emdash.db); - const siteUrl = (settings.url || url.origin).replace(TRAILING_SLASH_RE, ""); + const siteUrl = (settings.url || getPublicOrigin(url, emdash?.config)).replace( + TRAILING_SLASH_RE, + "", + ); const result = await handleSitemapData(emdash.db); diff --git a/packages/core/src/page/context.ts b/packages/core/src/page/context.ts index b1f12e75a..f1cb1595b 100644 --- a/packages/core/src/page/context.ts +++ b/packages/core/src/page/context.ts @@ -31,6 +31,8 @@ interface PageContextFields { }; /** Site name for structured data and og:site_name */ siteName?: string; + /** Public-facing site URL (origin) for structured data */ + siteUrl?: string; } /** Input with Astro global -- used in .astro files */ @@ -89,5 +91,6 @@ export function createPublicPageContext(input: CreatePublicPageContextInput): Pu seo: input.seo, articleMeta: input.articleMeta, siteName: input.siteName, + siteUrl: input.siteUrl, }; } diff --git a/packages/core/src/page/jsonld.ts b/packages/core/src/page/jsonld.ts index 2ad3dd162..789484364 100644 --- a/packages/core/src/page/jsonld.ts +++ b/packages/core/src/page/jsonld.ts @@ -77,12 +77,16 @@ export function buildWebSiteJsonLd(page: PublicPageContext): Record[0]; + return { + url: new URL("/.well-known/test", origin), + locals: { emdash: undefined }, + } as unknown as Parameters[0]; } describe("Protected Resource Metadata (RFC 9728)", () => { From e2f111f0f048e7aa0dd89fdffbc224ba75883e33 Mon Sep 17 00:00:00 2001 From: UpperM Date: Wed, 8 Apr 2026 14:27:54 +0200 Subject: [PATCH 3/3] docs: update configuration docs for siteUrl, add changeset 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/site-url-reverse-proxy.md | 11 ++++++++ demos/simple/astro.config.mjs | 4 +-- .../content/docs/reference/configuration.mdx | 25 +++++++++++------- .../references/configuration.md | 26 +++++++++++++++++-- 4 files changed, 53 insertions(+), 13 deletions(-) create mode 100644 .changeset/site-url-reverse-proxy.md diff --git a/.changeset/site-url-reverse-proxy.md b/.changeset/site-url-reverse-proxy.md new file mode 100644 index 000000000..1abf1c12a --- /dev/null +++ b/.changeset/site-url-reverse-proxy.md @@ -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`. diff --git a/demos/simple/astro.config.mjs b/demos/simple/astro.config.mjs index d77553265..fe222d948 100644 --- a/demos/simple/astro.config.mjs +++ b/demos/simple/astro.config.mjs @@ -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 }, diff --git a/docs/src/content/docs/reference/configuration.mdx b/docs/src/content/docs/reference/configuration.mdx index e1d537604..c2f911ef5 100644 --- a/docs/src/content/docs/reference/configuration.mdx +++ b/docs/src/content/docs/reference/configuration.mdx @@ -200,13 +200,13 @@ Use Cloudflare Access as the authentication provider instead of passkeys. magic links, and self-signup are disabled. -### `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({ @@ -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. + + + +#### 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.