Emdash source with visual editor image upload fix
Fixes: 1. media.ts: wrap placeholder generation in try-catch 2. toolbar.ts: check r.ok, display error message in popover
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Rate-limit behaviour on POST /_emdash/api/comments/:collection/:contentId.
|
||||
*
|
||||
* Specifically covers the removal of the user-agent-hash fallback. Before,
|
||||
* a submitter with no trusted IP could rotate their User-Agent string to
|
||||
* get a fresh rate-limit bucket each time; the route now buckets all
|
||||
* trusted-IP-less requests together into the shared "unknown" bucket.
|
||||
*
|
||||
* Operators behind a reverse proxy they control should set
|
||||
* `trustedProxyHeaders` (or EMDASH_TRUSTED_PROXY_HEADERS) so this path
|
||||
* isn't hit for legitimate traffic. Those tests live alongside the
|
||||
* extractRequestMeta unit tests.
|
||||
*/
|
||||
|
||||
import type { APIContext } from "astro";
|
||||
import type { Kysely } from "kysely";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { POST as postComment } from "../../../src/astro/routes/api/comments/[collection]/[contentId]/index.js";
|
||||
import { _resetTrustedProxyHeadersCache } from "../../../src/auth/trusted-proxy.js";
|
||||
import type { Database } from "../../../src/database/types.js";
|
||||
import { SchemaRegistry } from "../../../src/schema/registry.js";
|
||||
import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js";
|
||||
|
||||
// Keep the env-derived trusted-header cache from leaking into this file
|
||||
// (a stale EMDASH_TRUSTED_PROXY_HEADERS would route every UA to its own
|
||||
// bucket and make the test pass for the wrong reason).
|
||||
const ORIGINAL_TRUSTED_ENV = process.env.EMDASH_TRUSTED_PROXY_HEADERS;
|
||||
|
||||
function buildRequest(opts: { userAgent?: string; body: unknown }): Request {
|
||||
return new Request("http://localhost/_emdash/api/comments/post/post-1", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(opts.userAgent ? { "user-agent": opts.userAgent } : {}),
|
||||
},
|
||||
body: JSON.stringify(opts.body),
|
||||
});
|
||||
}
|
||||
|
||||
function buildContext(opts: { db: Kysely<Database>; request: Request }): APIContext {
|
||||
return {
|
||||
params: { collection: "post", contentId: "post-1" },
|
||||
request: opts.request,
|
||||
locals: {
|
||||
emdash: {
|
||||
db: opts.db,
|
||||
config: {},
|
||||
hooks: {
|
||||
// Pass-through beforeCreate (returns the event unchanged).
|
||||
runCommentBeforeCreate: async (event: unknown) => event,
|
||||
// No moderator configured — returns null (route coerces to pending).
|
||||
invokeExclusiveHook: async () => null,
|
||||
runCommentAfterCreate: async () => undefined,
|
||||
},
|
||||
},
|
||||
user: null,
|
||||
},
|
||||
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- minimal stub for tests
|
||||
} as unknown as APIContext;
|
||||
}
|
||||
|
||||
describe("POST /comments — UA-hash rate-limit removal", () => {
|
||||
let db: Kysely<Database>;
|
||||
|
||||
beforeEach(async () => {
|
||||
delete process.env.EMDASH_TRUSTED_PROXY_HEADERS;
|
||||
_resetTrustedProxyHeadersCache();
|
||||
db = await setupTestDatabase();
|
||||
const registry = new SchemaRegistry(db);
|
||||
await registry.createCollection({
|
||||
slug: "post",
|
||||
label: "Posts",
|
||||
labelSingular: "Post",
|
||||
commentsEnabled: true,
|
||||
});
|
||||
await registry.createField("post", { slug: "title", label: "Title", type: "string" });
|
||||
// Create a published content row so the comment route can target it.
|
||||
await db
|
||||
.insertInto("ec_post" as never)
|
||||
.values({
|
||||
id: "post-1",
|
||||
slug: "post-1",
|
||||
status: "published",
|
||||
published_at: new Date().toISOString(),
|
||||
title: "Test post",
|
||||
} as never)
|
||||
.execute();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownTestDatabase(db);
|
||||
if (ORIGINAL_TRUSTED_ENV === undefined) {
|
||||
delete process.env.EMDASH_TRUSTED_PROXY_HEADERS;
|
||||
} else {
|
||||
process.env.EMDASH_TRUSTED_PROXY_HEADERS = ORIGINAL_TRUSTED_ENV;
|
||||
}
|
||||
_resetTrustedProxyHeadersCache();
|
||||
});
|
||||
|
||||
it("buckets no-trusted-IP requests together regardless of User-Agent", async () => {
|
||||
// Submit 20 comments from different UA strings but without any
|
||||
// trusted IP header. The limit for the "unknown" bucket is 20/10min.
|
||||
// Before the fix, rotating UAs would give each request its own
|
||||
// bucket; with the fix, they share the "unknown" bucket.
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const res = await postComment(
|
||||
buildContext({
|
||||
db,
|
||||
request: buildRequest({
|
||||
userAgent: `Bot/${i}`,
|
||||
body: {
|
||||
authorName: "Spam",
|
||||
authorEmail: "s@example.com",
|
||||
body: `message ${i}`,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect([200, 201]).toContain(res.status);
|
||||
}
|
||||
|
||||
// 21st call with a fresh UA must still hit the shared bucket and
|
||||
// get rate-limited.
|
||||
const limitedRes = await postComment(
|
||||
buildContext({
|
||||
db,
|
||||
request: buildRequest({
|
||||
userAgent: "Bot/fresh",
|
||||
body: {
|
||||
authorName: "Spam",
|
||||
authorEmail: "s@example.com",
|
||||
body: "one more",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(limitedRes.status).toBe(429);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Success-path coverage for the setup nonce cookie.
|
||||
*
|
||||
* The sibling file `setup-admin-nonce.test.ts` covers the negative
|
||||
* paths (missing cookie, mismatched cookie, rotation) by driving
|
||||
* /setup/admin/verify with a bogus credential that fails at the
|
||||
* WebAuthn step. That harness can't exercise the *successful* verify
|
||||
* path — real WebAuthn verification requires a live authenticator.
|
||||
*
|
||||
* This file stubs `verifyRegistrationResponse` with a fake that
|
||||
* returns synthetic credential material so we can reach the code
|
||||
* after the nonce gate: user creation, passkey registration, setup
|
||||
* completion, and — the property we actually care about — deletion
|
||||
* of the nonce cookie.
|
||||
*
|
||||
* `registerPasskey` is left real; it only talks to the Kysely
|
||||
* adapter against the in-memory test DB.
|
||||
*/
|
||||
|
||||
import type { APIContext, AstroCookies } from "astro";
|
||||
import type { Kysely } from "kysely";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@emdash-cms/auth/passkey", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@emdash-cms/auth/passkey")>();
|
||||
return {
|
||||
...actual,
|
||||
verifyRegistrationResponse: vi.fn(async () => ({
|
||||
credentialId: "fake-credential-id",
|
||||
publicKey: new Uint8Array([1, 2, 3, 4]),
|
||||
counter: 0,
|
||||
deviceType: "singleDevice" as const,
|
||||
backedUp: false,
|
||||
transports: [],
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
// Deferred so vi.mock applies before the route modules evaluate.
|
||||
type AdminRoute = typeof import("../../../src/astro/routes/api/setup/admin.js");
|
||||
type AdminVerifyRoute = typeof import("../../../src/astro/routes/api/setup/admin-verify.js");
|
||||
let postAdmin: AdminRoute["POST"];
|
||||
let postAdminVerify: AdminVerifyRoute["POST"];
|
||||
|
||||
import { OptionsRepository } from "../../../src/database/repositories/options.js";
|
||||
import type { Database } from "../../../src/database/types.js";
|
||||
import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js";
|
||||
|
||||
interface CookieRecord {
|
||||
value: string;
|
||||
options: Record<string, unknown>;
|
||||
deleted?: boolean;
|
||||
}
|
||||
|
||||
interface CookieJar {
|
||||
jar: Map<string, CookieRecord>;
|
||||
cookies: AstroCookies;
|
||||
}
|
||||
|
||||
function createCookieJar(initial: Record<string, string> = {}): CookieJar {
|
||||
const jar = new Map<string, CookieRecord>();
|
||||
for (const [name, value] of Object.entries(initial)) {
|
||||
jar.set(name, { value, options: {} });
|
||||
}
|
||||
|
||||
const cookies = {
|
||||
get(name: string) {
|
||||
const record = jar.get(name);
|
||||
if (!record || record.deleted) return undefined;
|
||||
return { value: record.value };
|
||||
},
|
||||
set(name: string, value: string, options: Record<string, unknown> = {}) {
|
||||
jar.set(name, { value, options });
|
||||
},
|
||||
delete(name: string, options: Record<string, unknown> = {}) {
|
||||
const existing = jar.get(name);
|
||||
jar.set(name, {
|
||||
value: existing?.value ?? "",
|
||||
options: { ...existing?.options, ...options },
|
||||
deleted: true,
|
||||
});
|
||||
},
|
||||
has(name: string) {
|
||||
const record = jar.get(name);
|
||||
return !!record && !record.deleted;
|
||||
},
|
||||
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- minimal stub
|
||||
} as unknown as AstroCookies;
|
||||
|
||||
return { jar, cookies };
|
||||
}
|
||||
|
||||
function buildAdminRequest(body: unknown): Request {
|
||||
return new Request("http://localhost/_emdash/api/setup/admin", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function buildVerifyRequest(body: unknown): Request {
|
||||
return new Request("http://localhost/_emdash/api/setup/admin/verify", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function buildContext(db: Kysely<Database>, request: Request, cookies: AstroCookies): APIContext {
|
||||
return {
|
||||
params: {},
|
||||
url: new URL(request.url),
|
||||
request,
|
||||
cookies,
|
||||
locals: {
|
||||
emdash: {
|
||||
db,
|
||||
config: {},
|
||||
storage: undefined,
|
||||
},
|
||||
},
|
||||
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- minimal stub
|
||||
} as unknown as APIContext;
|
||||
}
|
||||
|
||||
const adminBody = { email: "real@admin.example", name: "Real Admin" };
|
||||
|
||||
// Any object that passes setupAdminVerifyBody — the actual WebAuthn
|
||||
// verification is mocked out, so the fields don't need to parse as
|
||||
// valid authenticator data.
|
||||
const fakeCredential = {
|
||||
credential: {
|
||||
id: "fake-credential-id",
|
||||
rawId: "fake-credential-id",
|
||||
type: "public-key" as const,
|
||||
response: {
|
||||
clientDataJSON: "AA",
|
||||
attestationObject: "AA",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe("POST /setup/admin/verify — success path clears nonce cookie", () => {
|
||||
let db: Kysely<Database>;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await setupTestDatabase();
|
||||
({ POST: postAdmin } = await import("../../../src/astro/routes/api/setup/admin.js"));
|
||||
({ POST: postAdminVerify } =
|
||||
await import("../../../src/astro/routes/api/setup/admin-verify.js"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownTestDatabase(db);
|
||||
});
|
||||
|
||||
it("deletes the setup nonce cookie and marks setup complete when verify succeeds", async () => {
|
||||
// 1. Start admin setup — mints the nonce and drops the cookie.
|
||||
const { jar, cookies } = createCookieJar();
|
||||
const adminRes = await postAdmin(buildContext(db, buildAdminRequest(adminBody), cookies));
|
||||
expect(adminRes.status).toBe(200);
|
||||
const setCookie = jar.get("emdash_setup_nonce");
|
||||
expect(setCookie).toBeDefined();
|
||||
expect(setCookie!.deleted).toBeFalsy();
|
||||
|
||||
// 2. Verify with the mocked-out WebAuthn check. The nonce gate
|
||||
// runs first (real code path), then the stub returns a
|
||||
// synthetic credential and the route creates the user.
|
||||
const verifyRes = await postAdminVerify(
|
||||
buildContext(db, buildVerifyRequest(fakeCredential), cookies),
|
||||
);
|
||||
expect(verifyRes.status).toBe(200);
|
||||
|
||||
// 3. Cookie should now be deleted. The deletion must be
|
||||
// scoped to /_emdash/ so it actually supersedes the cookie
|
||||
// the browser holds.
|
||||
const afterVerify = jar.get("emdash_setup_nonce");
|
||||
expect(afterVerify?.deleted).toBe(true);
|
||||
expect(afterVerify?.options.path).toBe("/_emdash/");
|
||||
|
||||
// 4. Setup state is cleared and setup_complete is set.
|
||||
const options = new OptionsRepository(db);
|
||||
const setupState = await options.get("emdash:setup_state");
|
||||
expect(setupState).toBeNull();
|
||||
const setupComplete = await options.get("emdash:setup_complete");
|
||||
expect(setupComplete).toBe(true);
|
||||
});
|
||||
});
|
||||
265
packages/core/tests/integration/astro/setup-admin-nonce.test.ts
Normal file
265
packages/core/tests/integration/astro/setup-admin-nonce.test.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* POST /_emdash/api/setup/admin mints a per-session nonce, sets it as an
|
||||
* HttpOnly cookie scoped to /_emdash/, and stores it inside
|
||||
* `emdash:setup_state`. POST /_emdash/api/setup/admin/verify must then
|
||||
* present the same cookie value.
|
||||
*
|
||||
* Without this binding, an unauthenticated attacker could call
|
||||
* /setup/admin during the setup window and overwrite the legitimate
|
||||
* admin's email; when the admin then completes passkey verification,
|
||||
* the user account would be created with the attacker's address.
|
||||
*/
|
||||
|
||||
import type { APIContext, AstroCookies } from "astro";
|
||||
import type { Kysely } from "kysely";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { POST as postAdminVerify } from "../../../src/astro/routes/api/setup/admin-verify.js";
|
||||
import { POST as postAdmin } from "../../../src/astro/routes/api/setup/admin.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";
|
||||
|
||||
interface CookieRecord {
|
||||
value: string;
|
||||
options: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface CookieJar {
|
||||
jar: Map<string, CookieRecord>;
|
||||
cookies: AstroCookies;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal in-memory implementation of Astro's `AstroCookies`. Tests
|
||||
* compose two contexts (admin, verify) and carry cookies between them.
|
||||
*/
|
||||
function createCookieJar(initial: Record<string, string> = {}): CookieJar {
|
||||
const jar = new Map<string, CookieRecord>();
|
||||
for (const [name, value] of Object.entries(initial)) {
|
||||
jar.set(name, { value, options: {} });
|
||||
}
|
||||
|
||||
const cookies = {
|
||||
get(name: string) {
|
||||
const record = jar.get(name);
|
||||
if (!record) return undefined;
|
||||
return { value: record.value };
|
||||
},
|
||||
set(name: string, value: string, options: Record<string, unknown> = {}) {
|
||||
jar.set(name, { value, options });
|
||||
},
|
||||
delete(name: string) {
|
||||
jar.delete(name);
|
||||
},
|
||||
has(name: string) {
|
||||
return jar.has(name);
|
||||
},
|
||||
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- minimal stub
|
||||
} as unknown as AstroCookies;
|
||||
|
||||
return { jar, cookies };
|
||||
}
|
||||
|
||||
function buildAdminRequest(body: unknown): Request {
|
||||
return new Request("http://localhost/_emdash/api/setup/admin", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function buildVerifyRequest(body: unknown): Request {
|
||||
return new Request("http://localhost/_emdash/api/setup/admin/verify", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function buildContext(db: Kysely<Database>, request: Request, cookies: AstroCookies): APIContext {
|
||||
return {
|
||||
params: {},
|
||||
url: new URL(request.url),
|
||||
request,
|
||||
cookies,
|
||||
locals: {
|
||||
emdash: {
|
||||
db,
|
||||
config: {},
|
||||
storage: undefined,
|
||||
},
|
||||
},
|
||||
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- minimal stub
|
||||
} as unknown as APIContext;
|
||||
}
|
||||
|
||||
const adminBody = { email: "real@admin.example", name: "Real Admin" };
|
||||
const attackerBody = { email: "attacker@evil.example", name: "Attacker" };
|
||||
|
||||
// A bogus passkey credential — verify will fail at the WebAuthn step,
|
||||
// but only AFTER the nonce check. We're asserting on the nonce gate, not
|
||||
// the eventual passkey result.
|
||||
const bogusCredential = {
|
||||
credential: {
|
||||
id: "AA",
|
||||
rawId: "AA",
|
||||
type: "public-key" as const,
|
||||
response: {
|
||||
clientDataJSON: "AA",
|
||||
attestationObject: "AA",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe("POST /setup/admin — session nonce binding", () => {
|
||||
let db: Kysely<Database>;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await setupTestDatabase();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownTestDatabase(db);
|
||||
});
|
||||
|
||||
it("sets a HttpOnly nonce cookie on the response and stores it with setup state", async () => {
|
||||
const { jar, cookies } = createCookieJar();
|
||||
|
||||
const res = await postAdmin(buildContext(db, buildAdminRequest(adminBody), cookies));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const cookie = jar.get("emdash_setup_nonce");
|
||||
expect(cookie).toBeDefined();
|
||||
// 32 bytes base64url-encoded with no padding = 43 chars. Lock the
|
||||
// shape so accidental entropy changes trip this test.
|
||||
expect(cookie!.value).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
||||
expect(cookie!.options.httpOnly).toBe(true);
|
||||
// The route sets sameSite: "strict" deliberately — this is the
|
||||
// property that prevents cross-site submission of the cookie.
|
||||
// Allowing "lax" here would silently accept a regression.
|
||||
expect(cookie!.options.sameSite).toBe("strict");
|
||||
expect(cookie!.options.path).toBe("/_emdash/");
|
||||
|
||||
const options = new OptionsRepository(db);
|
||||
const setupState = await options.get<{ email: string; nonce: string }>("emdash:setup_state");
|
||||
expect(setupState).toBeDefined();
|
||||
expect(setupState?.email).toBe("real@admin.example");
|
||||
expect(setupState?.nonce).toBe(cookie!.value);
|
||||
});
|
||||
|
||||
it("sets Secure on the nonce cookie when the public origin is HTTPS, even if the internal request URL is HTTP", async () => {
|
||||
// Simulates a TLS-terminating reverse proxy: browser speaks
|
||||
// https:// to the proxy, proxy speaks http:// to the app. The
|
||||
// cookie must still be marked Secure so it's never sent over a
|
||||
// plain-text channel on the public side.
|
||||
const { jar, cookies } = createCookieJar();
|
||||
const request = buildAdminRequest(adminBody);
|
||||
const ctx = buildContext(db, request, cookies);
|
||||
// Force the "internal" view to be HTTP…
|
||||
(ctx as { url: URL }).url = new URL("http://internal.localhost/_emdash/api/setup/admin");
|
||||
// …while config.siteUrl declares the public HTTPS origin.
|
||||
(ctx.locals as { emdash: { config: { siteUrl: string } } }).emdash.config = {
|
||||
siteUrl: "https://public.example.com",
|
||||
};
|
||||
|
||||
const res = await postAdmin(ctx);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const cookie = jar.get("emdash_setup_nonce");
|
||||
expect(cookie).toBeDefined();
|
||||
expect(cookie!.options.secure).toBe(true);
|
||||
});
|
||||
|
||||
it("omits Secure on the nonce cookie when the public origin is HTTP (local dev)", async () => {
|
||||
// Mirror of the test above: a plain http://localhost deployment
|
||||
// must not set Secure (Chromium would drop the cookie entirely).
|
||||
const { jar, cookies } = createCookieJar();
|
||||
const res = await postAdmin(buildContext(db, buildAdminRequest(adminBody), cookies));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const cookie = jar.get("emdash_setup_nonce");
|
||||
expect(cookie).toBeDefined();
|
||||
expect(cookie!.options.secure).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects /admin/verify when no nonce cookie is present", async () => {
|
||||
// Legitimate admin call mints the nonce.
|
||||
const { cookies: adminCookies } = createCookieJar();
|
||||
const adminRes = await postAdmin(buildContext(db, buildAdminRequest(adminBody), adminCookies));
|
||||
expect(adminRes.status).toBe(200);
|
||||
|
||||
// Attacker calls verify without the cookie.
|
||||
const { cookies: noCookies } = createCookieJar();
|
||||
const verifyRes = await postAdminVerify(
|
||||
buildContext(db, buildVerifyRequest(bogusCredential), noCookies),
|
||||
);
|
||||
expect(verifyRes.status).toBe(400);
|
||||
const body = (await verifyRes.json()) as { error?: { code?: string } };
|
||||
expect(body.error?.code).toBe("INVALID_STATE");
|
||||
});
|
||||
|
||||
it("rejects /admin/verify when the nonce cookie does not match the stored nonce", async () => {
|
||||
const { cookies: adminCookies } = createCookieJar();
|
||||
const adminRes = await postAdmin(buildContext(db, buildAdminRequest(adminBody), adminCookies));
|
||||
expect(adminRes.status).toBe(200);
|
||||
|
||||
// Attacker presents a forged cookie with a guessed value.
|
||||
const { cookies: attackerCookies } = createCookieJar({
|
||||
emdash_setup_nonce: "obviously-wrong-value",
|
||||
});
|
||||
const verifyRes = await postAdminVerify(
|
||||
buildContext(db, buildVerifyRequest(bogusCredential), attackerCookies),
|
||||
);
|
||||
expect(verifyRes.status).toBe(400);
|
||||
const body = (await verifyRes.json()) as { error?: { code?: string } };
|
||||
expect(body.error?.code).toBe("INVALID_STATE");
|
||||
});
|
||||
|
||||
it("blocks the email-hijack attack: attacker overwrites setup_state but cannot complete verify", async () => {
|
||||
// 1. Legitimate admin starts setup.
|
||||
const { jar: adminJar, cookies: adminCookies } = createCookieJar();
|
||||
const firstRes = await postAdmin(buildContext(db, buildAdminRequest(adminBody), adminCookies));
|
||||
expect(firstRes.status).toBe(200);
|
||||
const adminNonce = adminJar.get("emdash_setup_nonce")!.value;
|
||||
|
||||
// 2. Attacker (different browser, no cookie) calls /setup/admin to
|
||||
// overwrite the email. With the fix this also rotates the nonce,
|
||||
// invalidating the legitimate admin's session.
|
||||
const { jar: attackerJar, cookies: attackerCookies } = createCookieJar();
|
||||
const attackerRes = await postAdmin(
|
||||
buildContext(db, buildAdminRequest(attackerBody), attackerCookies),
|
||||
);
|
||||
expect(attackerRes.status).toBe(200);
|
||||
const attackerNonce = attackerJar.get("emdash_setup_nonce")!.value;
|
||||
expect(attackerNonce).not.toBe(adminNonce);
|
||||
|
||||
// 3. Legitimate admin completes verify with their original cookie.
|
||||
// This must NOT succeed, because the stored nonce has rotated.
|
||||
const verifyRes = await postAdminVerify(
|
||||
buildContext(db, buildVerifyRequest(bogusCredential), adminCookies),
|
||||
);
|
||||
expect(verifyRes.status).toBe(400);
|
||||
const body = (await verifyRes.json()) as { error?: { code?: string } };
|
||||
expect(body.error?.code).toBe("INVALID_STATE");
|
||||
});
|
||||
|
||||
it("allows a legitimate admin to retry /setup/admin and reuse the new cookie", async () => {
|
||||
// First call mints nonce A.
|
||||
const { jar, cookies } = createCookieJar();
|
||||
const first = await postAdmin(buildContext(db, buildAdminRequest(adminBody), cookies));
|
||||
expect(first.status).toBe(200);
|
||||
const nonceA = jar.get("emdash_setup_nonce")!.value;
|
||||
|
||||
// Same admin retries (e.g. corrected typo). Nonce rotates, cookie
|
||||
// updates in the same jar — they continue with the new value.
|
||||
const second = await postAdmin(buildContext(db, buildAdminRequest(adminBody), cookies));
|
||||
expect(second.status).toBe(200);
|
||||
const nonceB = jar.get("emdash_setup_nonce")!.value;
|
||||
expect(nonceB).not.toBe(nonceA);
|
||||
|
||||
const options = new OptionsRepository(db);
|
||||
const setupState = await options.get<{ nonce: string }>("emdash:setup_state");
|
||||
expect(setupState?.nonce).toBe(nonceB);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* POST /_emdash/api/setup writes `emdash:site_url` once. Subsequent calls
|
||||
* to the setup endpoint (during the multi-step wizard, before
|
||||
* `emdash:setup_complete` is true) must not overwrite it.
|
||||
*
|
||||
* Without this, a spoofed Host header on any follow-up POST during the
|
||||
* setup window could poison the site URL used in auth emails.
|
||||
*
|
||||
* The primary defence (config.siteUrl / EMDASH_SITE_URL env) was added
|
||||
* earlier; this is the last-line lock for deployments that rely on the
|
||||
* request-origin fallback.
|
||||
*/
|
||||
|
||||
import type { APIContext } from "astro";
|
||||
import type { Kysely } from "kysely";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Stub the seed virtual module that loadSeed() imports at runtime. Without
|
||||
// this the setup route errors out before reaching the site_url write.
|
||||
vi.mock("virtual:emdash/seed", () => ({
|
||||
seed: {
|
||||
version: "1",
|
||||
settings: {},
|
||||
collections: [],
|
||||
},
|
||||
userSeed: null,
|
||||
}));
|
||||
|
||||
import { POST as postSetup } from "../../../src/astro/routes/api/setup/index.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";
|
||||
|
||||
function buildRequest(host: string, body: unknown): Request {
|
||||
return new Request(`http://${host}/_emdash/api/setup`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
host,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function buildContext(db: Kysely<Database>, request: Request): APIContext {
|
||||
return {
|
||||
params: {},
|
||||
url: new URL(request.url),
|
||||
request,
|
||||
locals: {
|
||||
emdash: {
|
||||
db,
|
||||
config: {},
|
||||
storage: undefined,
|
||||
},
|
||||
},
|
||||
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- minimal stub
|
||||
} as unknown as APIContext;
|
||||
}
|
||||
|
||||
describe("POST /setup — site_url write-once lock", () => {
|
||||
let db: Kysely<Database>;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await setupTestDatabase();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownTestDatabase(db);
|
||||
});
|
||||
|
||||
it("stores site_url from the first request", async () => {
|
||||
const res = await postSetup(
|
||||
buildContext(
|
||||
db,
|
||||
buildRequest("real-site.example", { title: "My Site", includeContent: false }),
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const options = new OptionsRepository(db);
|
||||
expect(await options.get("emdash:site_url")).toBe("http://real-site.example");
|
||||
});
|
||||
|
||||
it("does not overwrite site_url when a later setup call arrives with a spoofed Host", async () => {
|
||||
// First call — legitimate admin on the real host.
|
||||
const first = await postSetup(
|
||||
buildContext(
|
||||
db,
|
||||
buildRequest("real-site.example", { title: "My Site", includeContent: false }),
|
||||
),
|
||||
);
|
||||
expect(first.status).toBe(200);
|
||||
|
||||
// Attacker sends a second setup call with a spoofed Host header
|
||||
// before the admin has completed the final step. Without the lock,
|
||||
// the stored site_url would be overwritten.
|
||||
const second = await postSetup(
|
||||
buildContext(
|
||||
db,
|
||||
buildRequest("attacker.example", { title: "My Site", includeContent: false }),
|
||||
),
|
||||
);
|
||||
expect(second.status).toBe(200);
|
||||
|
||||
const options = new OptionsRepository(db);
|
||||
expect(await options.get("emdash:site_url")).toBe("http://real-site.example");
|
||||
});
|
||||
|
||||
it("is atomic under concurrent setup POSTs with different Hosts", async () => {
|
||||
// Two concurrent callers observe an empty site_url and race to
|
||||
// write. Without DB-level write-once semantics, the last writer
|
||||
// wins and the legitimate host can still be replaced.
|
||||
const [a, b] = await Promise.all([
|
||||
postSetup(
|
||||
buildContext(
|
||||
db,
|
||||
buildRequest("real-site.example", { title: "My Site", includeContent: false }),
|
||||
),
|
||||
),
|
||||
postSetup(
|
||||
buildContext(
|
||||
db,
|
||||
buildRequest("attacker.example", { title: "My Site", includeContent: false }),
|
||||
),
|
||||
),
|
||||
]);
|
||||
expect(a.status).toBe(200);
|
||||
expect(b.status).toBe(200);
|
||||
|
||||
const options = new OptionsRepository(db);
|
||||
const stored = await options.get("emdash:site_url");
|
||||
// Whichever call won the race must now stick — a third caller must
|
||||
// not be able to overwrite it.
|
||||
expect(["http://real-site.example", "http://attacker.example"]).toContain(stored);
|
||||
|
||||
const third = await postSetup(
|
||||
buildContext(db, buildRequest("other.example", { title: "My Site", includeContent: false })),
|
||||
);
|
||||
expect(third.status).toBe(200);
|
||||
expect(await options.get("emdash:site_url")).toBe(stored);
|
||||
});
|
||||
|
||||
it("does not overwrite a legitimately-stored empty string", async () => {
|
||||
// Defence-in-depth: if site_url was somehow stored as "" (e.g.
|
||||
// manual DB edit, legacy data, test fixture), the guard must treat
|
||||
// it as present, not missing.
|
||||
const options = new OptionsRepository(db);
|
||||
await options.set("emdash:site_url", "");
|
||||
|
||||
const res = await postSetup(
|
||||
buildContext(
|
||||
db,
|
||||
buildRequest("attacker.example", { title: "My Site", includeContent: false }),
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(await options.get("emdash:site_url")).toBe("");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user