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:
kappa
2026-01-14 13:00:44 +09:00
commit 1e71e035e7
15 changed files with 2272 additions and 0 deletions

159
src/security.ts Normal file
View 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);
}
}
}