From 40447952a9a3cc9d48a86ddc9b71f955152e51fd Mon Sep 17 00:00:00 2001 From: kappa Date: Thu, 29 Jan 2026 10:02:35 +0900 Subject: [PATCH] refactor: migrate webhook to Hono router with auth middleware webhook.ts: - Convert handleWebhook() to Hono router pattern - Create telegramAuth middleware with security validations: - HTTP method validation (POST only) - Content-Type validation (application/json) - Timing-safe secret token comparison - Timestamp validation (5-min replay attack prevention) - Request body structure validation - Always return 200 to Telegram (prevents retry storms) - Structured logging with context index.ts: - Import webhookRouter instead of handleWebhook - Use app.route('/webhook', webhookRouter) Benefits: - Consistent Hono pattern across all routes - Reusable auth middleware - Better separation of concerns Co-Authored-By: Claude Opus 4.5 --- src/index.ts | 6 +-- src/routes/webhook.ts | 113 +++++++++++++++++++++++++++++++++++------- 2 files changed, 97 insertions(+), 22 deletions(-) diff --git a/src/index.ts b/src/index.ts index 2f3d31a..007df53 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ import { Env, EmailMessage, ProvisionMessage, MessageBatch } from './types'; import { sendMessage, setWebhook, getWebhookInfo } from './telegram'; -import { handleWebhook } from './routes/webhook'; +import { webhookRouter } from './routes/webhook'; import { apiRouter } from './routes/api'; import { handleHealthCheck } from './routes/health'; import { parseBankSMS } from './services/bank-sms-parser'; @@ -72,8 +72,8 @@ app.get('/webhook-info', async (c) => { // API routes - use Hono router app.route('/api', apiRouter); -// Telegram Webhook -app.post('/webhook', (c) => handleWebhook(c.req.raw, c.env)); +// Telegram Webhook - use Hono router with middleware +app.route('/webhook', webhookRouter); // Root path app.get('/', (c) => { diff --git a/src/routes/webhook.ts b/src/routes/webhook.ts index 74c302f..493de83 100644 --- a/src/routes/webhook.ts +++ b/src/routes/webhook.ts @@ -1,34 +1,109 @@ -import type { Env } from '../types'; -import { validateWebhookRequest } from '../security'; +import { Hono } from 'hono'; +import { createMiddleware } from 'hono/factory'; +import type { Env, TelegramUpdate } from '../types'; +import { timingSafeEqual } from '../security'; import { handleCallbackQuery } from './handlers/callback-handler'; import { handleMessage } from './handlers/message-handler'; import { createLogger } from '../utils/logger'; const logger = createLogger('webhook'); -/** - * Telegram Webhook 요청 처리 - */ -export async function handleWebhook(request: Request, env: Env): Promise { - const validation = await validateWebhookRequest(request, env); +// Create Hono router +const webhook = new Hono<{ Bindings: Env }>(); - if (!validation.valid) { - logger.error('검증 실패', new Error(validation.error || 'Unknown validation error')); - return new Response(validation.error, { status: 401 }); +// Telegram webhook authentication middleware +const telegramAuth = createMiddleware<{ Bindings: Env }>(async (c, next) => { + // 1. HTTP Method validation + if (c.req.method !== 'POST') { + logger.warn('Invalid HTTP method', { method: c.req.method }); + return c.text('Method not allowed', 405); + } + + // 2. Content-Type validation + const contentType = c.req.header('Content-Type'); + if (!contentType?.includes('application/json')) { + logger.warn('Invalid content type', { contentType }); + return c.text('Invalid content type', 400); + } + + // 3. Secret Token validation (timing-safe) + const secretToken = c.req.header('X-Telegram-Bot-Api-Secret-Token'); + + if (!c.env.WEBHOOK_SECRET) { + logger.error('WEBHOOK_SECRET not configured', new Error('Missing WEBHOOK_SECRET')); + return c.text('Server configuration error', 500); + } + + if (!timingSafeEqual(secretToken, c.env.WEBHOOK_SECRET)) { + logger.warn('Invalid webhook secret token'); + return c.text('Unauthorized', 401); + } + + // 4. IP validation (optional warning only) + const clientIP = c.req.header('CF-Connecting-IP'); + if (clientIP) { + // Log for monitoring, but don't block (Cloudflare proxy may change IPs) + logger.debug('Request from IP', { clientIP }); + } + + return next(); +}); + +// Webhook endpoint with auth middleware +webhook.post('/', telegramAuth, async (c) => { + let update: TelegramUpdate; + + try { + update = await c.req.json(); + } catch (error) { + logger.error('JSON parsing error', error as Error); + return c.json({ ok: true }); // Return 200 to Telegram anyway + } + + // Validate request body structure + if (!update || typeof update.update_id !== 'number') { + logger.warn('Invalid update structure', { updateKeys: update ? Object.keys(update) : [] }); + return c.json({ ok: true }); // Return 200 to Telegram anyway + } + + // Timestamp validation (5 minutes) - replay attack prevention + if (update.message?.date) { + const messageTime = update.message.date * 1000; + const now = Date.now(); + const MAX_AGE_MS = 5 * 60 * 1000; + + if (now - messageTime > MAX_AGE_MS) { + logger.warn('Message too old', { messageAge: Math.floor((now - messageTime) / 1000) }); + return c.json({ ok: true }); // Return 200 to Telegram anyway + } } try { - const update = validation.update!; - + // Handle callback query (inline button clicks) if (update.callback_query) { - await handleCallbackQuery(env, update.callback_query); - return new Response('OK'); + await handleCallbackQuery(c.env, update.callback_query); + return c.json({ ok: true }); } - await handleMessage(env, update); - return new Response('OK'); + // Handle text message + if (update.message) { + await handleMessage(c.env, update); + return c.json({ ok: true }); + } + + // Unknown update type (e.g., edited_message, channel_post) + logger.debug('Unknown update type', { updateKeys: Object.keys(update) }); + return c.json({ ok: true }); + } catch (error) { - logger.error('메시지 처리 오류', error as Error); - return new Response('Error', { status: 500 }); + // CRITICAL: Always return 200 to Telegram to prevent retries + logger.error('Webhook processing error', error as Error, { + updateId: update.update_id, + hasMessage: !!update.message, + hasCallback: !!update.callback_query + }); + return c.json({ ok: true }); } -} \ No newline at end of file +}); + +export { webhook as webhookRouter };