Initial commit: Telegram bot with Cloudflare Workers
- OpenAI GPT-4o-mini with Function Calling - Cloudflare D1 for user profiles and message buffer - Sliding window (3 summaries max) for infinite context - Tools: weather, search, time, calculator - Workers AI fallback support - Webhook security with rate limiting Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
159
src/security.ts
Normal file
159
src/security.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { Env, TelegramUpdate } from './types';
|
||||
|
||||
// Telegram 서버 IP 대역 (2024년 기준)
|
||||
// https://core.telegram.org/bots/webhooks#the-short-version
|
||||
const TELEGRAM_IP_RANGES = [
|
||||
'149.154.160.0/20',
|
||||
'91.108.4.0/22',
|
||||
];
|
||||
|
||||
// CIDR 범위 체크 유틸
|
||||
function ipInCIDR(ip: string, cidr: string): boolean {
|
||||
const [range, bits] = cidr.split('/');
|
||||
const mask = ~(2 ** (32 - parseInt(bits)) - 1);
|
||||
|
||||
const ipNum = ip.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet), 0);
|
||||
const rangeNum = range.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet), 0);
|
||||
|
||||
return (ipNum & mask) === (rangeNum & mask);
|
||||
}
|
||||
|
||||
// IP 화이트리스트 검증
|
||||
function isValidTelegramIP(ip: string): boolean {
|
||||
return TELEGRAM_IP_RANGES.some(range => ipInCIDR(ip, range));
|
||||
}
|
||||
|
||||
// Webhook Secret Token 검증 (Timing-safe comparison)
|
||||
function isValidSecretToken(request: Request, expectedSecret: string): boolean {
|
||||
const secretHeader = request.headers.get('X-Telegram-Bot-Api-Secret-Token');
|
||||
|
||||
if (!secretHeader || !expectedSecret) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Timing-safe comparison
|
||||
if (secretHeader.length !== expectedSecret.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let result = 0;
|
||||
for (let i = 0; i < secretHeader.length; i++) {
|
||||
result |= secretHeader.charCodeAt(i) ^ expectedSecret.charCodeAt(i);
|
||||
}
|
||||
|
||||
return result === 0;
|
||||
}
|
||||
|
||||
// 요청 본문 검증
|
||||
function isValidRequestBody(body: unknown): body is TelegramUpdate {
|
||||
return (
|
||||
body !== null &&
|
||||
typeof body === 'object' &&
|
||||
'update_id' in body &&
|
||||
typeof (body as TelegramUpdate).update_id === 'number'
|
||||
);
|
||||
}
|
||||
|
||||
// 타임스탬프 검증 (리플레이 공격 방지)
|
||||
function isRecentUpdate(message: TelegramUpdate['message']): boolean {
|
||||
if (!message?.date) return true; // 메시지가 없으면 통과
|
||||
|
||||
const messageTime = message.date * 1000; // Unix timestamp to ms
|
||||
const now = Date.now();
|
||||
const maxAge = 60 * 1000; // 60초
|
||||
|
||||
return now - messageTime < maxAge;
|
||||
}
|
||||
|
||||
export interface SecurityCheckResult {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
update?: TelegramUpdate;
|
||||
}
|
||||
|
||||
// 통합 보안 검증
|
||||
export async function validateWebhookRequest(
|
||||
request: Request,
|
||||
env: Env
|
||||
): Promise<SecurityCheckResult> {
|
||||
// 1. HTTP 메서드 검증
|
||||
if (request.method !== 'POST') {
|
||||
return { valid: false, error: 'Method not allowed' };
|
||||
}
|
||||
|
||||
// 2. Content-Type 검증
|
||||
const contentType = request.headers.get('Content-Type');
|
||||
if (!contentType?.includes('application/json')) {
|
||||
return { valid: false, error: 'Invalid content type' };
|
||||
}
|
||||
|
||||
// 3. Secret Token 검증 (필수)
|
||||
if (env.WEBHOOK_SECRET) {
|
||||
if (!isValidSecretToken(request, env.WEBHOOK_SECRET)) {
|
||||
console.error('Invalid webhook secret token');
|
||||
return { valid: false, error: 'Invalid secret token' };
|
||||
}
|
||||
} else {
|
||||
console.warn('WEBHOOK_SECRET not configured - skipping token validation');
|
||||
}
|
||||
|
||||
// 4. IP 화이트리스트 검증 (선택적 - CF에서는 CF-Connecting-IP 사용)
|
||||
const clientIP = request.headers.get('CF-Connecting-IP');
|
||||
if (clientIP && !isValidTelegramIP(clientIP)) {
|
||||
// 경고만 로그 (Cloudflare 프록시 환경에서는 정확하지 않을 수 있음)
|
||||
console.warn(`Request from non-Telegram IP: ${clientIP}`);
|
||||
}
|
||||
|
||||
// 5. 요청 본문 파싱 및 검증
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return { valid: false, error: 'Invalid JSON body' };
|
||||
}
|
||||
|
||||
if (!isValidRequestBody(body)) {
|
||||
return { valid: false, error: 'Invalid request body structure' };
|
||||
}
|
||||
|
||||
// 6. 타임스탬프 검증 (리플레이 공격 방지)
|
||||
if (!isRecentUpdate(body.message)) {
|
||||
return { valid: false, error: 'Message too old' };
|
||||
}
|
||||
|
||||
return { valid: true, update: body };
|
||||
}
|
||||
|
||||
// Rate Limiting
|
||||
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
|
||||
|
||||
export function checkRateLimit(
|
||||
userId: string,
|
||||
maxRequests: number = 30,
|
||||
windowMs: number = 60000
|
||||
): boolean {
|
||||
const now = Date.now();
|
||||
const userLimit = rateLimitMap.get(userId);
|
||||
|
||||
if (!userLimit || now > userLimit.resetAt) {
|
||||
rateLimitMap.set(userId, { count: 1, resetAt: now + windowMs });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (userLimit.count >= maxRequests) {
|
||||
return false;
|
||||
}
|
||||
|
||||
userLimit.count++;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Rate limit 정리 (메모리 관리)
|
||||
export function cleanupRateLimits(): void {
|
||||
const now = Date.now();
|
||||
for (const [key, value] of rateLimitMap.entries()) {
|
||||
if (now > value.resetAt) {
|
||||
rateLimitMap.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user