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:
@@ -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) => {
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user