fix(02-bot-skeleton-initdata-security): WR-01 cap /api/session body at 4 KB before JSON split (413, category-only logs)
This commit is contained in:
+33
-1
@@ -9,7 +9,10 @@
|
|||||||
* (T-02-03). The bot is instantiated per request because the token only
|
* (T-02-03). The bot is instantiated per request because the token only
|
||||||
* arrives with `env` (research-verified Workers pattern).
|
* arrives with `env` (research-verified Workers pattern).
|
||||||
* POST /api/session → the phase's security gate (T-02-01): the caller
|
* 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
|
* 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) —
|
* 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).
|
* this endpoint adds no extra branch and no extra write path (T-02-06).
|
||||||
@@ -42,11 +45,40 @@ export default {
|
|||||||
},
|
},
|
||||||
} satisfies ExportedHandler<Env>;
|
} satisfies ExportedHandler<Env>;
|
||||||
|
|
||||||
|
/** 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 —
|
/** Phase-2 security gate: initData in, one-time opaque session token out —
|
||||||
* nothing else; unvalidated identities get a category-only 401 (T-02-01). */
|
* nothing else; unvalidated identities get a category-only 401 (T-02-01). */
|
||||||
async function handleSession(req: Request, env: Env): Promise<Response> {
|
async function handleSession(req: Request, env: Env): Promise<Response> {
|
||||||
if (req.method !== "POST") return new Response("Method not allowed", { status: 405 });
|
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
|
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);
|
const result = await validateInitData(raw, env.BOT_TOKEN);
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
console.log(`[initdata] rejected: ${result.reason}`); // D-14: category ONLY — no payload, no ids, no timestamps
|
console.log(`[initdata] rejected: ${result.reason}`); // D-14: category ONLY — no payload, no ids, no timestamps
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user