Files
kunthawat 2d1be52177 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
2026-05-03 10:44:54 +07:00

266 lines
9.9 KiB
TypeScript

/**
* 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);
});
});