import { describe, expect, test } from "bun:test"; import { claimedLabel, commitLabel, emailAllowed, hostLabel, isBlockedOutbound, isReservedSetCookie, isValidLabel, safeNext, sanitizeSetCookie, previewLabel, pullLabel, scratchLabel, signAccess, siteLabel, slug, splitWorker, stripReservedCookies, validSitesDomain, verifyAccess, } from "./naming"; import { hashSitePassword, verifySitePassword } from "./password"; const DOMAIN = "gitforstatic.dev"; describe("labels", () => { test("slugs never contain the -- separator", () => { expect(slug("My--Repo__v2.0")).toBe("my-repo-v2-0"); expect(slug("--x--")).toBe("x"); }); test("user, project, preview and scratch labels", () => { expect(siteLabel("Alice", "alice", DOMAIN)).toBe("alice"); expect(siteLabel("alice", "alice.gitforstatic.dev", DOMAIN)).toBe("alice"); expect(siteLabel("alice", "blog", DOMAIN)).toBe("blog--alice"); expect(previewLabel("blog--alice", "feature/Login")).toBe("feature-login--blog--alice"); expect(previewLabel("alice", "dev")).toBe("dev--alice--alice"); expect(scratchLabel("k3x9")).toBe("k3x9--scratch"); expect(claimedLabel("k3x9", "alice")).toBe("k3x9--alice"); }); test("pull request and commit aliases stay single labels and never collide with a branch", () => { expect(pullLabel("blog--alice", 42)).toBe("pr-42--blog--alice"); expect(pullLabel("alice", 3)).toBe("pr-3--alice--alice"); expect(commitLabel("blog--alice", "ABCDEF1234567")).toBe("abcdef1--blog--alice"); expect(previewLabel("blog--alice", "pr-42")).toBe("branch-pr-42--blog--alice"); expect(previewLabel("blog--alice", "abcdef1")).toBe("branch-abcdef1--blog--alice"); expect(previewLabel("blog--alice", "pr-x")).toBe("pr-x--blog--alice"); const long = `${"r".repeat(40)}--${"o".repeat(15)}`; expect(pullLabel(long, 123456).length).toBeLessThanOrEqual(63); expect(isValidLabel(pullLabel(long, 123456))).toBe(true); expect(pullLabel(long, 1)).not.toBe(pullLabel(long, 2)); expect(commitLabel(long, "abcdef1").length).toBeLessThanOrEqual(63); }); test("a user-site preview can't collide with a project site", () => { expect(previewLabel(siteLabel("alice", "alice", DOMAIN), "blog")).not.toBe(siteLabel("alice", "blog", DOMAIN)); }); test("long labels are truncated to a valid, distinct DNS label", () => { const branchA = `${"a".repeat(80)}-one`; const branchB = `${"a".repeat(80)}-two`; const first = previewLabel("blog--alice", branchA); const second = previewLabel("blog--alice", branchB); expect(first.length).toBeLessThanOrEqual(63); expect(isValidLabel(first)).toBe(true); expect(first.endsWith("--blog--alice")).toBe(true); expect(first).not.toBe(second); expect(isValidLabel(siteLabel("x".repeat(39), "r".repeat(100), DOMAIN))).toBe(true); }); test("previews of very long site labels stay valid and keep three parts", () => { const base = siteLabel("h".repeat(39), "r".repeat(100), DOMAIN); const label = previewLabel(base, "main"); expect(isValidLabel(label)).toBe(true); expect(label.split("--")).toHaveLength(3); expect(label).not.toBe(previewLabel(base, "dev")); }); test("hostLabel only accepts one label directly below the domain", () => { expect(hostLabel("Blog--Alice.gitforstatic.dev", DOMAIN)).toBe("blog--alice"); expect(hostLabel("a.b.gitforstatic.dev", DOMAIN)).toBeNull(); expect(hostLabel("gitforstatic.dev", DOMAIN)).toBeNull(); expect(hostLabel("evilgitforstatic.dev", DOMAIN)).toBeNull(); }); }); describe("sites domain", () => { test("must be a different registrable domain from the forge", () => { expect(validSitesDomain("gitforstatic.dev", "gitfor.dev")).toBe("gitforstatic.dev"); expect(validSitesDomain("sites.gitfor.dev", "gitfor.dev")).toBeNull(); expect(validSitesDomain("sites.gitfor.dev", "git.gitfor.dev")).toBeNull(); expect(validSitesDomain("gitfor.dev", "git.gitfor.dev")).toBeNull(); expect(validSitesDomain("a.co.uk", "b.co.uk")).toBe("a.co.uk"); expect(validSitesDomain("", "gitfor.dev")).toBeNull(); }); test("local dev may use *.localhost", () => { expect(validSitesDomain("gitforstatic.localhost", "localhost")).toBe("gitforstatic.localhost"); expect(validSitesDomain("localhost", "localhost")).toBeNull(); }); }); describe("outbound", () => { const blocked = ["gitfor.dev", DOMAIN]; const check = (url: string) => isBlockedOutbound(new URL(url), blocked); test("blocks forge, sites, loopback and private hosts", () => { for (const url of [ "https://gitfor.dev/api", "https://x.gitforstatic.dev/", "http://localhost/", "http://127.0.0.1/", "http://10.1.2.3/", "http://192.168.0.1/", "http://169.254.169.254/latest", "http://[::1]/", "http://[fd00::1]/", "http://[::ffff:127.0.0.1]/", "http://2130706433/", "http://metadata.internal/", "ftp://example.com/", ]) { expect(check(url)).toBe(true); } }); test("blocks long-form and embedded IPv4 IPv6 literals and non-dotted IPv4 forms (CI-A9)", () => { for (const url of [ "http://[0:0:0:0:0:ffff:7f00:1]/", "http://[0000::1]/", "http://[::ffff:7f00:1]/", "http://[64:ff9b::7f00:1]/", "http://[2002:7f00:1::]/", "http://[2001:db8::1]/", "http://[fe80::1]/", "http://0x7f.1/", "http://127.1/", "http://0177.0.0.1/", "http://100.64.0.1/", "http://0.0.0.0/", ]) { expect(check(url)).toBe(true); } }); test("allows public hosts", () => { expect(check("https://api.example.com/v1")).toBe(false); expect(check("http://8.8.8.8/")).toBe(false); expect(check("https://[2606:4700::1111]/")).toBe(false); }); }); describe("access cookies", () => { const secret = "test-secret"; const claims = { siteId: "s1", host: "blog--alice.gitforstatic.dev", version: 1, subject: "alice", expiresAt: 0 }; test("round-trips and is bound to the host, site and access version", async () => { const now = 1000; const token = await signAccess(secret, { ...claims, expiresAt: now + 60_000 }); const expected = { siteId: "s1", host: claims.host, version: 1, now }; expect(await verifyAccess(secret, token, expected)).toBe("alice"); expect(await verifyAccess(secret, token, { ...expected, host: "other--alice.gitforstatic.dev" })).toBeNull(); expect(await verifyAccess(secret, token, { ...expected, siteId: "s2" })).toBeNull(); expect(await verifyAccess(secret, token, { ...expected, version: 2 })).toBeNull(); expect(await verifyAccess("other", token, expected)).toBeNull(); expect(await verifyAccess(secret, token, { ...expected, now: now + 120_000 })).toBeNull(); }); test("rejects tampering", async () => { const token = await signAccess(secret, { ...claims, expiresAt: Date.now() + 60_000 }); const forged = token.replace(".alice.", ".mallory."); const expected = { siteId: "s1", host: claims.host, version: 1, now: Date.now() }; expect(await verifyAccess(secret, forged, expected)).toBeNull(); expect(await verifyAccess(secret, "v1.s1.9999999999999.x", expected)).toBeNull(); }); test("subjects with email dots survive", async () => { const token = await signAccess(secret, { ...claims, subject: "a.b@example.com", expiresAt: Date.now() + 60_000 }); const expected = { siteId: "s1", host: claims.host, version: 1, now: Date.now() }; expect(await verifyAccess(secret, token, expected)).toBe("a.b@example.com"); }); }); describe("request sanitizing", () => { test("strips dispatcher cookies before user code", () => { expect(stripReservedCookies("a=1; __Host-gitfor_site=x; gitfor_state=y; b=2")).toBe("a=1; b=2"); expect(stripReservedCookies("gitfor_site=x")).toBeNull(); expect(isReservedSetCookie("__Host-gitfor_site=evil; Path=/")).toBe(true); expect(isReservedSetCookie("theme=dark")).toBe(false); }); test("splits _worker.js modules out of static assets", () => { const single = splitWorker({ "index.html": 1, "_worker.js": 2 }); expect(single.assets).toEqual({ "index.html": 1 }); expect(single.worker).toEqual({ "index.js": 2 }); const folder = splitWorker({ "_worker.js/index.js": 1, "_worker.js/lib.mjs": 2, "_worker.js/README.md": 3, "a.css": 4, }); expect(folder.worker).toEqual({ "index.js": 1, "lib.mjs": 2 }); expect(folder.assets).toEqual({ "a.css": 4 }); expect(splitWorker({ "_worker.js/lib.js": 1 }).worker).toBeNull(); }); test("sign-in redirects stay on the site", () => { expect(safeNext("/docs?a=1#x")).toBe("/docs?a=1#x"); for (const evil of [ "//evil.com", "/\\evil.com", "/\t/evil.com", "/\n/evil.com", "/.//evil.com", "/..//evil.com", "/%2e//evil.com", "https://evil.com", "", null, ]) { expect(safeNext(evil)).toBe("/"); } }); test("user cookies are forced host-only so sites can't toss cookies to siblings", () => { const host = "a--alice.gitforstatic.dev"; expect(sanitizeSetCookie("x=1; Path=/", host)).toBe("x=1; Path=/"); expect(sanitizeSetCookie("x=1; Domain=a--alice.gitforstatic.dev; Path=/", host)).toBe("x=1; Path=/"); expect(sanitizeSetCookie("x=1; domain=.A--alice.gitforstatic.dev", host)).toBe("x=1"); expect(sanitizeSetCookie("x=1; Domain=gitforstatic.dev; Path=/", host)).toBeNull(); expect(sanitizeSetCookie("x=1; Domain=.gitforstatic.dev", host)).toBeNull(); expect(sanitizeSetCookie("x=1; Domain=evil.example", host)).toBeNull(); expect(sanitizeSetCookie("__Host-gitfor_site=forged; Path=/", host)).toBeNull(); expect(sanitizeSetCookie("x=1; Domain=example.com", "www.example.com", false)).toBe("x=1; Domain=example.com"); expect(sanitizeSetCookie("gitfor_state=x; Domain=example.com", "www.example.com", false)).toBeNull(); }); test("email allow lists", () => { expect(emailAllowed("Bob@Example.com", "@example.com")).toBe(true); expect(emailAllowed("bob@evil-example.com", "@example.com")).toBe(false); expect(emailAllowed("a@x.io", "b@x.io, a@x.io")).toBe(true); expect(emailAllowed("a@x.io", "")).toBe(false); }); }); describe("site passwords", () => { test("scrypt hash verifies only the right password", async () => { const hash = await hashSitePassword("correct horse"); expect(hash.startsWith("scrypt$16384$8$1$")).toBe(true); expect(await verifySitePassword("correct horse", hash)).toBe(true); expect(await verifySitePassword("wrong", hash)).toBe(false); expect(await verifySitePassword("correct horse", hash.replace("16384", "2"))).toBe(false); }); });