Route legacy address paths to per-site hostnames, confine control APIs to the apex, issue host-only unlock cookies, and preserve old address-prefixed builds during migration. Add wildcard deployment configuration plus unit and workerd coverage for hostname validation, redirects, cookie isolation, telemetry, and canonical URLs.
- commit
- be8f7ee4301de6f773250dcbf9d5ef8cbaf220e2
- parent
- f0a472666fb42b7086dcd07fef5c30b16f3bc568
- author
- Dami <dami.etoile@gmail.com>
| M | worker/ | 2 | + |
| M | worker/ | 42 | +++++++- |
| M | worker/ | 40 | ++++++-- |
| M | worker/ | 25 | ++++- |
| M | worker/ | 82 | ++++++++------- |
| M | worker/ | 150 | +++++++++++++++++++-------- |
| A | worker/ | 35 | ++++++ |
| M | worker/ | 165 | +++++++++++++++++++++--------- |
| M | worker/ | 1 | + |
| M | worker/ | 17 | +++ |
| M | worker/ | 23 | ++++- |
| M | worker/ | 110 | ++++++++++++-------- |
| M | worker/ | 2 | + |
| M | worker/ | 7 | +- |
| M | worker/ | 6 | +- |
@@ -16,6 +16,44 @@ export function err(message: string, status: number): Response {
1616 return json({ error: message }, status, { "cache-control": "no-store" });1717 }1818 19-export function siteUrl(env: Env, address: string): string {20- return `${env.PUBLIC_BASE}/${address}`;19+function configuredSiteDomain(env: Env): string {20+ const domain = env.SITE_DOMAIN.toLowerCase();21+ if (22+ domain.length > 253 || domain.includes("..") ||23+ !/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/.test(domain)24+ ) {25+ throw new Error("invalid SITE_DOMAIN");26+ }27+ return domain;28+}29+30+export function isControlOrigin(env: Env, url: URL): boolean {31+ const publicUrl = new URL(env.PUBLIC_BASE);32+ return url.hostname === publicUrl.hostname &&33+ (url.port === "" || url.port === publicUrl.port);34+}35+36+export function siteAddressFromUrl(env: Env, url: URL): string | null {37+ const publicUrl = new URL(env.PUBLIC_BASE);38+ if (url.port !== "" && url.port !== publicUrl.port) {39+ return null;40+ }41+ const suffix = `.${configuredSiteDomain(env)}`;42+ if (!url.hostname.endsWith(suffix)) return null;43+ const address = url.hostname.slice(0, -suffix.length);44+ return /^[0-9a-f]{4}$/.test(address) ? address : null;45+}46+47+export function siteUrl(48+ env: Env,49+ address: string,50+ pathname = "/",51+ search = "",52+): string {53+ const url = new URL(env.PUBLIC_BASE);54+ url.hostname = `${address}.${configuredSiteDomain(env)}`;55+ url.pathname = pathname;56+ url.search = search;57+ url.hash = "";58+ return url.toString();2159 }@@ -1,7 +1,7 @@
11 import { api } from "./api.ts";22 import { checkBearer } from "./auth.ts";33 import type { Env } from "./env.ts";4-import { err } from "./env.ts";4+import { err, isControlOrigin, siteAddressFromUrl } from "./env.ts";55 import { runGc } from "./gc.ts";66 import { logSecurityRequest, recordEnumerationProbe } from "./observability.ts";77 import {@@ -30,23 +30,42 @@ export default {
3030 ctx: ExecutionContext,3131 ): Promise<Response> {3232 const url = new URL(req.url);33+ const controlOrigin = isControlOrigin(env, url);34+ const siteAddress = controlOrigin ? null : siteAddressFromUrl(env, url);3335 const finish = (response: Response): Response => {34- ctx.waitUntil(logSecurityRequest(req, env, url, response));35- ctx.waitUntil(recordEnumerationProbe(req, env, url, response));36+ ctx.waitUntil(logSecurityRequest(req, env, url, response, siteAddress));37+ ctx.waitUntil(38+ recordEnumerationProbe(req, env, url, response, siteAddress),39+ );3640 return response;3741 };3842 39- if (url.pathname === "/api" || url.pathname.startsWith("/api/")) {43+ // The wildcard route must never widen the management surface. Only the44+ // configured control origin may dispatch API or notification requests.45+ if (!controlOrigin && siteAddress === null) {46+ return finish(err("not found", 404));47+ }48+49+ if (50+ controlOrigin &&51+ (url.pathname === "/api" || url.pathname.startsWith("/api/"))52+ ) {4053 if (!checkBearer(req, env)) return finish(err("unauthorized", 401));4154 return finish(await api(req, env, url, ctx));4255 }4356 44- if (url.pathname === "/_notify" || url.pathname.startsWith("/_notify/")) {57+ if (58+ controlOrigin &&59+ (url.pathname === "/_notify" || url.pathname.startsWith("/_notify/"))60+ ) {4561 return finish(await handleNotifyPublic(req, env, url));4662 }4763 48- const isUnlock = req.method === "POST" &&64+ const legacyUnlock = controlOrigin && req.method === "POST" &&4965 /^\/[0-9a-f]{4}\/__unlock$/.test(url.pathname);66+ const siteUnlock = siteAddress !== null && req.method === "POST" &&67+ url.pathname === "/__unlock";68+ const isUnlock = legacyUnlock || siteUnlock;5069 if (req.method !== "GET" && req.method !== "HEAD" && !isUnlock) {5170 return finish(err("method not allowed", 405));5271 }@@ -56,17 +75,20 @@ export default {
5675 // alone — reaching them already requires knowing a live address.5776 const ip = req.headers.get("cf-connecting-ip") ?? "local";5877 if (isUnlock) {59- const address = url.pathname.slice(1, 5);78+ const address = siteAddress ?? url.pathname.slice(1, 5);6079 const { success } = await env.RL_UNLOCK.limit({6180 key: `${ip}:${address}`,6281 });6382 if (!success) return finish(tooManyRequests());64- } else if (/^\/[0-9a-f]{4}\/?$/.test(url.pathname)) {83+ } else if (84+ (controlOrigin && /^\/[0-9a-f]{4}\/?$/.test(url.pathname)) ||85+ (siteAddress !== null && url.pathname === "/")86+ ) {6587 const { success } = await env.RL_ENUM.limit({ key: ip });6688 if (!success) return finish(tooManyRequests());6789 }6890 69- return finish(await serve(req, env, url));91+ return finish(await serve(req, env, url, siteAddress ?? undefined));7092 },7193 7294 async scheduled(@@ -42,6 +42,7 @@ export function buildSecurityRequestEvent(
4242 req: Request,4343 url: URL,4444 response: Response,45+ siteAddress: string | null = null,4546 ): SecurityRequestEvent | null {4647 const path = url.pathname;4748 const unlock = path.match(UNLOCK_PATH);@@ -49,7 +50,16 @@ export function buildSecurityRequestEvent(
4950 const addressPath = path.match(ADDRESS_PATH);5051 5152 let pathClass: RequestClass;52- if (path === "/api" || path.startsWith("/api/")) {53+ if (siteAddress !== null) {54+ if (path === "/__unlock") {55+ pathClass = "unlock";56+ } else if (path === "/") {57+ pathClass = "address";58+ } else {59+ if (response.status < 400) return null;60+ pathClass = "address_asset";61+ }62+ } else if (path === "/api" || path.startsWith("/api/")) {5363 if (response.status < 400) return null;5464 pathClass = "api";5565 } else if (unlock) {@@ -65,7 +75,7 @@ export function buildSecurityRequestEvent(
6575 pathClass = "invalid";6676 }6777 68- const address = (unlock ?? exactAddress ?? addressPath)?.[1];78+ const address = siteAddress ?? (unlock ?? exactAddress ?? addressPath)?.[1];6979 const loggedPath = path.slice(0, MAX_LOGGED_PATH_LENGTH);7080 return {7181 event: "security.request",@@ -117,9 +127,13 @@ export async function recordEnumerationProbe(
117127 env: Env,118128 url: URL,119129 response: Response,130+ siteAddress: string | null = null,120131 ): Promise<void> {121132 const match = url.pathname.match(EXACT_ADDRESS_PATH);122- if (!match) return;133+ const addressString = siteAddress !== null && url.pathname === "/"134+ ? siteAddress135+ : match?.[1];136+ if (!addressString) return;123137 124138 try {125139 const ip = req.headers.get("cf-connecting-ip") ?? "local";@@ -137,7 +151,7 @@ export async function recordEnumerationProbe(
137151 138152 const now = Math.floor(Date.now() / 1000);139153 const bucket = now - (now % 300);140- const address = Number.parseInt(match[1], 16);154+ const address = Number.parseInt(addressString, 16);141155 const cf = req.cf;142156 await env.DB.prepare(143157 `INSERT OR IGNORE INTO security_probes@@ -177,8 +191,9 @@ export async function logSecurityRequest(
177191 env: Env,178192 url: URL,179193 response: Response,194+ siteAddress: string | null = null,180195 ): Promise<void> {181- const event = buildSecurityRequestEvent(req, url, response);196+ const event = buildSecurityRequestEvent(req, url, response, siteAddress);182197 if (!event) return;183198 184199 try {@@ -2,6 +2,7 @@
22 33 const PBKDF2_ITERATIONS = 100_000;44 const COOKIE_TTL_S = 7 * 24 * 3600;5+const UNLOCK_COOKIE = "__Host-nzip-unlock";56 67 interface UnlockSecrets {78 NZIP_TOKEN: string;@@ -81,10 +82,6 @@ async function hmacKey(env: UnlockSecrets): Promise<CryptoKey> {
8182 );8283 }8384 84-function cookieName(address: string): string {85- return `nzip_a${address}`;86-}87-8885 /** Set-Cookie value granting access to one site for COOKIE_TTL_S. */8986 export async function makeUnlockCookie(9087 env: UnlockSecrets,@@ -99,10 +96,9 @@ export async function makeUnlockCookie(
9996 new TextEncoder().encode(`${address}.${authVersion}.${exp}`),10097 ),10198 );102- // Path without trailing slash so the cookie also matches the bare /{address} URL.103- return `${104- cookieName(address)105- }=${authVersion}.${exp}.${sig}; Max-Age=${COOKIE_TTL_S}; Path=/${address}; HttpOnly; Secure; SameSite=Lax`;99+ // __Host- prevents a sibling artifact hostname from planting a parent-domain100+ // cookie with this name. Each site hostname receives its own host-only value.101+ return `${UNLOCK_COOKIE}=${authVersion}.${exp}.${sig}; Max-Age=${COOKIE_TTL_S}; Path=/; HttpOnly; Secure; SameSite=Lax`;106102 }107103 108104 export async function hasValidUnlockCookie(@@ -112,37 +108,43 @@ export async function hasValidUnlockCookie(
112108 authVersion: number,113109 ): Promise<boolean> {114110 const cookies = req.headers.get("cookie") ?? "";115- const match = cookies.match(116- new RegExp(`(?:^|;\\s*)${cookieName(address)}=([^;]+)`),117- );118- if (!match) return false;119- const parts = match[1].split(".");120- let expStr: string;121- let sig: string;122- let payload: string;123- if (parts.length === 2 && authVersion === 1) {124- // Cookies issued before auth_version existed remain valid until the site's125- // password policy first changes and increments the version.126- [expStr, sig] = parts;127- payload = `${address}.${expStr}`;128- } else if (parts.length === 3 && parts[0] === String(authVersion)) {129- [, expStr, sig] = parts;130- payload = `${address}.${authVersion}.${expStr}`;131- } else {132- return false;133- }134- const exp = parseInt(expStr, 10);135- if (!Number.isFinite(exp) || exp < Math.floor(Date.now() / 1000)) {136- return false;137- }138- try {139- return await crypto.subtle.verify(140- "HMAC",141- await hmacKey(env),142- hexToBytes(sig) as BufferSource,143- new TextEncoder().encode(payload),144- );145- } catch {146- return false;111+ const acceptedNames = new Set([UNLOCK_COOKIE, `nzip_a${address}`]);112+ for (const entry of cookies.split(";")) {113+ const [name, ...valueParts] = entry.trim().split("=");114+ if (!acceptedNames.has(name)) continue;115+ const parts = valueParts.join("=").split(".");116+ let expStr: string;117+ let sig: string;118+ let payload: string;119+ if (parts.length === 2 && authVersion === 1) {120+ // Cookies issued before auth_version existed remain valid until the site's121+ // password policy first changes and increments the version.122+ [expStr, sig] = parts;123+ payload = `${address}.${expStr}`;124+ } else if (parts.length === 3 && parts[0] === String(authVersion)) {125+ [, expStr, sig] = parts;126+ payload = `${address}.${authVersion}.${expStr}`;127+ } else {128+ continue;129+ }130+ const exp = parseInt(expStr, 10);131+ if (!Number.isFinite(exp) || exp < Math.floor(Date.now() / 1000)) {132+ continue;133+ }134+ try {135+ if (136+ await crypto.subtle.verify(137+ "HMAC",138+ await hmacKey(env),139+ hexToBytes(sig) as BufferSource,140+ new TextEncoder().encode(payload),141+ )142+ ) {143+ return true;144+ }145+ } catch {146+ // Ignore malformed or cookie-shadowing candidates and try the next value.147+ }147148 }149+ return false;148150 }@@ -2,6 +2,7 @@ import { parseAddress, parseManifest } from "../../shared/mod.ts";
22 import { getSiteByAddress, type SiteRow } from "./db.ts";33 import { siteCacheTag } from "./cache.ts";44 import type { Env } from "./env.ts";5+import { siteUrl } from "./env.ts";56 import { FAVICON_PNG } from "./favicon.ts";67 import {78 hasValidUnlockCookie,@@ -22,6 +23,9 @@ const NOT_FOUND_PAGE =
2223 2324 const MAX_UNLOCK_BODY_BYTES = 4096;2425 const MAX_PASSWORD_LENGTH = 256;26+const ARTIFACT_SECURITY_HEADERS = {27+ "permissions-policy": "document-domain=()",28+} as const;2529 2630 function htmlResponse(2731 body: string,@@ -42,6 +46,7 @@ function publicSiteHeaders(address: string): HeadersInit {
4246 return {4347 "cache-control": "public, max-age=60",4448 "cache-tag": siteCacheTag(address),49+ ...ARTIFACT_SECURITY_HEADERS,4550 };4651 }4752 @@ -50,14 +55,14 @@ function publicSiteRedirect(
5055 pathname: string,5156 search: string,5257 address: string,58+ status = 302,5359 ): Response {54- const url = new URL(env.PUBLIC_BASE);55- url.pathname = pathname;56- url.search = search;57- url.hash = "";5860 return new Response(null, {59- status: 302,60- headers: { location: url.toString(), ...publicSiteHeaders(address) },61+ status,62+ headers: {63+ location: siteUrl(env, address, pathname, search),64+ ...publicSiteHeaders(address),65+ },6166 });6267 }6368 @@ -68,7 +73,7 @@ function escapeHtmlAttribute(value: string): string {
6873 function unlockForm(address: string, returnTo: string, error?: string): string {6974 return `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"><meta name="robots" content="noindex"><title>unlock — nzip</title>7075 <style>*{box-sizing:border-box}body{background:#121110;color:#d6cfc2;font-family:ui-monospace,monospace;display:grid;place-items:center;min-height:100vh;min-height:100dvh;margin:0;padding:max(24px,env(safe-area-inset-top)) max(16px,env(safe-area-inset-right)) max(24px,env(safe-area-inset-bottom)) max(16px,env(safe-area-inset-left))}form{width:min(100%,420px);text-align:center}b{color:#ffb347;font-size:24px;display:block;margin-bottom:20px}.field{display:flex;width:100%}input{background:#1a1815;border:1px solid #2e2a24;color:#d6cfc2;min-width:0;flex:1;padding:12px 14px;font:inherit;font-size:16px;line-height:20px;outline:none;border-radius:0}input:focus{border-color:#ffb347}button{background:#ffb347;border:0;color:#121110;min-height:46px;padding:12px 18px;font:inherit;font-weight:700;cursor:pointer;border-radius:0}.e{color:#f7768e;margin-top:12px;font-size:13px}@media(max-width:360px){.field{display:grid;gap:10px}button{width:100%}}</style>71-<form method="post" action="/${address}/__unlock"><input type="hidden" name="return_to" value="${76+<form method="post" action="/__unlock"><input type="hidden" name="return_to" value="${7277 escapeHtmlAttribute(returnTo)7378 }"><b>🔒 ${address}</b><div class="field"><input type="password" name="password" aria-label="password" placeholder="password" maxlength="256" autofocus autocomplete="current-password"><button>unlock</button></div>${7479 error ? `<div class="e">${error}</div>` : ""@@ -77,21 +82,17 @@ function unlockForm(address: string, returnTo: string, error?: string): string {
7782 7883 function unlockReturnTarget(7984 req: Request,80- address: string,8185 returnTo: string | null,8286 ): string {83- const fallback = `/${address}`;87+ const fallback = "/";8488 if (!returnTo) return fallback;8589 8690 try {8791 const requestUrl = new URL(req.url);8892 const target = new URL(returnTo, requestUrl);89- const siteRoot = `/${address}`;9093 if (9194 target.origin !== requestUrl.origin ||92- (target.pathname !== siteRoot &&93- !target.pathname.startsWith(`${siteRoot}/`)) ||94- target.pathname === `${siteRoot}/__unlock`95+ target.pathname === "/__unlock"9596 ) {9697 return fallback;9798 }@@ -107,12 +108,13 @@ async function handleUnlock(
107108 site: SiteRow,108109 address: string,109110 ): Promise<Response> {110- const fallbackTarget = `/${address}`;111+ const fallbackTarget = "/";111112 const contentLength = req.headers.get("content-length");112113 if (contentLength === null) {113114 return htmlResponse(114115 unlockForm(address, fallbackTarget, "request size required"),115116 411,117+ ARTIFACT_SECURITY_HEADERS,116118 );117119 }118120 const declaredBytes = Number(contentLength);@@ -120,12 +122,14 @@ async function handleUnlock(
120122 return htmlResponse(121123 unlockForm(address, fallbackTarget, "invalid request size"),122124 400,125+ ARTIFACT_SECURITY_HEADERS,123126 );124127 }125128 if (declaredBytes > MAX_UNLOCK_BODY_BYTES) {126129 return htmlResponse(127130 unlockForm(address, fallbackTarget, "request too large"),128131 413,132+ ARTIFACT_SECURITY_HEADERS,129133 );130134 }131135 const bytes = new Uint8Array(await req.arrayBuffer());@@ -133,12 +137,14 @@ async function handleUnlock(
133137 return htmlResponse(134138 unlockForm(address, fallbackTarget, "request too large"),135139 413,140+ ARTIFACT_SECURITY_HEADERS,136141 );137142 }138143 if (bytes.length !== declaredBytes) {139144 return htmlResponse(140145 unlockForm(address, fallbackTarget, "request size mismatch"),141146 400,147+ ARTIFACT_SECURITY_HEADERS,142148 );143149 }144150 const contentType = req.headers.get("content-type")?.split(";", 1)[0].trim()@@ -147,15 +153,17 @@ async function handleUnlock(
147153 return htmlResponse(148154 unlockForm(address, fallbackTarget, "unsupported form"),149155 415,156+ ARTIFACT_SECURITY_HEADERS,150157 );151158 }152159 const form = new URLSearchParams(new TextDecoder().decode(bytes));153160 const password = form.get("password");154- const returnTarget = unlockReturnTarget(req, address, form.get("return_to"));161+ const returnTarget = unlockReturnTarget(req, form.get("return_to"));155162 if (password !== null && password.length > MAX_PASSWORD_LENGTH) {156163 return htmlResponse(157164 unlockForm(address, returnTarget, "password too long"),158165 400,166+ ARTIFACT_SECURITY_HEADERS,159167 );160168 }161169 if (@@ -165,6 +173,7 @@ async function handleUnlock(
165173 return htmlResponse(166174 unlockForm(address, returnTarget, "wrong password"),167175 401,176+ ARTIFACT_SECURITY_HEADERS,168177 );169178 }170179 return new Response(null, {@@ -172,12 +181,12 @@ async function handleUnlock(
172181 headers: {173182 location: returnTarget,174183 "set-cookie": await makeUnlockCookie(env, address, site.auth_version),184+ ...ARTIFACT_SECURITY_HEADERS,175185 },176186 });177187 }178188 179-export async function serve(180- req: Request,189+async function serveControlOrigin(181190 env: Env,182191 url: URL,183192 ): Promise<Response> {@@ -210,20 +219,41 @@ export async function serve(
210219 });211220 }212221 213- // /{4hex} or /{4hex}/{asset-path}222+ // Legacy share URLs remain stable, but artifact bytes never execute on the223+ // control origin. A 308 preserves method and body for stale unlock forms.214224 const m = path.match(/^\/([0-9a-f]{4})(\/.*)?$/);215225 if (!m) return htmlResponse(NOT_FOUND_PAGE, 404);216226 const addressStr = m[1];217227 218228 const site = await getSiteByAddress(env, parseAddress(addressStr));219229 if (!site) return htmlResponse(NOT_FOUND_PAGE, 404);230+ return publicSiteRedirect(231+ env,232+ m[2] ?? "/",233+ url.search,234+ addressStr,235+ 308,236+ );237+}238+239+async function serveSiteOrigin(240+ req: Request,241+ env: Env,242+ url: URL,243+ addressStr: string,244+): Promise<Response> {245+ const path = url.pathname;246+ const site = await getSiteByAddress(env, parseAddress(addressStr));247+ if (!site) {248+ return htmlResponse(NOT_FOUND_PAGE, 404, ARTIFACT_SECURITY_HEADERS);249+ }220250 const now = Math.floor(Date.now() / 1000);221251 if (site.expires_at !== null && site.expires_at < now) {222- return htmlResponse(GONE_PAGE, 410);252+ return htmlResponse(GONE_PAGE, 410, ARTIFACT_SECURITY_HEADERS);223253 }224254 225255 // Password gate.226- if (m[2] === "/__unlock" && req.method === "POST") {256+ if (path === "/__unlock" && req.method === "POST") {227257 return await handleUnlock(req, env, site, addressStr);228258 }229259 if (@@ -233,56 +263,76 @@ export async function serve(
233263 return htmlResponse(234264 unlockForm(addressStr, `${url.pathname}${url.search}`),235265 401,266+ ARTIFACT_SECURITY_HEADERS,236267 );237268 }238269 239270 const manifestObj = await env.CONTENT.get(240271 `manifest/${site.current_manifest}`,241272 );242- if (!manifestObj) return htmlResponse(NOT_FOUND_PAGE, 404);273+ if (!manifestObj) {274+ return htmlResponse(NOT_FOUND_PAGE, 404, ARTIFACT_SECURITY_HEADERS);275+ }243276 const manifest = parseManifest(244277 new Uint8Array(await manifestObj.arrayBuffer()),245278 );246279 const filePaths = Object.keys(manifest.files);247280 248- // Bare address: single-file sites serve directly (URL stays /{4hex} — nothing249- // relative to break); multi-file bundles redirect to the trailing slash so250- // relative asset URLs resolve.251281 let assetPath: string;252282 let explicitIndexPath = false;253- if (m[2] === undefined) {254- if (filePaths.length !== 1) {255- return publicSiteRedirect(256- env,257- `/${addressStr}/`,258- url.search,259- addressStr,260- );261- }262- assetPath = filePaths[0];283+ if (path === "/") {284+ assetPath = manifest.files["index.html"]285+ ? "index.html"286+ : filePaths.length === 1287+ ? filePaths[0]288+ : "index.html";263289 } else {264- assetPath = decodeURIComponent(m[2].slice(1)); // strip leading /290+ try {291+ assetPath = decodeURIComponent(path.slice(1));292+ } catch {293+ return htmlResponse(NOT_FOUND_PAGE, 404, ARTIFACT_SECURITY_HEADERS);294+ }265295 explicitIndexPath = /(^|\/)index\.html$/.test(assetPath);266296 if (assetPath === "" || assetPath.endsWith("/")) assetPath += "index.html";267297 }268298 299+ // Sites built for the former /{address}/ deployment base may contain absolute300+ // asset URLs with that prefix. Prefer a real file at the requested path, then301+ // fall back to stripping exactly this site's address so existing artifacts302+ // remain usable after moving to their own origin.303+ let entry = manifest.files[assetPath];304+ if (!entry) {305+ const legacyPrefix = `${addressStr}/`;306+ const legacyPath = assetPath === addressStr307+ ? "index.html"308+ : assetPath.startsWith(legacyPrefix)309+ ? assetPath.slice(legacyPrefix.length) || "index.html"310+ : null;311+ if (legacyPath !== null) entry = manifest.files[legacyPath];312+ }313+269314 // Keep directory index filenames out of public URLs. Static generators often270315 // emit same-page and anchor links as `index.html` or `index.html#section`;271316 // browsers preserve the fragment while following this redirect.272- if (explicitIndexPath && manifest.files[assetPath]) {273- const isRootIndex = assetPath === "index.html";274- const canonicalPath = isRootIndex && filePaths.length === 1275- ? `/${addressStr}`317+ if (explicitIndexPath && entry) {318+ const canonicalPath = assetPath === "index.html"319+ ? "/"276320 : path.slice(0, -"index.html".length);277321 return publicSiteRedirect(env, canonicalPath, url.search, addressStr);278322 }279323 280- const entry = manifest.files[assetPath];281- if (!entry && manifest.files[`${assetPath}/index.html`]) {324+ let directoryIndex = manifest.files[`${assetPath}/index.html`];325+ if (!entry && !directoryIndex && assetPath.startsWith(`${addressStr}/`)) {326+ const legacyDirectory = assetPath.slice(addressStr.length + 1);327+ directoryIndex = manifest.files[`${legacyDirectory}/index.html`];328+ }329+ if (!entry && directoryIndex) {282330 // Directory hit without trailing slash: redirect so relative URLs resolve.283331 return publicSiteRedirect(env, `${path}/`, url.search, addressStr);284332 }285- if (!entry) return htmlResponse(NOT_FOUND_PAGE, 404);333+ if (!entry) {334+ return htmlResponse(NOT_FOUND_PAGE, 404, ARTIFACT_SECURITY_HEADERS);335+ }286336 287337 const etag = `"${entry.h}"`;288338 const protectedSite = site.password_hash !== null;@@ -299,7 +349,9 @@ export async function serve(
299349 }300350 301351 const blob = await env.CONTENT.get(`blob/${entry.h}`);302- if (!blob) return htmlResponse(NOT_FOUND_PAGE, 404);352+ if (!blob) {353+ return htmlResponse(NOT_FOUND_PAGE, 404, ARTIFACT_SECURITY_HEADERS);354+ }303355 304356 return new Response(blob.body, {305357 headers: {@@ -310,6 +362,18 @@ export async function serve(
310362 // survive the password check in a browser or intermediary cache.311363 "cache-control": cacheControl,312364 ...(protectedSite ? {} : { "cache-tag": siteCacheTag(addressStr) }),365+ ...ARTIFACT_SECURITY_HEADERS,313366 },314367 });315368 }369+370+export async function serve(371+ req: Request,372+ env: Env,373+ url: URL,374+ siteAddress?: string,375+): Promise<Response> {376+ return siteAddress === undefined377+ ? await serveControlOrigin(env, url)378+ : await serveSiteOrigin(req, env, url, siteAddress);379+}@@ -0,0 +1,35 @@
1+import assert from "node:assert/strict";2+import test from "node:test";3+4+import { isControlOrigin, siteAddressFromUrl, siteUrl } from "../src/env.ts";5+6+const env = {7+ PUBLIC_BASE: "https://n.zip",8+ SITE_DOMAIN: "n.zip",9+};10+11+test("control and site hosts are classified without trusting arbitrary wildcard hosts", () => {12+ assert.equal(isControlOrigin(env, new URL("https://n.zip/api/status")), true);13+ assert.equal(isControlOrigin(env, new URL("http://n.zip/2a3f")), true);14+ assert.equal(isControlOrigin(env, new URL("https://2a3f.n.zip/")), false);15+16+ assert.equal(siteAddressFromUrl(env, new URL("https://2a3f.n.zip/")), "2a3f");17+ assert.equal(siteAddressFromUrl(env, new URL("http://2a3f.n.zip/")), "2a3f");18+ assert.equal(siteAddressFromUrl(env, new URL("https://site.n.zip/")), null);19+ assert.equal(siteAddressFromUrl(env, new URL("https://2a3f.evil.n.zip/")), null);20+ assert.equal(siteAddressFromUrl(env, new URL("https://n.zip/")), null);21+});22+23+test("site URLs always derive from the configured public HTTPS base", () => {24+ assert.equal(25+ siteUrl(env, "2a3f", "/docs/", "?view=full"),26+ "https://2a3f.n.zip/docs/?view=full",27+ );28+});29+30+test("invalid site domains fail closed", () => {31+ assert.throws(32+ () => siteUrl({ ...env, SITE_DOMAIN: "bad..domain" }, "2a3f"),33+ /invalid SITE_DOMAIN/,34+ );35+});@@ -1,4 +1,9 @@
1-import { createExecutionContext, env, SELF, waitOnExecutionContext } from "cloudflare:test";1+import {2+ createExecutionContext,3+ env,4+ SELF,5+ waitOnExecutionContext,6+} from "cloudflare:test";27 import { describe, expect, it } from "vitest";38 import worker from "../src/index.ts";49 import { hashPassword, verifyPassword } from "../src/password.ts";@@ -9,11 +14,27 @@ describe("Worker runtime", () => {
914 1015 expect(response.status).toBe(200);1116 expect(response.headers.get("cache-control")).toBe("public, max-age=3600");12- expect(response.headers.get("content-security-policy")).toBe("frame-ancestors 'none'");17+ expect(response.headers.get("content-security-policy")).toBe(18+ "frame-ancestors 'none'",19+ );1320 expect(response.headers.get("x-frame-options")).toBe("DENY");1421 expect(await response.text()).toContain("nzip");1522 });1623 24+ it("rejects unknown wildcard hosts without exposing the control plane", async () => {25+ const invalid = await SELF.fetch("https://attacker.demo.dev/api/status", {26+ headers: { authorization: "Bearer runtime-test-token" },27+ });28+ const validArtifactHost = await SELF.fetch(29+ "https://0123.demo.dev/api/status",30+ { headers: { authorization: "Bearer runtime-test-token" } },31+ );32+33+ expect(invalid.status).toBe(404);34+ expect(validArtifactHost.status).toBe(404);35+ expect(await invalid.json()).toEqual({ error: "not found" });36+ });37+1738 it("keeps the root shell claim-independent and hides pairing by default", async () => {1839 const response = await SELF.fetch("https://share.demo.dev/");1940 const html = await response.text();@@ -47,12 +68,15 @@ describe("Worker runtime", () => {
4768 expect(enrolled.status).toBe(404);4869 expect(await enrolled.json()).toEqual({ error: "pairing unavailable" });4970 50- const crossOrigin = await SELF.fetch("https://share.demo.dev/_notify/pairing", {51- headers: {52- origin: "https://attacker.example",53- "sec-fetch-site": "cross-site",71+ const crossOrigin = await SELF.fetch(72+ "https://share.demo.dev/_notify/pairing",73+ {74+ headers: {75+ origin: "https://attacker.example",76+ "sec-fetch-site": "cross-site",77+ },5478 },55- });79+ );5680 expect(crossOrigin.status).toBe(403);5781 5882 await SELF.fetch("https://share.demo.dev/api/notify/pairing", {@@ -203,7 +227,8 @@ describe("Worker runtime", () => {
203227 it("preserves TTL and password settings when repushing without policy flags", async () => {204228 const address = 0xeffe;205229 const expiresAt = Math.floor(Date.now() / 1000) + 90 * 86_400;206- const emptyHash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";230+ const emptyHash =231+ "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";207232 const oldManifest = "a".repeat(64);208233 await env.DB.batch([209234 env.DB.prepare(@@ -226,32 +251,58 @@ describe("Worker runtime", () => {
226251 ]);227252 await env.CONTENT.put(`blob/${emptyHash}`, new Uint8Array());228253 229- const response = await SELF.fetch("https://share.demo.dev/api/push/commit", {230- method: "POST",231- headers: {232- authorization: "Bearer runtime-test-token",233- "content-type": "application/json",234- },235- body: JSON.stringify({236- manifest: {237- v: 1,238- files: {239- "index.html": { h: emptyHash, s: 0, ct: "text/html; charset=utf-8" },240- },254+ const response = await SELF.fetch(255+ "https://share.demo.dev/api/push/commit",256+ {257+ method: "POST",258+ headers: {259+ authorization: "Bearer runtime-test-token",260+ "content-type": "application/json",241261 },242- target: { address },243- }),244- });262+ body: JSON.stringify({263+ manifest: {264+ v: 1,265+ files: {266+ "index.html": {267+ h: emptyHash,268+ s: 0,269+ ct: "text/html; charset=utf-8",270+ },271+ },272+ },273+ target: { address },274+ }),275+ },276+ );245277 246278 expect(response.status).toBe(200);247279 expect(await response.json()).toMatchObject({248280 address: "effe",281+ url: "https://effe.demo.dev/",249282 expiresAt,250283 protected: true,251284 });285+286+ const legacy = await SELF.fetch("https://share.demo.dev/effe/index.html", {287+ redirect: "manual",288+ });289+ expect(legacy.status).toBe(308);290+ expect(legacy.headers.get("location")).toBe(291+ "https://effe.demo.dev/index.html",292+ );293+294+ const isolated = await SELF.fetch("https://effe.demo.dev/", {295+ headers: { cookie: "nzip_aeffe=not-a-valid-unlock" },296+ });297+ expect(isolated.status).toBe(401);298+ expect(isolated.headers.get("permissions-policy")).toBe(299+ "document-domain=()",300+ );252301 const stored = await env.DB.prepare(253302 "SELECT expires_at, password_hash FROM sites WHERE address = ?",254- ).bind(address).first<{ expires_at: number | null; password_hash: string | null }>();303+ ).bind(address).first<304+ { expires_at: number | null; password_hash: string | null }305+ >();255306 expect(stored).toEqual({256307 expires_at: expiresAt,257308 password_hash: "existing-password-hash",@@ -295,40 +346,60 @@ describe("Worker runtime", () => {
295346 description: "Human review links; safe to share with collaborators",296347 });297348 298- const listed = await SELF.fetch("https://share.demo.dev/api/vaults", { headers });349+ const listed = await SELF.fetch("https://share.demo.dev/api/vaults", {350+ headers,351+ });299352 expect(await listed.json()).toContainEqual(expect.objectContaining({300353 name: "reviews",301354 description: "Human review links; safe to share with collaborators",302355 }));303356 304- const cleared = await SELF.fetch("https://share.demo.dev/api/vaults/reviews", {305- method: "PATCH",306- headers,307- body: JSON.stringify({ description: "" }),308- });357+ const cleared = await SELF.fetch(358+ "https://share.demo.dev/api/vaults/reviews",359+ {360+ method: "PATCH",361+ headers,362+ body: JSON.stringify({ description: "" }),363+ },364+ );309365 expect(cleared.status).toBe(200);310- expect(await cleared.json()).toMatchObject({ name: "reviews", description: null });311-312- const clearedWithNull = await SELF.fetch("https://share.demo.dev/api/vaults/reviews", {313- method: "PATCH",314- headers,315- body: JSON.stringify({ description: null }),366+ expect(await cleared.json()).toMatchObject({367+ name: "reviews",368+ description: null,316369 });317- expect(clearedWithNull.status).toBe(200);318- expect(await clearedWithNull.json()).toMatchObject({ name: "reviews", description: null });319370 320- const multiline = await SELF.fetch("https://share.demo.dev/api/vaults/reviews", {321- method: "PATCH",322- headers,323- body: JSON.stringify({ description: "line1\nline2" }),371+ const clearedWithNull = await SELF.fetch(372+ "https://share.demo.dev/api/vaults/reviews",373+ {374+ method: "PATCH",375+ headers,376+ body: JSON.stringify({ description: null }),377+ },378+ );379+ expect(clearedWithNull.status).toBe(200);380+ expect(await clearedWithNull.json()).toMatchObject({381+ name: "reviews",382+ description: null,324383 });384+385+ const multiline = await SELF.fetch(386+ "https://share.demo.dev/api/vaults/reviews",387+ {388+ method: "PATCH",389+ headers,390+ body: JSON.stringify({ description: "line1\nline2" }),391+ },392+ );325393 expect(multiline.status).toBe(400);326394 327- const badEncoding = await SELF.fetch("https://share.demo.dev/api/vaults/%zz", {328- method: "PATCH",329- headers,330- body: JSON.stringify({ description: "x" }),331- });395+ const badEncoding = await SELF.fetch(396+ "https://share.demo.dev/api/vaults/%zz",397+ {398+ method: "PATCH",399+ headers,400+ body: JSON.stringify({ description: "x" }),401+ },402+ );332403 expect(badEncoding.status).toBe(400);333404 });334405 @@ -7,6 +7,7 @@ import { buildWebPushRequest, validatePushEndpoint } from "../src/web_push.ts";
77 88 const env = {99 PUBLIC_BASE: "https://share.demo.dev",10+ SITE_DOMAIN: "demo.dev",1011 WEB_PUSH_ORIGINS:1112 "https://fcm.googleapis.com,https://web.push.apple.com,https://updates.push.services.mozilla.com",1213 };@@ -15,6 +15,16 @@ function eventFor(path, status, method = "GET") {
1515 );1616 }1717 18+function siteEventFor(path, status, method = "GET") {19+ const url = new URL(path, "https://0123.n.zip");20+ return buildSecurityRequestEvent(21+ new Request(url, { method }),22+ url,23+ new Response(null, { status }),24+ "0123",25+ );26+}27+1828 test("four-hex address probes include derived vault and site locations", () => {1929 assert.deepEqual(eventFor("/0123", 404), {2030 event: "security.request",@@ -54,6 +64,13 @@ test("missing assets and unlock attempts retain their request class", () => {
5464 assert.equal(eventFor("/0123/__unlock", 429, "POST")?.result, "rate_limited");5565 });5666 67+test("site-origin probes retain the hostname address", () => {68+ assert.equal(siteEventFor("/", 404)?.path_class, "address");69+ assert.equal(siteEventFor("/__unlock", 429, "POST")?.path_class, "unlock");70+ assert.equal(siteEventFor("/missing.js", 404)?.address, "0123");71+ assert.equal(siteEventFor("/app.js", 200), null);72+});73+5774 class FakeRateLimit {5875 calls = [];5976 @@ -4,11 +4,14 @@ import test from "node:test";
44 import { hasValidUnlockCookie, makeUnlockCookie } from "../src/password.ts";55 66 function requestWith(cookie) {7- return new Request("https://n.zip/12d8", { headers: { cookie: cookie.split(";", 1)[0] } });7+ return new Request("https://12d8.n.zip/", {8+ headers: { cookie: cookie.split(";", 1)[0] },9+ });810 }911 1012 const hex = (bytes) =>11- [...new Uint8Array(bytes)].map((byte) => byte.toString(16).padStart(2, "0")).join("");13+ [...new Uint8Array(bytes)].map((byte) => byte.toString(16).padStart(2, "0"))14+ .join("");1215 1316 async function legacyCookie(env, address) {1417 const exp = Math.floor(Date.now() / 1000) + 3600;@@ -32,11 +35,27 @@ test("unlock cookies are scoped to the current auth version", async () => {
3235 const cookie = await makeUnlockCookie(env, "12d8", 3);3336 const req = requestWith(cookie);3437 38+ assert.match(cookie, /^__Host-nzip-unlock=/);39+ assert.match(cookie, /; Path=\//);40+ assert.match(cookie, /; Secure/);41+ assert.doesNotMatch(cookie, /Domain=/i);42+3543 assert.equal(await hasValidUnlockCookie(req, env, "12d8", 3), true);3644 assert.equal(await hasValidUnlockCookie(req, env, "12d8", 4), false);3745 assert.equal(await hasValidUnlockCookie(req, env, "ffff", 3), false);3846 });3947 48+test("a malformed shadowing cookie does not hide a valid unlock cookie", async () => {49+ const env = { NZIP_TOKEN: "test-owner-token" };50+ const cookie = await makeUnlockCookie(env, "12d8", 3);51+ const value = cookie.split(";", 1)[0];52+ const req = new Request("https://12d8.n.zip/", {53+ headers: { cookie: `__Host-nzip-unlock=broken; ${value}` },54+ });55+56+ assert.equal(await hasValidUnlockCookie(req, env, "12d8", 3), true);57+});58+4059 test("legacy unlock cookies survive migration until the auth version changes", async () => {4160 const env = { NZIP_TOKEN: "test-owner-token" };4261 const req = requestWith(await legacyCookie(env, "12d8"));@@ -23,6 +23,7 @@ function envFor(files, siteOverrides = {}) {
2323 2424 return {2525 PUBLIC_BASE: "https://n.zip",26+ SITE_DOMAIN: "n.zip",2627 DB: {2728 prepare() {2829 return {@@ -47,72 +48,70 @@ function envFor(files, siteOverrides = {}) {
4748 const html = { h: HASH, s: 0, ct: "text/html; charset=utf-8" };4849 const css = { h: HASH, s: 0, ct: "text/css; charset=utf-8" };4950 50-test("single-file root index redirects to the bare site URL", async () => {51+function serveSite(request, env, url) {52+ return serve(request, env, url, "2f9b");53+}54+55+test("legacy address paths permanently redirect to the isolated site origin", async () => {5156 const response = await serve(5257 new Request("https://n.zip/2f9b/index.html?view=full"),5358 envFor({ "index.html": html }),5459 new URL("https://n.zip/2f9b/index.html?view=full"),5560 );5661 57- assert.equal(response.status, 302);62+ assert.equal(response.status, 308);5863 assert.equal(response.headers.get("cache-control"), "public, max-age=60");5964 assert.equal(response.headers.get("cache-tag"), "nzip-site-2f9b");6065 assert.equal(6166 response.headers.get("location"),62- "https://n.zip/2f9b?view=full",67+ "https://2f9b.n.zip/index.html?view=full",6368 );6469 });6570 66-test("multi-file root index redirects to the site directory", async () => {67- const url = new URL("https://n.zip/2f9b/index.html");68- const response = await serve(71+test("root index redirects to the isolated site root", async () => {72+ const url = new URL("https://2f9b.n.zip/index.html");73+ const response = await serveSite(6974 new Request(url),7075 envFor({ "index.html": html, "style.css": css }),7176 url,7277 );7378 7479 assert.equal(response.status, 302);75- assert.equal(response.headers.get("location"), "https://n.zip/2f9b/");80+ assert.equal(response.headers.get("location"), "https://2f9b.n.zip/");7681 });7782 7883 test("nested index redirects to its directory", async () => {79- const url = new URL("https://n.zip/2f9b/docs/index.html");80- const response = await serve(84+ const url = new URL("https://2f9b.n.zip/docs/index.html");85+ const response = await serveSite(8186 new Request(url),8287 envFor({ "index.html": html, "docs/index.html": html }),8388 url,8489 );8590 8691 assert.equal(response.status, 302);87- assert.equal(response.headers.get("location"), "https://n.zip/2f9b/docs/");92+ assert.equal(response.headers.get("location"), "https://2f9b.n.zip/docs/");8893 });8994 9095 test("canonical redirects use the HTTPS public base instead of the request origin", async (t) => {9196 const cases = [92- {93- name: "bare multi-file address",94- requestUrl: "http://internal/2f9b?mode=pwa",95- files: { "index.html": html, "style.css": css },96- location: "https://n.zip/2f9b/?mode=pwa",97- },9897 {9998 name: "explicit index",100- requestUrl: "http://internal/2f9b/index.html?mode=pwa",99+ requestUrl: "http://internal/index.html?mode=pwa",101100 files: { "index.html": html, "style.css": css },102- location: "https://n.zip/2f9b/?mode=pwa",101+ location: "https://2f9b.n.zip/?mode=pwa",103102 },104103 {105104 name: "directory without trailing slash",106- requestUrl: "http://internal/2f9b/docs?mode=pwa",105+ requestUrl: "http://internal/docs?mode=pwa",107106 files: { "index.html": html, "docs/index.html": html },108- location: "https://n.zip/2f9b/docs/?mode=pwa",107+ location: "https://2f9b.n.zip/docs/?mode=pwa",109108 },110109 ];111110 112111 for (const { name, requestUrl, files, location } of cases) {113112 await t.test(name, async () => {114113 const url = new URL(requestUrl);115- const response = await serve(new Request(url), envFor(files), url);114+ const response = await serveSite(new Request(url), envFor(files), url);116115 117116 assert.equal(response.status, 302);118117 assert.equal(response.headers.get("location"), location);@@ -121,8 +120,8 @@ test("canonical redirects use the HTTPS public base instead of the request origi
121120 });122121 123122 test("canonical directory URL serves its index without redirecting", async () => {124- const url = new URL("https://n.zip/2f9b/");125- const response = await serve(123+ const url = new URL("https://2f9b.n.zip/");124+ const response = await serveSite(126125 new Request(url),127126 envFor({ "index.html": html, "style.css": css }),128127 url,@@ -135,8 +134,8 @@ test("canonical directory URL serves its index without redirecting", async () =>
135134 });136135 137136 test("missing index path still returns not found", async () => {138- const url = new URL("https://n.zip/2f9b/index.html");139- const response = await serve(137+ const url = new URL("https://2f9b.n.zip/index.html");138+ const response = await serveSite(140139 new Request(url),141140 envFor({ "download.txt": { ...css, ct: "text/plain; charset=utf-8" } }),142141 url,@@ -145,6 +144,26 @@ test("missing index path still returns not found", async () => {
145144 assert.equal(response.status, 404);146145 });147146 147+test("old address-prefixed asset bases remain usable on the site origin", async () => {148+ const assetUrl = new URL("https://2f9b.n.zip/2f9b/style.css");149+ const asset = await serveSite(150+ new Request(assetUrl),151+ envFor({ "index.html": html, "style.css": css }),152+ assetUrl,153+ );154+ const indexUrl = new URL("https://2f9b.n.zip/2f9b/index.html");155+ const index = await serveSite(156+ new Request(indexUrl),157+ envFor({ "index.html": html, "style.css": css }),158+ indexUrl,159+ );160+161+ assert.equal(asset.status, 200);162+ assert.equal(asset.headers.get("content-type"), "text/css; charset=utf-8");163+ assert.equal(index.status, 302);164+ assert.equal(index.headers.get("location"), "https://2f9b.n.zip/2f9b/");165+});166+148167 test("favicon serves the wordmark PNG with a cacheable response", async () => {149168 const url = new URL("https://n.zip/favicon.ico");150169 const response = await serve(new Request(url), envFor({}), url);@@ -168,8 +187,8 @@ test("favicon serves the wordmark PNG with a cacheable response", async () => {
168187 });169188 170189 test("unlock requires a declared request size", async () => {171- const url = new URL("https://n.zip/2f9b/__unlock");172- const response = await serve(190+ const url = new URL("https://2f9b.n.zip/__unlock");191+ const response = await serveSite(173192 new Request(url, {174193 method: "POST",175194 headers: { "content-type": "application/x-www-form-urlencoded" },@@ -183,8 +202,8 @@ test("unlock requires a declared request size", async () => {
183202 });184203 185204 test("unlock page disables mobile zoom", async () => {186- const url = new URL("https://n.zip/2f9b");187- const response = await serve(205+ const url = new URL("https://2f9b.n.zip/");206+ const response = await serveSite(188207 new Request(url),189208 envFor({ "index.html": html }, { password_hash: "hash" }),190209 url,@@ -196,8 +215,8 @@ test("unlock page disables mobile zoom", async () => {
196215 197216 test("unlock returns to the requested nested page", async () => {198217 const passwordHash = await hashPassword("test");199- const pageUrl = new URL("https://n.zip/2f9b/docs/guide.html?view=full");200- const locked = await serve(218+ const pageUrl = new URL("https://2f9b.n.zip/docs/guide.html?view=full");219+ const locked = await serveSite(201220 new Request(pageUrl),202221 envFor({ "docs/guide.html": html }, { password_hash: passwordHash }),203222 pageUrl,@@ -205,11 +224,11 @@ test("unlock returns to the requested nested page", async () => {
205224 const returnTo = /name="return_to" value="([^"]+)"/.exec(206225 await locked.text(),207226 )?.[1].replaceAll("&", "&");208- assert.equal(returnTo, "/2f9b/docs/guide.html?view=full");227+ assert.equal(returnTo, "/docs/guide.html?view=full");209228 210229 const body = new URLSearchParams({ password: "test", return_to: returnTo });211- const unlockUrl = new URL("https://n.zip/2f9b/__unlock");212- const unlocked = await serve(230+ const unlockUrl = new URL("https://2f9b.n.zip/__unlock");231+ const unlocked = await serveSite(213232 new Request(unlockUrl, {214233 method: "POST",215234 headers: {@@ -227,9 +246,14 @@ test("unlock returns to the requested nested page", async () => {
227246 assert.equal(unlocked.status, 303);228247 assert.equal(229248 unlocked.headers.get("location"),230- "/2f9b/docs/guide.html?view=full",249+ "/docs/guide.html?view=full",250+ );251+ assert.match(252+ unlocked.headers.get("set-cookie") ?? "",253+ /^__Host-nzip-unlock=/,231254 );232- assert.match(unlocked.headers.get("set-cookie") ?? "", /^nzip_a2f9b=/);255+ assert.match(unlocked.headers.get("set-cookie") ?? "", /; Path=\//);256+ assert.doesNotMatch(unlocked.headers.get("set-cookie") ?? "", /Domain=/i);233257 });234258 235259 test("unlock rejects a return target outside the locked site", async () => {@@ -238,8 +262,8 @@ test("unlock rejects a return target outside the locked site", async () => {
238262 password: "test",239263 return_to: "https://example.com/phishing",240264 });241- const unlockUrl = new URL("https://n.zip/2f9b/__unlock");242- const response = await serve(265+ const unlockUrl = new URL("https://2f9b.n.zip/__unlock");266+ const response = await serveSite(243267 new Request(unlockUrl, {244268 method: "POST",245269 headers: {@@ -255,12 +279,12 @@ test("unlock rejects a return target outside the locked site", async () => {
255279 );256280 257281 assert.equal(response.status, 303);258- assert.equal(response.headers.get("location"), "/2f9b");282+ assert.equal(response.headers.get("location"), "/");259283 });260284 261285 test("unlock rejects an oversized declared body before password verification", async () => {262- const url = new URL("https://n.zip/2f9b/__unlock");263- const response = await serve(286+ const url = new URL("https://2f9b.n.zip/__unlock");287+ const response = await serveSite(264288 new Request(url, {265289 method: "POST",266290 headers: {@@ -277,9 +301,9 @@ test("unlock rejects an oversized declared body before password verification", a
277301 });278302 279303 test("unlock rejects passwords above 256 characters before hashing", async () => {280- const url = new URL("https://n.zip/2f9b/__unlock");304+ const url = new URL("https://2f9b.n.zip/__unlock");281305 const body = new URLSearchParams({ password: "x".repeat(257) }).toString();282- const response = await serve(306+ const response = await serveSite(283307 new Request(url, {284308 method: "POST",285309 headers: {@@ -1,5 +1,5 @@
11 /* eslint-disable */2-// Generated by Wrangler by running `wrangler types --env-file=.dev.vars.example --include-runtime=false --strict-vars=false` (hash: 74af392e91803a5e7b5145df95107baa)2+// Generated by Wrangler by running `wrangler types --env-file=.dev.vars.example --include-runtime=false --strict-vars=false` (hash: de29a204fcbfba82ce81959371a47ae1)33 interface __BaseEnv_Env {44 CONTENT: R2Bucket;55 DB: D1Database;@@ -10,7 +10,6 @@ interface __BaseEnv_Env {
1010 RL_SIGNAL: RateLimit;1111 RL_NOTIFY_ENROLL: RateLimit;1212 RL_NOTIFY_READ: RateLimit;13- PUBLIC_BASE: string;1413 ALERT_EMAIL_TO: string;1514 ALERT_EMAIL_FROM: string;1615 NOTIFY_ENABLED: string;@@ -19,6 +18,8 @@ interface __BaseEnv_Env {
1918 VAPID_SUBJECT: string;2019 NZIP_TOKEN: string;2120 VAPID_PRIVATE_KEY: string;21+ PUBLIC_BASE: string;22+ SITE_DOMAIN: string;2223 }2324 declare namespace Cloudflare {2425 interface GlobalProps {@@ -31,5 +32,5 @@ type StringifyValues<EnvType extends Record<string, unknown>> = {
3132 [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string;3233 };3334 declare namespace NodeJS {34- interface ProcessEnv extends StringifyValues<Pick<Cloudflare.Env, "PUBLIC_BASE" | "ALERT_EMAIL_TO" | "ALERT_EMAIL_FROM" | "NOTIFY_ENABLED" | "WEB_PUSH_ORIGINS" | "VAPID_PUBLIC_KEY" | "VAPID_SUBJECT" | "NZIP_TOKEN" | "VAPID_PRIVATE_KEY">> {}35+ interface ProcessEnv extends StringifyValues<Pick<Cloudflare.Env, "ALERT_EMAIL_TO" | "ALERT_EMAIL_FROM" | "NOTIFY_ENABLED" | "WEB_PUSH_ORIGINS" | "VAPID_PUBLIC_KEY" | "VAPID_SUBJECT" | "NZIP_TOKEN" | "VAPID_PRIVATE_KEY" | "PUBLIC_BASE" | "SITE_DOMAIN">> {}3536 }@@ -13,7 +13,10 @@
1313 "invocation_logs": false1414 }1515 },16- "routes": [{ "pattern": "share.demo.dev", "custom_domain": true }],16+ "routes": [17+ { "pattern": "share.demo.dev", "custom_domain": true },18+ { "pattern": "*.demo.dev/*", "zone_name": "demo.dev" }19+ ],1720 "r2_buckets": [1821 {1922 "binding": "CONTENT",@@ -70,6 +73,7 @@
7073 },7174 "vars": {7275 "PUBLIC_BASE": "https://share.demo.dev",76+ "SITE_DOMAIN": "demo.dev",7377 "ALERT_EMAIL_TO": "operator@example.com",7478 "ALERT_EMAIL_FROM": "alerts@share.demo.dev",7579 "NOTIFY_ENABLED": "false",