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
5 changes: 5 additions & 0 deletions .changeset/fix-site-base-url-config.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 14 additions & 2 deletions packages/core/src/api/public-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
*/

/** Minimal config shape — avoids importing the full EmDashConfig type tree. */
interface SiteUrlConfig {
export interface SiteUrlConfig {
siteUrl?: string;
}

Expand Down Expand Up @@ -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;
}

/**
Expand Down
22 changes: 16 additions & 6 deletions packages/core/src/api/site-url.ts
Original file line number Diff line number Diff line change
@@ -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<Database>, request: Request): Promise<string> {
export async function getSiteBaseUrl(
db: Kysely<Database>,
request: Request,
config?: SiteUrlConfig,
): Promise<string> {
const configured = getConfiguredOrigin(config);
if (configured) {
return `${configured}/_emdash`;
}
const options = new OptionsRepository(db);
const storedUrl = await options.get<string>("emdash:site_url");
if (storedUrl) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>("emdash:site_title")) ?? "EmDash";

const config: MagicLinkConfig = {
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/astro/routes/api/auth/invite/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ export const POST: APIRoute = async ({ request, locals }) => {
const options = new OptionsRepository(emdash.db);
const siteName = (await options.get<string>("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()
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/astro/routes/api/auth/magic-link/send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>("emdash:site_title")) ?? "EmDash";

const config: MagicLinkConfig = {
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/astro/routes/api/auth/signup/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,8 @@ export const POST: APIRoute = async ({ request, locals }) => {
const options = new OptionsRepository(emdash.db);
const siteName = (await options.get<string>("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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
67 changes: 67 additions & 0 deletions packages/core/tests/integration/api/site-url.test.ts
Original file line number Diff line number Diff line change
@@ -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<Database>;

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`);
});
});
Loading