Files
wehub-resource-sync b3a7f98e5a
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:23:53 +08:00

162 lines
4.9 KiB
TypeScript

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