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 <noreply@anthropic.com>
This commit is contained in:
kappa
2026-01-29 10:02:35 +09:00
parent 3cfcb06f27
commit 40447952a9
2 changed files with 97 additions and 22 deletions

View File

@@ -1,6 +1,6 @@
import { Env, EmailMessage, ProvisionMessage, MessageBatch } from './types'; import { Env, EmailMessage, ProvisionMessage, MessageBatch } from './types';
import { sendMessage, setWebhook, getWebhookInfo } from './telegram'; import { sendMessage, setWebhook, getWebhookInfo } from './telegram';
import { handleWebhook } from './routes/webhook'; import { webhookRouter } from './routes/webhook';
import { apiRouter } from './routes/api'; import { apiRouter } from './routes/api';
import { handleHealthCheck } from './routes/health'; import { handleHealthCheck } from './routes/health';
import { parseBankSMS } from './services/bank-sms-parser'; import { parseBankSMS } from './services/bank-sms-parser';
@@ -72,8 +72,8 @@ app.get('/webhook-info', async (c) => {
// API routes - use Hono router // API routes - use Hono router
app.route('/api', apiRouter); app.route('/api', apiRouter);
// Telegram Webhook // Telegram Webhook - use Hono router with middleware
app.post('/webhook', (c) => handleWebhook(c.req.raw, c.env)); app.route('/webhook', webhookRouter);
// Root path // Root path
app.get('/', (c) => { app.get('/', (c) => {

View File

@@ -1,34 +1,109 @@
import type { Env } from '../types'; import { Hono } from 'hono';
import { validateWebhookRequest } from '../security'; import { createMiddleware } from 'hono/factory';
import type { Env, TelegramUpdate } from '../types';
import { timingSafeEqual } from '../security';
import { handleCallbackQuery } from './handlers/callback-handler'; import { handleCallbackQuery } from './handlers/callback-handler';
import { handleMessage } from './handlers/message-handler'; import { handleMessage } from './handlers/message-handler';
import { createLogger } from '../utils/logger'; import { createLogger } from '../utils/logger';
const logger = createLogger('webhook'); const logger = createLogger('webhook');
/** // Create Hono router
* Telegram Webhook 요청 처리 const webhook = new Hono<{ Bindings: Env }>();
*/
export async function handleWebhook(request: Request, env: Env): Promise<Response> {
const validation = await validateWebhookRequest(request, env);
if (!validation.valid) { // Telegram webhook authentication middleware
logger.error('검증 실패', new Error(validation.error || 'Unknown validation error')); const telegramAuth = createMiddleware<{ Bindings: Env }>(async (c, next) => {
return new Response(validation.error, { status: 401 }); // 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<TelegramUpdate>();
} 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 { try {
const update = validation.update!; // Handle callback query (inline button clicks)
if (update.callback_query) { if (update.callback_query) {
await handleCallbackQuery(env, update.callback_query); await handleCallbackQuery(c.env, update.callback_query);
return new Response('OK'); return c.json({ ok: true });
} }
await handleMessage(env, update); // Handle text message
return new Response('OK'); 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) { } catch (error) {
logger.error('메시지 처리 오류', error as Error); // CRITICAL: Always return 200 to Telegram to prevent retries
return new Response('Error', { status: 500 }); logger.error('Webhook processing error', error as Error, {
} updateId: update.update_id,
hasMessage: !!update.message,
hasCallback: !!update.callback_query
});
return c.json({ ok: true });
} }
});
export { webhook as webhookRouter };