diff --git a/bot/src/webhook.ts b/bot/src/webhook.ts index 2ed1018..e75b721 100644 --- a/bot/src/webhook.ts +++ b/bot/src/webhook.ts @@ -9,7 +9,10 @@ * (T-02-03). The bot is instantiated per request because the token only * arrives with `env` (research-verified Workers pattern). * POST /api/session → the phase's security gate (T-02-01): the caller - * posts a verbatim Telegram.WebApp.initData string; only an HMAC-validated, + * posts a verbatim Telegram.WebApp.initData string — capped at 4 KB + * (WR-01: over-cap bodies are rejected 413 before any parsing; real + * initData is ≪ 1 KB, so this touches no legitimate client); only an + * HMAC-validated, * fresh identity obtains a one-time opaque session token (D-06). Rotation * of any prior token for the chat is mintSession's own invariant (D-07) — * this endpoint adds no extra branch and no extra write path (T-02-06). @@ -42,11 +45,40 @@ export default { }, } satisfies ExportedHandler; +/** initData is Telegram's verbatim WebApp string — a few hundred bytes on + * the wire (research: known-good fixtures ≪ 1 KB). 4 KB is generous + * headroom; anything beyond is abuse aimed at the isolate's memory/CPU + * budget (WR-01) — unbounded, `raw.split("&")` would allocate millions of + * entries per multi-MB request. */ +const MAX_INITDATA_CHARS = 4096; + +/** Category-only 413 for over-cap bodies (WR-01) — the error carries the + * D-14 category, nothing of the request payload. */ +function oversizeBody(): Response { + return new Response(JSON.stringify({ error: "malformed" }), { + status: 413, + headers: { "content-type": "application/json" }, + }); +} + /** Phase-2 security gate: initData in, one-time opaque session token out — * nothing else; unvalidated identities get a category-only 401 (T-02-01). */ async function handleSession(req: Request, env: Env): Promise { if (req.method !== "POST") return new Response("Method not allowed", { status: 405 }); + // WR-01: reject over-cap bodies before any parse work. A declared + // Content-Length lets us say no without buffering the stream; lying or + // absent headers are covered by the post-read length check. The character + // bound is what governs the `split("&")` allocation below — the DoS + // surface this cap exists to close. + if (Number(req.headers.get("content-length") ?? "0") > MAX_INITDATA_CHARS) { + console.log("[initdata] rejected: malformed"); // category ONLY — no payload, no ids, no timestamps + return oversizeBody(); + } const raw = await req.text(); // the verbatim Telegram.WebApp.initData string + if (raw.length > MAX_INITDATA_CHARS) { + console.log("[initdata] rejected: malformed"); // category ONLY — no payload, no ids, no timestamps + return oversizeBody(); + } const result = await validateInitData(raw, env.BOT_TOKEN); if (!result.ok) { console.log(`[initdata] rejected: ${result.reason}`); // D-14: category ONLY — no payload, no ids, no timestamps diff --git a/bot/test/webhook.test.mjs b/bot/test/webhook.test.mjs new file mode 100644 index 0000000..7276bdc --- /dev/null +++ b/bot/test/webhook.test.mjs @@ -0,0 +1,187 @@ +#!/usr/bin/env node +/* + * webhook.test.mjs — the Worker entry (/api/session + /telegram/webhook gate). + * --------------------------------------------------------------------------- + * Source-under-test: src/webhook.ts, exercised through its exported + * `fetch(request, env)` with undici Request/Response (Node 20+ ships both; + * the code paths here are platform-neutral, no workerd needed). + * + * Scope note (offline-only): grammY's cloudflare-mod adapter calls + * `bot.init()` (a live getMe) BEFORE its own secret-token compare, so no + * committed case may let an update REACH the framework. Do not add a case + * that posts a well-formed update with an ARMED secret — it would call the + * live Bot API. Armed-secret behavior is verified by the live smokes in + * plan 02-03 instead. + * + * Cases (WR-01 body cap, T-02-01 gate): + * WK1 POST valid initData under the cap → 200 {token: uuid}; + * exactly two TTL-carrying KV writes (900 s) + * WK2 POST an over-cap (4 KB+) but VALIDLY SIGNED initData → 413 + * {error:"malformed"}, zero KV writes, log is category-only + * ("[initdata] rejected: malformed") and echoes no payload + * WK2b lying Content-Length (declares 100, ships 5000+) → 413 via the + * post-read length check (the header blind spot in WR-01) + * WK3 declared-length 999999 + unreadable body stream → 413 resolved + * WITHOUT pulling the stream (the pre-read check refuses to buffer) + * WK4 GET /api/session → 405 (the cap logic never runs on GET) + */ +import { strict as assert } from "node:assert"; +import { createHmac } from "node:crypto"; +import { test } from "./harness.mjs"; + +const TEST_TOKEN = "12345:TESTTOKEN"; // same signer token as initdata.test.mjs + +/* ————— node:crypto signing helpers (test-side, independent path) ————— */ + +function dataCheckString(fields) { + return fields + .filter(([key]) => key !== "hash" && key !== "signature") + .slice() + .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)) + .map(([key, value]) => `${key}=${value}`) + .join("\n"); +} + +function telegramSecretKey(botToken) { + return createHmac("sha256", "WebAppData").update(botToken, "utf8").digest(); +} + +function buildInitData(fields) { + const hash = createHmac("sha256", telegramSecretKey(TEST_TOKEN)) + .update(dataCheckString(fields), "utf8") + .digest("hex"); + const wire = fields + .map(([key, value]) => `${key}=${encodeURIComponent(value)}`) + .join("&"); + return `${wire}&hash=${hash}`; +} + +function signedUser(authDateSeconds, extraFields = []) { + return buildInitData([ + ...extraFields, + ["user", JSON.stringify({ id: 42, first_name: "Zul", language_code: "ms" })], + ["auth_date", String(authDateSeconds)], + ]); +} + +/* ————— env / request harness ————— */ + +function makeKV() { + const map = new Map(); + const ttlLog = []; + return { + async get(key) { + return map.has(key) ? map.get(key) : null; + }, + async put(key, value, opts) { + ttlLog.push({ key, expirationTtl: opts?.expirationTtl ?? null }); + map.set(key, value); + }, + async delete(key) { + map.delete(key); + }, + map, + ttlLog, + }; +} + +function makeEnv(kv) { + return { BOT_TOKEN: TEST_TOKEN, WEBHOOK_SECRET: "wk-test-secret", chat_sessions: kv }; +} + +const handler = await import("../src/webhook.ts").then((m) => m.default.fetch); + +function postSession(body, headers = {}, kvOverride = null) { + const kv = kvOverride ?? makeKV(); + const req = new Request("https://host/api/session", { + method: "POST", + ...(body !== undefined ? { body, headers } : { headers }), + }); + return { req, kv, resP: handler(req, makeEnv(kv), {}) }; +} + +/* ————— WK1 — the happy path still works under the cap ————— */ + +test("WK1 POST valid initData (under cap) → 200 {token: uuid}; exactly two 900s KV writes", async () => { + const { kv, resP } = postSession(signedUser(Math.floor(Date.now() / 1000))); + const res = await resP; + assert.equal(res.status, 200, `expected 200, got ${res.status}: ${await res.clone().text()}`); + const body = await res.json(); + assert.match(body.token, /^[0-9a-f-]{36}$/, "opaque UUID token (D-06)"); + assert.equal(kv.ttlLog.length, 2, "mint writes exactly two keys"); + for (const entry of kv.ttlLog) assert.strictEqual(entry.expirationTtl, 900, "TTL-only (PRIV-02)"); +}); + +/* ————— WK2 — over-cap signed body → 413, category-only, no side effects ————— */ + +test("WK2 POST 4KB+ validly-signed initData → 413 malformed; zero KV writes; log carries ONLY the category", async () => { + const kv = makeKV(); + const seen = []; + const orig = console.log; + console.log = (...args) => seen.push(args.map((a) => String(a)).join(" ")); + let res; + try { + // validly signed — proves the cap fires before/instead of validation, + // and that a real signature cannot buy the body past the cap + const overCapSigned = signedUser(Math.floor(Date.now() / 1000), [["start_param", "p".repeat(4900)]]); + assert.ok(overCapSigned.length > 4096, "fixture must exceed the cap"); + res = await handler( + new Request("https://host/api/session", { method: "POST", body: overCapSigned }), + makeEnv(kv), + {}, + ); + } finally { + console.log = orig; + } + assert.equal(res.status, 413, `expected 413, got ${res.status}`); + assert.deepEqual(await res.json(), { error: "malformed" }, "category-only body (D-14)"); + assert.equal(kv.map.size, 0, "no session minted"); + assert.equal(kv.ttlLog.length, 0, "no KV writes at all"); + assert.deepEqual(seen, ["[initdata] rejected: malformed"], "log is the category ONLY — no payload fragment, no ids"); +}); + +/* ————— WK2b/WK3 — the two blind spots of a naive body cap ————— */ + +/* ————— WK2b/WK3 — the two blind spots of a naive body cap ————— */ + +test("WK2b lying Content-Length (declares 100, ships 5000+) → 413 via the post-read check", async () => { + const { kv, resP } = postSession("a=".concat("b".repeat(5000)), { "content-length": "100" }); + const res = await resP; + assert.equal(res.status, 413, "the post-read length check must backstop a lying header"); + assert.deepEqual(await res.json(), { error: "malformed" }); + assert.equal(kv.map.size, 0); +}); + +test("WK3 declared-length 999999 + unreadable body stream → handler RESOLVES 413 (must not await the body)", async () => { + // The stream errors if any consumer pulls it. The assertion that matters: + // handler() resolves with 413 — had the handler awaited req.text() on a + // broken stream, the fetch call would REJECT instead. (Node/undici pulls + // stream bodies opportunistically after construction, so pull-tracking is + // implementation noise, not boundable here; the resolution outcome is.) + let pulled = false; + const unreadable = new ReadableStream({ + pull(controller) { + pulled = true; + controller.error(new Error("body must never be read (WR-01 pre-read path)")); + }, + }); + void pulled; + const kv = makeKV(); + const req = new Request("https://host/api/session", { + method: "POST", + headers: { "content-length": "999999" }, + body: unreadable, + duplex: "half", // required by undici for stream bodies + }); + const res = await handler(req, makeEnv(kv), {}); + assert.equal(res.status, 413, "resolve 413 — the pre-read check must refuse to buffer the declared-oversize body"); + assert.equal(kv.map.size, 0); +}); + +/* ————— WK4 — method gate ————— */ + +test("WK4 GET /api/session → 405", async () => { + const req = new Request("https://host/api/session", { method: "GET" }); + const res = await handler(req, makeEnv(makeKV()), {}); + assert.equal(res.status, 405); +});