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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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 }