diff --git a/.changeset/fix-site-base-url-config.md b/.changeset/fix-site-base-url-config.md new file mode 100644 index 0000000000..5f9f7692be --- /dev/null +++ b/.changeset/fix-site-base-url-config.md @@ -0,0 +1,5 @@ +--- +"emdash": patch +--- + +Fixes outbound email links (magic link, invites, signup confirmation, account recovery, comment notifications) ignoring the configured `siteUrl`. The configured origin — `siteUrl` in the integration options or the `EMDASH_SITE_URL`/`SITE_URL` environment variables — now takes precedence over the origin stored during setup, so a site set up on a temporary domain no longer needs a manual database edit to correct its email links. diff --git a/packages/core/src/api/public-url.ts b/packages/core/src/api/public-url.ts index c9b00960f8..dd843b3713 100644 --- a/packages/core/src/api/public-url.ts +++ b/packages/core/src/api/public-url.ts @@ -10,7 +10,7 @@ */ /** Minimal config shape — avoids importing the full EmDashConfig type tree. */ -interface SiteUrlConfig { +export interface SiteUrlConfig { siteUrl?: string; } @@ -74,7 +74,19 @@ function getEnvSiteUrl(): string | undefined { * @returns Origin string, e.g. `"https://mysite.example.com"` */ export function getPublicOrigin(url: URL, config?: SiteUrlConfig): string { - return config?.siteUrl || getEnvSiteUrl() || url.origin; + return getConfiguredOrigin(config) || url.origin; +} + +/** + * Return the operator-configured public origin, if any. + * + * The first two steps of {@link getPublicOrigin}'s resolution — + * `config.siteUrl`, then the `EMDASH_SITE_URL`/`SITE_URL` env vars — without + * the request-URL fallback. For callers that have a more trustworthy fallback + * than the request (e.g. the stored setup origin used for outbound emails). + */ +export function getConfiguredOrigin(config?: SiteUrlConfig): string | undefined { + return config?.siteUrl || getEnvSiteUrl() || undefined; } /** diff --git a/packages/core/src/api/site-url.ts b/packages/core/src/api/site-url.ts index bff3d11c8e..d51171441b 100644 --- a/packages/core/src/api/site-url.ts +++ b/packages/core/src/api/site-url.ts @@ -1,19 +1,29 @@ /** * Resolve the canonical site base URL for use in outbound links (emails, etc.). * - * Uses the stored `emdash:site_url` (set during setup on the real domain) - * so that Host header spoofing in later requests cannot redirect users to - * attacker-controlled domains. - * - * Falls back to the request URL only if no stored value exists (pre-setup). + * Precedence mirrors `getPublicOrigin`: the operator-configured origin + * (`config.siteUrl`, then the `EMDASH_SITE_URL`/`SITE_URL` env vars) wins, + * then the stored `emdash:site_url` option (written once during setup on the + * real domain), and only before setup completes does the request URL fill in. + * A configured or stored value always beats the request, so Host header + * spoofing cannot redirect users to attacker-controlled domains. */ import type { Kysely } from "kysely"; import { OptionsRepository } from "../database/repositories/options.js"; import type { Database } from "../database/types.js"; +import { getConfiguredOrigin, type SiteUrlConfig } from "./public-url.js"; -export async function getSiteBaseUrl(db: Kysely, request: Request): Promise { +export async function getSiteBaseUrl( + db: Kysely, + request: Request, + config?: SiteUrlConfig, +): Promise { + const configured = getConfiguredOrigin(config); + if (configured) { + return `${configured}/_emdash`; + } const options = new OptionsRepository(db); const storedUrl = await options.get("emdash:site_url"); if (storedUrl) { diff --git a/packages/core/src/astro/routes/api/admin/comments/[id]/status.ts b/packages/core/src/astro/routes/api/admin/comments/[id]/status.ts index d21b0444eb..fff5192f1e 100644 --- a/packages/core/src/astro/routes/api/admin/comments/[id]/status.ts +++ b/packages/core/src/astro/routes/api/admin/comments/[id]/status.ts @@ -93,7 +93,7 @@ export const PUT: APIRoute = async ({ params, request, locals }) => { // Send notification when a comment is newly approved if (newStatus === "approved" && previousStatus !== "approved" && emdash.email) { try { - const adminBaseUrl = await getSiteBaseUrl(emdash.db, request); + const adminBaseUrl = await getSiteBaseUrl(emdash.db, request, emdash.config); const content = await lookupContentAuthor(emdash.db, updated.collection, updated.contentId); if (content?.author) { await sendCommentNotification({ diff --git a/packages/core/src/astro/routes/api/admin/users/[id]/send-recovery.ts b/packages/core/src/astro/routes/api/admin/users/[id]/send-recovery.ts index 61bee6ffe9..1957068713 100644 --- a/packages/core/src/astro/routes/api/admin/users/[id]/send-recovery.ts +++ b/packages/core/src/astro/routes/api/admin/users/[id]/send-recovery.ts @@ -51,9 +51,9 @@ export const POST: APIRoute = async ({ request, params, locals }) => { ); } - // Build config using stored site URL (not request Host header) + // Build config using the configured site URL, stored option as fallback (not request Host header) const options = new OptionsRepository(emdash.db); - const baseUrl = await getSiteBaseUrl(emdash.db, request); + const baseUrl = await getSiteBaseUrl(emdash.db, request, emdash.config); const siteName = (await options.get("emdash:site_title")) ?? "EmDash"; const config: MagicLinkConfig = { diff --git a/packages/core/src/astro/routes/api/auth/invite/index.ts b/packages/core/src/astro/routes/api/auth/invite/index.ts index 3547a79a44..5c73f467e4 100644 --- a/packages/core/src/astro/routes/api/auth/invite/index.ts +++ b/packages/core/src/astro/routes/api/auth/invite/index.ts @@ -46,8 +46,8 @@ export const POST: APIRoute = async ({ request, locals }) => { const options = new OptionsRepository(emdash.db); const siteName = (await options.get("emdash:site_title")) || "EmDash"; - // Use stored site URL to prevent Host header spoofing in invite emails - const baseUrl = await getSiteBaseUrl(emdash.db, request); + // Use the configured site URL (stored option as fallback) to prevent Host header spoofing in invite emails + const baseUrl = await getSiteBaseUrl(emdash.db, request, emdash.config); // Build email sender from the plugin pipeline (if available) const emailSend = emdash.email?.isAvailable() diff --git a/packages/core/src/astro/routes/api/auth/magic-link/send.ts b/packages/core/src/astro/routes/api/auth/magic-link/send.ts index e80da04177..f0e90ab347 100644 --- a/packages/core/src/astro/routes/api/auth/magic-link/send.ts +++ b/packages/core/src/astro/routes/api/auth/magic-link/send.ts @@ -57,9 +57,9 @@ export const POST: APIRoute = async ({ request, locals }) => { ); } - // Build magic link config using stored site URL (not request Host header) + // Build magic link config using the configured site URL, stored option as fallback (not request Host header) const options = new OptionsRepository(emdash.db); - const baseUrl = await getSiteBaseUrl(emdash.db, request); + const baseUrl = await getSiteBaseUrl(emdash.db, request, emdash.config); const siteName = (await options.get("emdash:site_title")) ?? "EmDash"; const config: MagicLinkConfig = { diff --git a/packages/core/src/astro/routes/api/auth/signup/request.ts b/packages/core/src/astro/routes/api/auth/signup/request.ts index 8c97b0cdab..c1c4193ac3 100644 --- a/packages/core/src/astro/routes/api/auth/signup/request.ts +++ b/packages/core/src/astro/routes/api/auth/signup/request.ts @@ -68,8 +68,8 @@ export const POST: APIRoute = async ({ request, locals }) => { const options = new OptionsRepository(emdash.db); const siteName = (await options.get("emdash:site_title")) || "EmDash"; - // Use stored site URL to prevent Host header spoofing in signup emails - const baseUrl = await getSiteBaseUrl(emdash.db, request); + // Use the configured site URL (stored option as fallback) to prevent Host header spoofing in signup emails + const baseUrl = await getSiteBaseUrl(emdash.db, request, emdash.config); // Request signup - this handles all checks internally and fails silently // if domain not allowed or user exists (to prevent enumeration) diff --git a/packages/core/src/astro/routes/api/comments/[collection]/[contentId]/index.ts b/packages/core/src/astro/routes/api/comments/[collection]/[contentId]/index.ts index 5139edcc47..10e26d4d88 100644 --- a/packages/core/src/astro/routes/api/comments/[collection]/[contentId]/index.ts +++ b/packages/core/src/astro/routes/api/comments/[collection]/[contentId]/index.ts @@ -302,7 +302,7 @@ export const POST: APIRoute = async ({ params, request, locals }) => { // isolate terminates after the response). if (result.comment.status === "approved" && emdash.email && contentAuthor) { try { - const adminBaseUrl = await getSiteBaseUrl(emdash.db, request); + const adminBaseUrl = await getSiteBaseUrl(emdash.db, request, emdash.config); await sendCommentNotification({ email: emdash.email, comment: result.comment, diff --git a/packages/core/tests/integration/api/site-url.test.ts b/packages/core/tests/integration/api/site-url.test.ts new file mode 100644 index 0000000000..aa7e9a4799 --- /dev/null +++ b/packages/core/tests/integration/api/site-url.test.ts @@ -0,0 +1,67 @@ +/** + * getSiteBaseUrl precedence: configured origin → stored setup origin → + * request URL. The configured origin is what `siteUrl` in the integration + * options resolves to; the stored `emdash:site_url` option is written once + * during setup; the request URL only fills in before setup completes and + * must never override either of the other two (Host-spoofing lock). + */ + +import type { Kysely } from "kysely"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { getPublicOrigin } from "../../../src/api/public-url.js"; +import { getSiteBaseUrl } from "../../../src/api/site-url.js"; +import { OptionsRepository } from "../../../src/database/repositories/options.js"; +import type { Database } from "../../../src/database/types.js"; +import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js"; + +const SETUP_ORIGIN = "https://my-site.workers.dev"; +const REAL_ORIGIN = "https://real.example"; +const REQUEST_URL = `${REAL_ORIGIN}/_emdash/api/auth/magic-link/send`; + +describe("getSiteBaseUrl", () => { + let db: Kysely; + + beforeEach(async () => { + db = await setupTestDatabase(); + }); + + afterEach(async () => { + await teardownTestDatabase(db); + }); + + it("configured siteUrl beats the stored setup origin", async () => { + // Setup ran on a throwaway origin; the operator has since configured + // the real one. Email links must follow the config, like every other + // origin-dependent feature does via getPublicOrigin. + await new OptionsRepository(db).set("emdash:site_url", SETUP_ORIGIN); + const config = { siteUrl: REAL_ORIGIN }; + const request = new Request(REQUEST_URL, { method: "POST" }); + + expect(getPublicOrigin(new URL(request.url), config)).toBe(REAL_ORIGIN); + expect(await getSiteBaseUrl(db, request, config)).toBe(`${REAL_ORIGIN}/_emdash`); + }); + + it("falls back to the stored setup origin when nothing is configured", async () => { + await new OptionsRepository(db).set("emdash:site_url", SETUP_ORIGIN); + const request = new Request(REQUEST_URL, { method: "POST" }); + + expect(await getSiteBaseUrl(db, request)).toBe(`${SETUP_ORIGIN}/_emdash`); + }); + + it("stored origin is not overridden by the request host", async () => { + // The Host-spoofing lock: a stored value always beats the request. + await new OptionsRepository(db).set("emdash:site_url", SETUP_ORIGIN); + const spoofed = new Request("https://attacker.example/_emdash/api/auth/magic-link/send", { + method: "POST", + }); + + expect(await getSiteBaseUrl(db, spoofed)).toBe(`${SETUP_ORIGIN}/_emdash`); + }); + + it("derives from the request only before setup has stored an origin", async () => { + const request = new Request(REQUEST_URL, { method: "POST" }); + + expect(await getSiteBaseUrl(db, request)).toBe(`${REAL_ORIGIN}/_emdash`); + }); +});