harden the marketing site: gate demo overrides, rate-limit + redact waitlist
- gerber-demo/boot.js honors the base/cdn/tag script-source overrides only on a loopback hostname, so a shipped page ignores them. - /api/waitlist gains a best-effort per-IP/per-address rate limit and masks the submitter address in the no-key log line. Adds vitest to the site: test/boot-overrides.test.ts (happy-dom) and test/waitlist.test.ts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
21e6f4c2d5
commit
74afb7b726
7 changed files with 690 additions and 18 deletions
65
site/test/boot-overrides.test.ts
Normal file
65
site/test/boot-overrides.test.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
// @vitest-environment happy-dom
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* boot.js honors the ?base= / ?cdn= / ?tag= script-source overrides only on a
|
||||
* loopback hostname; a shipped page must ignore them. Black-box test: run the
|
||||
* REAL boot.js in a DOM at a given URL and check whether an external-origin
|
||||
* <script> gets injected.
|
||||
*/
|
||||
const bootSrc = fs.readFileSync(
|
||||
path.join(path.dirname(fileURLToPath(import.meta.url)), "../public/gerber-demo/boot.js"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const EXTERNAL = "https://external.example/x";
|
||||
|
||||
async function runBootAt(url: string): Promise<void> {
|
||||
const hd = (window as unknown as {
|
||||
happyDOM: { setURL(u: string): void; settings: { disableJavaScriptFileLoading: boolean } };
|
||||
}).happyDOM;
|
||||
// We assert on the injected <script src>, not on it actually loading — keep
|
||||
// happy-dom from fetching the URL over the network.
|
||||
hd.settings.disableJavaScriptFileLoading = true;
|
||||
hd.setURL(url);
|
||||
// The status scaffold boot.js's helpers look up (all null-guarded anyway).
|
||||
document.body.innerHTML =
|
||||
'<div id="status"><span id="status-text"></span><div id="progress-bar"></div></div>' +
|
||||
'<div id="main-window"></div>';
|
||||
// The default path fetches a CDN manifest — let it fail; we only assert on
|
||||
// script injection, never on a successful boot.
|
||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("no network in test")));
|
||||
(0, eval)(bootSrc);
|
||||
await new Promise((r) => setTimeout(r, 20)); // let resolveBase().then(boot) settle
|
||||
}
|
||||
|
||||
function externalScriptsInjected(): string[] {
|
||||
return Array.from(document.querySelectorAll("script[src]"))
|
||||
.map((s) => s.getAttribute("src") || "")
|
||||
.filter((src) => /^https?:\/\//i.test(src));
|
||||
}
|
||||
|
||||
describe("gerber-demo boot.js override gate", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
document.body.innerHTML = "";
|
||||
document.querySelectorAll("script[src]").forEach((s) => s.remove());
|
||||
});
|
||||
|
||||
it("ignores ?base= on a production origin — no external script injected", async () => {
|
||||
await runBootAt(
|
||||
"https://www.pcbjam.com/gerber-demo/index.html?base=" + encodeURIComponent(EXTERNAL),
|
||||
);
|
||||
expect(externalScriptsInjected().some((s) => s.includes("external.example"))).toBe(false);
|
||||
});
|
||||
|
||||
it("still honors ?base= on localhost — the dev override is preserved", async () => {
|
||||
await runBootAt(
|
||||
"http://localhost:4321/gerber-demo/index.html?base=" + encodeURIComponent(EXTERNAL),
|
||||
);
|
||||
expect(externalScriptsInjected().some((s) => s.includes("external.example"))).toBe(true);
|
||||
});
|
||||
});
|
||||
53
site/test/waitlist.test.ts
Normal file
53
site/test/waitlist.test.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* Waitlist route hardening: per-key rate limiting, and no raw address in logs on
|
||||
* the no-key path.
|
||||
*
|
||||
* The route imports typed Astro env + the Resend SDK; both are stubbed so the
|
||||
* handler runs in plain node. RESEND_API_KEY is left undefined so the no-key
|
||||
* branch (the one that logs) is exercised.
|
||||
*/
|
||||
vi.mock("astro:env/server", () => ({
|
||||
RESEND_API_KEY: undefined,
|
||||
RESEND_SEGMENT_ID: undefined,
|
||||
WAITLIST_FROM_EMAIL: "hello@pcbjam.com",
|
||||
WAITLIST_ALLOWED_ORIGINS: "",
|
||||
}));
|
||||
vi.mock("resend", () => ({
|
||||
Resend: class {
|
||||
emails = { send: async () => ({}) };
|
||||
contacts = { create: async () => ({}) };
|
||||
},
|
||||
}));
|
||||
|
||||
const { POST } = await import("../src/pages/api/waitlist.ts");
|
||||
|
||||
function post(email: string, ip: string): Promise<Response> {
|
||||
const request = new Request("https://www.pcbjam.com/api/waitlist", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", "x-forwarded-for": ip },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
// Astro passes a full context; the handler only reads `request`.
|
||||
return POST({ request } as unknown as Parameters<typeof POST>[0]) as Promise<Response>;
|
||||
}
|
||||
|
||||
describe("waitlist route hardening", () => {
|
||||
it("rate-limits a burst from one IP (429 after the per-IP cap)", async () => {
|
||||
const ip = "203.0.113.9";
|
||||
const statuses: number[] = [];
|
||||
for (let i = 0; i < 7; i++) statuses.push((await post(`u${i}@ex.com`, ip)).status);
|
||||
expect(statuses.filter((s) => s === 200).length).toBe(5); // RATE_MAX_PER_IP
|
||||
expect(statuses.filter((s) => s === 429).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("does not write the raw email to logs in the no-key path", async () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
await post("secret.person@example.com", "198.51.100.7");
|
||||
const logged = warn.mock.calls.map((c) => c.join(" ")).join("\n");
|
||||
expect(logged).not.toContain("secret.person@example.com"); // raw address masked
|
||||
expect(logged).toContain("@example.com"); // domain kept for debugging
|
||||
warn.mockRestore();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue