Files
arah2/bot/src/webhook.ts
T

115 lines
5.8 KiB
TypeScript

/*
* webhook.ts — the Worker entry (D-11, D-14, D-16, D-17).
* --------------------------------------------------------
* Routes exactly two paths:
* POST /telegram/webhook → grammY's webhook adapter (cloudflare-mod) with
* the configured secret token — Telegram's updates must carry
* X-Telegram-Bot-Api-Secret-Token = env.WEBHOOK_SECRET or they are 401'd
* by a constant-time compare BEFORE any bot processing or KV touch
* (T-02-03). FAIL-CLOSED (WR-02): if the WEBHOOK_SECRET binding is
* missing/empty at request time, the route rejects outright — grammY's
* compareSecretToken silently accepts EVERY request when its secret is
* undefined, so an unarmed binding must never reach it. 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 — 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 — best-effort under KV eventual consistency, see
* sessions.ts) — this endpoint adds no extra branch and no extra write
* path (T-02-06).
* Anything else → 404.
*
* D-14: the ONLY log statements are category-only — malformed/tampered/stale
* on the initData gate, misconfigured on the fail-closed webhook gate —
* never the raw payload, never user ids, never timestamps. Rejection
* responses carry the category only.
* D-17: no secret is ever logged or echoed; the bot token / webhook secret
* exist only inside `env` at runtime.
*/
import { webhookCallback } from "grammy";
import type { Env } from "./config.ts";
import { createBot } from "./bot/bot.ts";
import { mintSession } from "./kv/sessions.ts";
import { validateInitData } from "./security/initdata.ts";
export default {
async fetch(req: Request, env: Env, _ctx: ExecutionContext): Promise<Response> {
const url = new URL(req.url);
if (url.pathname === "/telegram/webhook") {
// WR-02 fail-closed: grammY's compareSecretToken accepts EVERY request
// when its secret is undefined (research-confirmed runtime behavior,
// convenience/webhook.js). A WEBHOOK_SECRET binding left unarmed after
// deploy would therefore silently disable webhook authentication and
// admit unsigned POSTs that inject fabricated updates. This guard runs
// BEFORE createBot/webhookCallback; the rejection is a constant 500
// and the log carries the category only.
if (!env.WEBHOOK_SECRET) {
console.log("[webhook] rejected: misconfigured");
return new Response("Server misconfigured", { status: 500 });
}
const bot = createBot(env); // env-bound token → per-request instantiation
const cb = webhookCallback(bot, "cloudflare-mod", undefined, undefined, env.WEBHOOK_SECRET);
return cb(req);
}
if (url.pathname === "/api/session") {
return handleSession(req, env);
}
return new Response("Not found", { status: 404 });
},
} 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 —
* nothing else; unvalidated identities get a category-only 401 (T-02-01). */
async function handleSession(req: Request, env: Env): Promise<Response> {
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
return new Response(JSON.stringify({ error: result.reason }), {
status: 401,
headers: { "content-type": "application/json" },
});
}
const token = await mintSession(env.chat_sessions, {
user_id: result.user.id,
chat_id: result.chat?.id ?? result.user.id, // private chat: Telegram conflates; claim surface unchanged (D-08)
issued_at: Math.floor(Date.now() / 1000),
language_code: result.languageCode,
});
return new Response(JSON.stringify({ token }), { status: 200, headers: { "content-type": "application/json" } });
}