functions/inbox/[kind].js 11.4 KiB 374 lines Raw
1 // Cloudflare Pages Functions inbox (f0rge #79).2 //3 // One dynamic route serves both mailboxes: /inbox/issue and4 // /inbox/pr-prompt. Contract parity with `hu13 serve`:5 //   urlencoded only, 32 KiB cap, strict UTF-8, NUL rejected,6 //   controls stripped, title/body caps, license required,7 //   same-origin gate, fixed delay, ack uuid, no echo.8 // Storage is one JSON object per submission in the R2 binding INBOX.9 //10 // Bindings (Pages project settings / wrangler.toml):11 //   INBOX       R2 bucket12 //   INBOX_SALT  secret (required; SHA-256 salt for IP hashes)13 // Optional: INBOX_RATE_PER_HOUR (default 2), INBOX_QUOTA (default 500).14 15 const MAX_BYTES = 32 * 1024;16 const MAX_TITLE = 300;17 const MAX_BODY = 20000;18 const MAX_HANDLE = 40;19 const MAX_CONTACT = 100;20 const LICENSE_VERSION = "v1";21 const ACK_DELAY_MS = 500;22 23 const encoder = new TextEncoder();24 25 function esc(s) {26   return String(s).replace(27     /[&<>"]/g,28     (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c],29   );30 }31 32 function page(title, body) {33   return `<!DOCTYPE html>34 <html lang="en"><head><meta charset="utf-8">35 <meta name="viewport" content="width=device-width, initial-scale=1">36 <meta name="robots" content="noindex">37 <title>${esc(title)}</title></head>38 <body><main><h1>${esc(title)}</h1>39 ${body}40 <p><a href="/">hu13</a></p></main></body></html>41 `;42 }43 44 function answer(status, body, extra = {}, delay = true) {45   const headers = {46     "content-type": "text/html; charset=utf-8",47     "x-content-type-options": "nosniff",48     "cache-control": "no-store",49     ...extra,50   };51   const response = () => new Response(body, { status, headers });52   if (!delay) return response();53   return new Promise((resolve) =>54     setTimeout(() => resolve(response()), ACK_DELAY_MS),55   );56 }57 58 function errorPage(status, detail, extra = {}) {59   const title = status === 405 ? "method not allowed" : status === 42960     ? "too many requests"61     : status === 50362     ? "unavailable"63     : status === 41364     ? "too large"65     : "bad request";66   return answer(67     status,68     page(title, `<p>${esc(detail)}</p>`),69     extra,70   );71 }72 73 function ackPage(uuid) {74   return page(75     "received",76     `<p>ack: <code>${esc(uuid)}</code></p>77 <p>Thank you. This identifier was shown once and is not a lookup URL.</p>`,78   );79 }80 81 function sleep(ms) {82   return new Promise((resolve) => setTimeout(resolve, ms));83 }84 85 // Same-origin gate: browsers send Origin/Referer; agents (curl) send86 // neither and pass. `null`/unparsable/mismatched host or scheme fails87 // closed; `.host` keeps the port, so another local port is rejected.88 function originAllowed(request) {89   let target;90   try {91     target = new URL(request.url);92   } catch {93     return false;94   }95   const host = target.host.toLowerCase();96   const protocol = target.protocol;97   for (const name of ["origin", "referer"]) {98     const value = request.headers.get(name);99     if (value === null) continue;100     if (!value || value.trim().toLowerCase() === "null") return false;101     try {102       const claimed = new URL(value);103       if (claimed.host.toLowerCase() !== host || claimed.protocol !== protocol) {104         return false;105       }106     } catch {107       return false;108     }109   }110   return true;111 }112 113 function hexByte(b) {114   if (b >= 0x30 && b <= 0x39) return b - 0x30;115   if (b >= 0x61 && b <= 0x66) return b - 0x61 + 10;116   if (b >= 0x41 && b <= 0x46) return b - 0x41 + 10;117   return -1;118 }119 120 // Strict percent-decoder matching hu13's `parse_form`: malformed `%`,121 // non-UTF-8, or a bad escape returns null (400) instead of guessing.122 function decodeComponent(input) {123   const raw = encoder.encode(input);124   const out = [];125   let i = 0;126   while (i < raw.length) {127     let b = raw[i++];128     if (b === 0x25) {129       if (i + 1 >= raw.length) return null;130       const hi = hexByte(raw[i++]);131       const lo = hexByte(raw[i++]);132       if (hi < 0 || lo < 0) return null;133       b = (hi << 4) | lo;134     } else if (b === 0x2b) {135       b = 0x20;136     }137     out.push(b);138   }139   try {140     return new TextDecoder("utf-8", { fatal: true }).decode(141       Uint8Array.from(out),142     );143   } catch {144     return null;145   }146 }147 148 function parseForm(body) {149   if (body === "") return [];150   const pairs = [];151   for (const part of body.split("&")) {152     const eq = part.indexOf("=");153     const key = eq < 0 ? part : part.slice(0, eq);154     const value = eq < 0 ? "" : part.slice(eq + 1);155     const k = decodeComponent(key);156     const v = decodeComponent(value);157     if (k === null || v === null) return null;158     pairs.push([k, v]);159   }160   return pairs;161 }162 163 function field(pairs, name) {164   const hit = pairs.find(([k]) => k === name);165   return hit ? hit[1] : undefined;166 }167 168 function stripControls(s) {169   return [...s]170     .filter((c) => c === "\n" || c === "\t" || !/\p{Cc}/u.test(c))171     .join("");172 }173 174 function licenseChecked(value) {175   if (value === undefined) return false;176   return ["on", "agree", "yes", "true", "1", "checked", "accept"].includes(177     value.trim().toLowerCase(),178   );179 }180 181 function validate(kind, pairs, maxTitle = MAX_TITLE, maxBody = MAX_BODY) {182   if (183     (field(pairs, "website") || "") !== "" ||184     (field(pairs, "honeypot") || "") !== ""185   ) {186     return { kind: "honeypot" };187   }188   for (const [k, v] of pairs) {189     if (k.includes("\0") || v.includes("\0")) return { kind: "reject" };190   }191   const rawTitle = field(pairs, "title") || "";192   const bodyName = kind === "pr-prompt" ? "prompt" : "body";193   let rawBody = field(pairs, bodyName);194   if (rawBody === undefined && kind === "pr-prompt") {195     rawBody = field(pairs, "body");196   }197   rawBody = rawBody || "";198   const title = stripControls(rawTitle);199   const body = stripControls(rawBody);200   const handle = stripControls(field(pairs, "handle") || "");201   const contact = stripControls(field(pairs, "contact") || "");202   if (title.trim() === "" || body.trim() === "") return { kind: "reject" };203   if (204     [...title].length > maxTitle ||205     [...body].length > maxBody ||206     [...handle].length > MAX_HANDLE ||207     [...contact].length > MAX_CONTACT208   ) {209     return { kind: "reject" };210   }211   if (!licenseChecked(field(pairs, "license"))) return { kind: "reject" };212   return { kind: "accept", title, body, handle, contact };213 }214 215 async function readCapped(request) {216   const declared = request.headers.get("content-length");217   if (declared !== null) {218     if (!/^\d+$/.test(declared.trim())) return { error: 400 };219     if (Number(declared) > MAX_BYTES) return { error: 413 };220   }221   if (!request.body) return { text: "" };222   const reader = request.body.getReader();223   const chunks = [];224   let total = 0;225   for (;;) {226     const { done, value } = await reader.read();227     if (done) break;228     total += value.byteLength;229     if (total > MAX_BYTES) {230       await reader.cancel();231       return { error: 413 };232     }233     chunks.push(value);234   }235   const merged = new Uint8Array(total);236   let at = 0;237   for (const chunk of chunks) {238     merged.set(chunk, at);239     at += chunk.byteLength;240   }241   try {242     return { text: new TextDecoder("utf-8", { fatal: true }).decode(merged) };243   } catch {244     return { error: 400 };245   }246 }247 248 async function saltedIpHash(ip, salt) {249   const date = new Date().toISOString().slice(0, 10);250   const bytes = encoder.encode(`v1|${ip}|${date}|${salt}`);251   const digest = await crypto.subtle.digest("SHA-256", bytes);252   return [...new Uint8Array(digest)]253     .map((b) => b.toString(16).padStart(2, "0"))254     .join("");255 }256 257 // Best-effort isolate-local limiter. Cloudflare's edge rate-limiting258 // rule for /inbox/* is the durable control; this still stops a single259 // hot isolate from storing unlimited submissions.260 const buckets = new Map();261 const MAX_RATE_KEYS = 10000;262 function rateAllowed(key, limit) {263   const now = Date.now();264   if (buckets.size >= MAX_RATE_KEYS) {265     for (const [k, v] of buckets) if (v.reset <= now) buckets.delete(k);266   }267   let b = buckets.get(key);268   if (!b || b.reset <= now) {269     // Fail closed for a new key when the isolate map is full; do not let270     // source-IP rotation grow it without bound (f0rge #116).271     if (!b && buckets.size >= MAX_RATE_KEYS) return false;272     b = { count: 0, reset: now + 3600_000 };273     buckets.set(key, b);274   }275   if (b.count >= limit) return false;276   b.count += 1;277   return true;278 }279 280 // R2 list returns at most 1000 objects per call. Paginate until the281 // configured quota is counted or the listing ends; a truncated page282 // without a cursor fails closed (f0rge #116).283 async function overQuota(bucket, limit) {284   if (!(limit > 0)) return true;285   let count = 0;286   let cursor;287   for (;;) {288     const page = await bucket.list({289       limit: Math.min(limit - count, 1000),290       cursor,291     });292     count += page.objects.length;293     if (count >= limit) return true;294     if (!page.truncated) return false;295     if (!page.cursor) return true;296     cursor = page.cursor;297   }298 }299 300 function nowRfc3339() {301   return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");302 }303 304 async function handlePost(context) {305   const request = context.request;306   const rawKind = context.params ? context.params.kind : "";307   const kind = Array.isArray(rawKind) ? rawKind[0] : rawKind;308   if (kind !== "issue" && kind !== "pr-prompt") {309     return errorPage(404, "Nothing here.", {}, false);310   }311   if (!originAllowed(request)) {312     return errorPage(403, "Bad origin.");313   }314   const ctype = (request.headers.get("content-type") || "").toLowerCase();315   if (!ctype.startsWith("application/x-www-form-urlencoded")) {316     return errorPage(400, "This endpoint accepts urlencoded forms only.");317   }318   const read = await readCapped(request);319   if (read.error) {320     return errorPage(read.error, read.error === 413 ? "Submission too large." : "Bad input.");321   }322   if (read.text.includes("\0")) return errorPage(400, "Bad input.");323   const pairs = parseForm(read.text);324   if (pairs === null) return errorPage(400, "Bad input.");325   const checked = validate(kind, pairs,326     context.env.INBOX_MAX_TITLE ? Number(context.env.INBOX_MAX_TITLE) : MAX_TITLE,327     context.env.INBOX_MAX_BODY ? Number(context.env.INBOX_MAX_BODY) : MAX_BODY);328   if (checked.kind === "honeypot") {329     return answer(200, ackPage(crypto.randomUUID()));330   }331   if (checked.kind === "reject") return errorPage(400, "Bad input.");332 333   const env = context.env || {};334   if (!env.INBOX) return errorPage(503, "Could not store that. Try later.");335   if (!env.INBOX_SALT) return errorPage(503, "Could not store that. Try later.");336   const ip = request.headers.get("cf-connecting-ip") || "unknown";337   const ipHash = await saltedIpHash(ip, env.INBOX_SALT);338   const rate = env.INBOX_RATE_PER_HOUR ? Number(env.INBOX_RATE_PER_HOUR) : 2;339   if (!rateAllowed(ipHash, rate)) return errorPage(429, "Slow down.");340   const quota = env.INBOX_QUOTA ? Number(env.INBOX_QUOTA) : 500;341   try {342     if (await overQuota(env.INBOX, quota)) {343       return errorPage(503, "Inbox full. Try later.");344     }345     const uuid = crypto.randomUUID();346     const doc = {347       type: kind,348       title: checked.title,349       body: checked.body,350       handle: checked.handle,351       contact: checked.contact,352       license_version: LICENSE_VERSION,353       received_at: nowRfc3339(),354       salted_ip_hash: ipHash,355     };356     await env.INBOX.put(`${uuid}.json`, JSON.stringify(doc) + "\n");357     return answer(200, ackPage(uuid));358   } catch {359     return errorPage(503, "Could not store that. Try later.");360   }361 }362 363 export async function onRequestGet() {364   return errorPage(405, "This endpoint accepts POST only.", { allow: "POST" }, false);365 }366 367 export async function onRequestPost(context) {368   try {369     return await handlePost(context);370   } catch {371     // Never echo or log submission text.372     return errorPage(503, "Could not store that. Try later.");373   }374 }