/** Pure helpers for site host labels, cookies and outbound checks; no Worker bindings so they unit-test under bun. */ import { isNonPublicIpLiteral } from "../net/resolve-guard"; const LABEL_MAX = 63; const HASH_SUFFIX = 7; const LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; const encoder = new TextEncoder(); /** DNS-safe slug with no `--`, so `--` can separate the branch, repo and handle parts of a label. */ export function slug(value: string): string { return value .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/-+/g, "-") .replace(/^-|-$/g, ""); } function fnv(value: string): string { let hash = 0x811c9dc5; for (const char of value) { hash ^= char.codePointAt(0) ?? 0; hash = Math.imul(hash, 0x01000193) >>> 0; } return hash.toString(36).padStart(HASH_SUFFIX, "0").slice(-HASH_SUFFIX); } /** Truncates an over-long leading part and appends a hash of the full value so distinct inputs stay distinct. */ function fit(head: string, tail: string, full: string): string { const joined = tail ? `${head}--${tail}` : head; if (joined.length <= LABEL_MAX) return joined; const room = LABEL_MAX - (tail ? tail.length + 2 : 0) - HASH_SUFFIX - 1; // An over-long tail keeps three parts under the reserved `scratch` handle, which no real site label uses. if (room < 1) return `${fnv(full)}--${fnv(tail)}--scratch`; const cut = head.slice(0, room).replace(/-+$/, ""); return `${cut}-${fnv(full)}${tail ? `--${tail}` : ""}`; } export function isUserSiteRepo(handle: string, repo: string, domain: string | null): boolean { const lower = repo.toLowerCase(); return lower === handle.toLowerCase() || (domain !== null && lower === `${handle.toLowerCase()}.${domain}`); } /** `` for user sites, `--` for project sites. */ export function siteLabel(handle: string, repo: string, domain: string | null): string { const owner = slug(handle) || "x"; if (isUserSiteRepo(handle, repo, domain)) return owner.slice(0, LABEL_MAX); return fit(slug(repo) || "site", owner, `${repo}/${handle}`); } /** Leading parts owned by pull request and commit aliases; a branch slugging to one is prefixed with `branch-`. */ const RESERVED_HEAD = /^(?:pr-\d+|[0-9a-f]{7})$/; function previewBase(site: string): string { return site.includes("--") ? site : `${site}--${site}`; } /** `--`; a user-site label gains its handle twice so it can't collide with a project site. */ export function previewLabel(site: string, branch: string): string { const head = slug(branch) || "branch"; return fit(RESERVED_HEAD.test(head) ? `branch-${head}` : head, previewBase(site), `${branch}@${site}`); } /** `pr---`: follows the latest preview of pull request N's head branch. */ export function pullLabel(site: string, number: number): string { return fit(`pr-${number}`, previewBase(site), `pr#${number}@${site}`); } /** `--`: an immutable preview of one commit. */ export function commitLabel(site: string, sha: string): string { const short = sha.toLowerCase().slice(0, 7); return fit(short, previewBase(site), `${short}#commit@${site}`); } export function scratchLabel(id: string): string { return `${slug(id)}--scratch`; } export function claimedLabel(id: string, handle: string): string { return fit(slug(id), slug(handle) || "x", `${id}/${handle}`); } export function isValidLabel(label: string): boolean { return LABEL.test(label); } /** The single label of `host` under `domain`, or null when the host isn't directly below it. */ export function hostLabel(host: string, domain: string): string | null { const lower = host.toLowerCase(); if (!lower.endsWith(`.${domain}`)) return null; const label = lower.slice(0, -domain.length - 1); return isValidLabel(label) ? label : null; } /** Second-level labels that form public suffixes under country TLDs (`co.uk`, `com.au`, ...). */ const COUNTRY_SECOND_LEVELS = new Set(["co", "com", "net", "org", "gov", "edu", "ac", "ne", "or", "go", "gv"]); const LOOPBACK = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]); /** Approximate eTLD+1 without bundling the PSL; `x.localhost` counts as its own site. */ export function registrableDomain(hostname: string): string { const labels = hostname.toLowerCase().split("."); const [secondLevel, tld] = labels.slice(-2); const multiPart = labels.length > 2 && tld?.length === 2 && COUNTRY_SECOND_LEVELS.has(secondLevel ?? ""); return labels.slice(multiPart ? -3 : -2).join("."); } /** * Validates the configured sites domain: it must not equal, contain or sit inside the forge host, and must be a * different registrable domain, so user content can't read or toss forge cookies. */ export function validSitesDomain(raw: string | undefined, forgeHost: string): string | null { const domain = (raw ?? "").trim().toLowerCase().replace(/\.$/, ""); if (!domain || !/^[a-z0-9.-]+$/.test(domain)) return null; const forge = forgeHost.toLowerCase(); if (forge === domain || forge.endsWith(`.${domain}`)) return null; if (LOOPBACK.has(forge)) return domain.endsWith(".localhost") || !domain.includes("localhost") ? domain : null; if (domain.endsWith(`.${forge}`)) return null; if (registrableDomain(domain) === registrableDomain(forge)) return null; return domain; } const PRIVATE_V4 = [ /^0\./, /^10\./, /^127\./, /^169\.254\./, /^172\.(1[6-9]|2\d|3[01])\./, /^192\.168\./, /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./, /^(22[4-9]|2[3-5]\d)\./, ]; /** * Hosts a site's outbound fetch may never reach: the forge, other sites, and non-public addresses. IP literals go * through the shared parser in `net/resolve-guard`, which understands every IPv6 form (long-form IPv4-mapped, * 6to4, NAT64, IETF space) and the full special-purpose IPv4 registry (CI-A9). */ export function isBlockedOutbound(url: URL, blocked: string[]): boolean { if (url.protocol !== "https:" && url.protocol !== "http:") return true; const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, ""); if (!host || LOOPBACK.has(host) || host.endsWith(".localhost")) return true; const literal = isNonPublicIpLiteral(url.hostname); if (literal !== null) return literal; if (host.endsWith(".internal") || host.endsWith(".local") || !host.includes(".")) return true; if (PRIVATE_V4.some((pattern) => pattern.test(host))) return true; return blocked.some((entry) => host === entry || host.endsWith(`.${entry}`)); } async function hmacKey(secret: string): Promise { return crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); } async function mac(secret: string, body: string): Promise { const signature = new Uint8Array(await crypto.subtle.sign("HMAC", await hmacKey(secret), encoder.encode(body))); return btoa(String.fromCharCode(...signature)) .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=+$/, ""); } function safeEqual(left: string, right: string): boolean { if (left.length !== right.length) return false; let difference = 0; for (let index = 0; index < left.length; index++) difference |= left.charCodeAt(index) ^ right.charCodeAt(index); return difference === 0; } export type AccessClaims = { siteId: string; host: string; version: number; subject: string; expiresAt: number }; const TOKEN_PARTS = 5; const SUBJECT = /^[A-Za-z0-9@._+-]{1,200}$/; /** `v1....`, where the MAC also covers the host and the site's access version. */ export async function signAccess(secret: string, claims: AccessClaims): Promise { const subject = SUBJECT.test(claims.subject) ? claims.subject : "visitor"; const body = `v1.${claims.siteId}.${claims.expiresAt}.${subject}`; return `${body}.${await mac(secret, `${body}|${claims.host}|${claims.version}`)}`; } export async function verifyAccess( secret: string, token: string, expected: { siteId: string; host: string; version: number; now: number }, ): Promise { const parts = token.split("."); if (parts.length < TOKEN_PARTS || parts[0] !== "v1") return null; const signature = parts.pop() ?? ""; const [, siteId, expiresAt] = parts; const subject = parts.slice(3).join("."); if (siteId !== expected.siteId || !(Number(expiresAt) > expected.now)) return null; const body = parts.join("."); const wanted = await mac(secret, `${body}|${expected.host}|${expected.version}`); return safeEqual(signature, wanted) ? subject : null; } const MODULE_EXTENSIONS = /\.(m?js|cjs|wasm|json|txt)$/; /** Splits `_worker.js` (file or folder) out of a deployment's files; worker modules are never served statically. */ export function splitWorker(files: Record): { assets: Record; worker: Record | null; } { const assets: Record = {}; const worker: Record = {}; for (const [path, value] of Object.entries(files)) { if (path === "_worker.js") worker["index.js"] = value; else if (path.startsWith("_worker.js/")) { const inner = path.slice("_worker.js/".length); if (MODULE_EXTENSIONS.test(inner)) worker[inner] = value; } else assets[path] = value; } return { assets, worker: worker["index.js"] ? worker : null }; } const COOKIE_PREFIXES = ["__Host-gitfor_", "gitfor_"]; /** Drops dispatcher-owned cookies from a Cookie header before it reaches user code. */ export function stripReservedCookies(header: string | null): string | null { if (!header) return null; const kept = header .split(";") .map((part) => part.trim()) .filter((part) => part && !COOKIE_PREFIXES.some((prefix) => part.startsWith(prefix))); return kept.length ? kept.join("; ") : null; } export function isReservedSetCookie(value: string): boolean { const name = value.split("=", 1)[0].trim(); return COOKIE_PREFIXES.some((prefix) => name.startsWith(prefix)); } export function emailAllowed(email: string, allowList: string): boolean { const lower = email.toLowerCase(); return allowList .split(/[\s,]+/) .map((entry) => entry.trim().toLowerCase()) .filter(Boolean) .some((entry) => (entry.startsWith("@") ? lower.endsWith(entry) : lower === entry)); } /** Only same-origin paths; resolving against a dummy origin defeats `//`, `/\` and tab or newline tricks. */ export function safeNext(raw: string | null): string { if (!raw || !raw.startsWith("/") || /[\u0000-\u001f\\]/.test(raw)) return "/"; const base = "https://site.invalid"; try { const url = new URL(raw, base); if (url.origin !== base || url.pathname.startsWith("//")) return "/"; return `${url.pathname}${url.search}${url.hash}`; } catch { return "/"; } } /** * Makes a user `Set-Cookie` host-only: `Domain=` is stripped and any parent or foreign domain scope * drops the cookie, so one site can't toss cookies onto its siblings (defense until the PSL entry ships). */ export function sanitizeSetCookie(value: string, host: string, shared = true): string | null { if (isReservedSetCookie(value)) return null; // A verified custom domain may also scope cookies to its registrable domain; nothing broader or foreign. const allowed = shared ? [host.toLowerCase()] : [host.toLowerCase(), registrableDomain(host)]; const [pair, ...attributes] = value.split(";"); const kept: string[] = []; for (const attribute of attributes) { const [rawName, ...rest] = attribute.split("="); if (rawName.trim().toLowerCase() !== "domain") { kept.push(attribute); continue; } const domain = rest.join("=").trim().toLowerCase().replace(/^\./, ""); if (domain && !allowed.includes(domain)) return null; if (domain && domain !== host.toLowerCase()) kept.push(attribute); } return [pair, ...kept].join(";"); }