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:
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user