forked from c-fu/arah2
- handlers/location-fallback.ts: requestLocationFallback (ReplyKeyboardMarkup request_location:true copy from strings.ts), recordSharedPin, registerLocationFallback (filters message.location, T-03-03) - strings.ts: awaitingPin/shareLocationButton/pinReceived in en+ms (CS1 parity) — all bot copy stays in strings.ts - bot.ts: createBot threads env.chat_sessions into the fallback handler; optional client fetch seam for tests; one-caller rule documented - webhook.ts: GET /api/location relay + POST /api/location-fallback trigger, both resolve chat_id & locale from getClaimByToken CLAIM, never client input (T-03-01/26/27); telegram-send failure is category-only 502 (T-03-28) - location-pin.test.mjs: LP1–LP14 (TTL contract, range guard, cross-user isolation both routes, locale split); LP12/LP13 patch node-fetch's cached default (grammy's node shim transport), then restore - CS4 scope untouched (handlers/ not scanned); CS1 parity automatic
203 lines
10 KiB
TypeScript
203 lines
10 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).
|
|
* GET /api/location → GEO-02 relay (T-03-01): the Mini App polls
|
|
* here with its memory-held Bearer token; the token resolves through
|
|
* getClaimByToken and the KV key loc:<chat_id> derives from the
|
|
* CLAIM — never from a client-supplied id — so one user's token can
|
|
* never read another user's pin. No pin or expired token → honest
|
|
* nulls / 401, nothing fabricated.
|
|
* POST /api/location-fallback → GEO-02 keyboard trigger (T-03-26): the
|
|
* app's denied card asks the BOT to send the request_location reply
|
|
* keyboard into the user's own chat. The target chat_id AND the
|
|
* locale come from the resolved claim (T-03-27) — the request body
|
|
* is never read, and no chat id or language is accepted from the
|
|
* client. A rejected Telegram send is a category-only 502 (T-03-28).
|
|
* 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 { requestLocationFallback } from "./bot/handlers/location-fallback.ts";
|
|
import { mintSession, getClaimByToken } from "./kv/sessions.ts";
|
|
import { getPin } from "./kv/location-pin.ts";
|
|
import { validateInitData } from "./security/initdata.ts";
|
|
import { pickLocale } from "./i18n/strings.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);
|
|
}
|
|
if (url.pathname === "/api/location") {
|
|
return handleLocation(req, env);
|
|
}
|
|
if (url.pathname === "/api/location-fallback") {
|
|
return handleLocationFallback(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" } });
|
|
}
|
|
|
|
/** GEO-02 relay (T-03-01): the Mini App polls here with its memory-held
|
|
* Bearer token to pick up a pin the user shared in chat. V4 Access
|
|
* Control (RESEARCH §Security Domain): the bearer token is resolved via
|
|
* getClaimByToken; a missing/expired token is a 401. The KV key is
|
|
* derived from the CLAIM's chat_id — NEVER from a client-supplied chat
|
|
* id, so one user's token can never read another user's pin. */
|
|
async function handleLocation(req: Request, env: Env): Promise<Response> {
|
|
if (req.method !== "GET") return new Response("Method not allowed", { status: 405 });
|
|
const header = req.headers.get("authorization") ?? "";
|
|
const match = header.match(/^Bearer ([A-Za-z0-9-]+)$/);
|
|
const token = match !== null && match[1] ? match[1] : null;
|
|
const claim = token === null ? null : await getClaimByToken(env.chat_sessions, token);
|
|
if (claim === null) {
|
|
console.log("[location] rejected: unauthorized"); // category ONLY (D-14)
|
|
return new Response(JSON.stringify({ error: "unauthorized" }), {
|
|
status: 401,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
}
|
|
const pin = await getPin(env.chat_sessions, claim.chat_id);
|
|
return new Response(JSON.stringify({ pin }), {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
}
|
|
|
|
/** GEO-02 trigger (T-03-26): the Mini App's denied card taps ASK VIA CHAT
|
|
* and POSTs here; the Worker asks the BOT to send the request_location
|
|
* reply keyboard in the user's own chat. Same V4 access control as
|
|
* handleLocation — the target chat_id comes from the resolved CLAIM,
|
|
* never from the request body, so one user's token can never push a
|
|
* keyboard into another user's chat.
|
|
*
|
|
* The locale comes from `claim.language_code`, which mintSession already
|
|
* recorded from the validated initData — the Worker does NOT re-derive it
|
|
* from client input, and no language is accepted from the request body
|
|
* (T-03-27). */
|
|
async function handleLocationFallback(req: Request, env: Env): Promise<Response> {
|
|
if (req.method !== "POST") return new Response("Method not allowed", { status: 405 });
|
|
const header = req.headers.get("authorization") ?? "";
|
|
const match = header.match(/^Bearer ([A-Za-z0-9-]+)$/);
|
|
const token = match !== null && match[1] ? match[1] : null;
|
|
const claim = token === null ? null : await getClaimByToken(env.chat_sessions, token);
|
|
if (claim === null) {
|
|
console.log("[location-fallback] rejected: unauthorized"); // category ONLY (D-14)
|
|
return new Response(JSON.stringify({ error: "unauthorized" }), {
|
|
status: 401,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
}
|
|
try {
|
|
await requestLocationFallback(createBot(env), claim.chat_id, pickLocale(claim.language_code));
|
|
return new Response(JSON.stringify({ ok: true }), {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
} catch {
|
|
// Telegram rejected the send (user blocked the bot, chat not found, rate
|
|
// limit). Category only — never the chat id, never the error text (D-14, T-03-28).
|
|
console.log("[location-fallback] failed: telegram-send");
|
|
return new Response(JSON.stringify({ error: "unavailable" }), {
|
|
status: 502,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
}
|
|
}
|