From 372ed6401b54e4815ad60c7f06ec24025b7e17b2 Mon Sep 17 00:00:00 2001 From: salvacybersec Date: Thu, 13 Nov 2025 03:40:05 +0300 Subject: [PATCH] api key and security --- API.md | 413 +++++++++++++++++++++++++++++++++++++++++++ README.md | 71 ++++++-- SECURITY.md | 212 ++++++++++++++++++++++ config/security.yaml | 53 ++++++ src/database.py | 53 +++++- src/security.py | 334 ++++++++++++++++++++++++++++++++++ src/web_server.py | 95 +++++++++- 7 files changed, 1210 insertions(+), 21 deletions(-) create mode 100644 API.md create mode 100644 SECURITY.md create mode 100644 config/security.yaml create mode 100644 src/security.py diff --git a/API.md b/API.md new file mode 100644 index 0000000..4072777 --- /dev/null +++ b/API.md @@ -0,0 +1,413 @@ +# API Dokümantasyonu + +YouTube Transcript RSS Feed API'si, YouTube kanallarının video transcript'lerini RSS/Atom feed formatında sunar. + +## Base URL + +``` +http://localhost:5000 +``` + +Production için base URL değişebilir. + +## Authentication + +**Tüm endpoint'ler API key gerektirir.** + +API key'i iki şekilde gönderebilirsiniz: + +### 1. HTTP Header (Önerilen) + +```http +X-API-Key: your_api_key_here +``` + +### 2. Query Parameter + +``` +?api_key=your_api_key_here +``` + +### API Key Alma + +API key'ler `config/security.yaml` dosyasından yönetilir. Yeni bir API key eklemek için: + +```yaml +security: + api_keys: + your_api_key_here: + name: "Your API Key Name" + rate_limit: 100 # Dakikada maksimum istek + enabled: true +``` + +### Hata Yanıtları + +**401 Unauthorized** - API key eksik veya geçersiz: +```json +{ + "error": "Geçersiz veya eksik API key", + "message": "X-API-Key header veya api_key query parametresi gerekli" +} +``` + +**429 Too Many Requests** - Rate limit aşıldı: +```json +{ + "error": "Rate limit aşıldı", + "message": "Dakikada 100 istek limiti", + "retry_after": 60 +} +``` + +## Rate Limiting + +Her API key için dakikada maksimum istek sayısı tanımlanır. Rate limit bilgisi response header'ında döner: + +``` +X-RateLimit-Remaining: 45 +``` + +Rate limit aşıldığında `429 Too Many Requests` hatası döner. + +## Endpoints + +### 1. RSS/Atom Feed Oluştur + +YouTube kanalı için transcript feed'i oluşturur. + +**Endpoint:** `GET /` + +**Query Parameters:** + +| Parametre | Tip | Zorunlu | Açıklama | +|-----------|-----|---------|----------| +| `api_key` | string | ✅ | API key (header ile de gönderilebilir) | +| `channel_id` | string | ⚠️* | YouTube Channel ID (UC ile başlar, 24 karakter) | +| `channel` | string | ⚠️* | Channel handle (@username veya username) | +| `channel_url` | string | ⚠️* | Full YouTube channel URL | +| `format` | string | ❌ | Feed formatı: `Atom` (varsayılan) veya `Rss` | +| `max_items` | integer | ❌ | Maksimum video sayısı (varsayılan: 50, max: 500) | + +\* `channel_id`, `channel` veya `channel_url` parametrelerinden biri zorunludur. + +**Örnek İstekler:** + +```bash +# Channel ID ile +curl -H "X-API-Key: demo_key_12345" \ + "http://localhost:5000/?channel_id=UC9h8BDcXwkhZtnqoQJ7PggA&format=Atom" + +# Channel handle ile +curl -H "X-API-Key: demo_key_12345" \ + "http://localhost:5000/?channel=@tavakfi&format=Atom" + +# Channel URL ile +curl -H "X-API-Key: demo_key_12345" \ + "http://localhost:5000/?channel_url=https://www.youtube.com/@tavakfi&format=Atom&max_items=100" + +# Query parametresi ile API key +curl "http://localhost:5000/?channel_id=UC9h8BDcXwkhZtnqoQJ7PggA&api_key=demo_key_12345&format=Rss" +``` + +**Başarılı Yanıt:** + +**Atom Format:** +```xml + + + UC9h8BDcXwkhZtnqoQJ7PggA + YouTube Transcript Feed - UC9h8BDcXwkhZtnqoQJ7PggA + 2025-01-13T00:30:36+00:00 + + + r5KfWUv6wqQ + Video Title + +

Transcript content...

+
+ + 2025-01-06T14:13:57+00:00 +
+
+``` + +**RSS Format:** +```xml + + + + YouTube Transcript Feed - UC9h8BDcXwkhZtnqoQJ7PggA + https://www.youtube.com/channel/UC9h8BDcXwkhZtnqoQJ7PggA + + Video Title + Transcript content...

]]>
+ https://www.youtube.com/watch?v=r5KfWUv6wqQ + Mon, 06 Jan 2025 14:13:57 +0000 +
+
+
+``` + +**Hata Yanıtları:** + +**400 Bad Request** - Geçersiz parametreler: +```json +{ + "error": "Channel ID bulunamadı", + "usage": { + "channel_id": "UC... (YouTube Channel ID)", + "channel": "@username veya username", + "channel_url": "https://www.youtube.com/@username veya https://www.youtube.com/channel/UC...", + "format": "Atom veya Rss (varsayılan: Atom)", + "max_items": "Maksimum video sayısı (varsayılan: 50)" + } +} +``` + +**400 Bad Request** - Geçersiz format: +```json +{ + "error": "Geçersiz channel_id formatı", + "message": "Channel ID UC ile başlayan 24 karakter olmalı" +} +``` + +**404 Not Found** - Henüz işlenmiş video yok: +```json +{ + "error": "Henüz işlenmiş video yok", + "channel_id": "UC9h8BDcXwkhZtnqoQJ7PggA", + "message": "Lütfen birkaç dakika sonra tekrar deneyin" +} +``` + +**500 Internal Server Error** - Sunucu hatası: +```json +{ + "error": "RSS-Bridge hatası: ...", + "channel_id": "UC9h8BDcXwkhZtnqoQJ7PggA" +} +``` + +--- + +### 2. Health Check + +Servisin durumunu kontrol eder. **API key gerektirmez.** + +**Endpoint:** `GET /health` + +**Örnek İstek:** + +```bash +curl "http://localhost:5000/health" +``` + +**Başarılı Yanıt:** + +```json +{ + "status": "ok", + "service": "YouTube Transcript RSS Feed" +} +``` + +**HTTP Status:** `200 OK` + +--- + +### 3. API Bilgileri + +API hakkında bilgi döner. + +**Endpoint:** `GET /info` + +**Örnek İstek:** + +```bash +curl -H "X-API-Key: demo_key_12345" "http://localhost:5000/info" +``` + +**Başarılı Yanıt:** + +```json +{ + "service": "YouTube Transcript RSS Feed Generator", + "version": "1.0.0", + "endpoints": { + "/": "RSS Feed Generator", + "/health": "Health Check", + "/info": "API Info" + }, + "usage": { + "channel_id": "UC... (YouTube Channel ID)", + "channel": "@username veya username", + "channel_url": "Full YouTube channel URL", + "format": "Atom veya Rss (varsayılan: Atom)", + "max_items": "Maksimum video sayısı (varsayılan: 50)" + }, + "examples": [ + "/?channel_id=UC9h8BDcXwkhZtnqoQJ7PggA&format=Atom", + "/?channel=@tavakfi&format=Rss", + "/?channel_url=https://www.youtube.com/@tavakfi&format=Atom&max_items=100" + ] +} +``` + +**HTTP Status:** `200 OK` + +--- + +## Response Headers + +Tüm başarılı response'larda aşağıdaki header'lar bulunur: + +| Header | Açıklama | +|--------|----------| +| `X-RateLimit-Remaining` | Kalan istek sayısı | +| `X-Content-Type-Options` | `nosniff` | +| `X-Frame-Options` | `DENY` | +| `X-XSS-Protection` | `1; mode=block` | +| `Content-Type` | `application/atom+xml` veya `application/rss+xml` | + +## Hata Kodları + +| HTTP Status | Açıklama | +|-------------|----------| +| `200 OK` | İstek başarılı | +| `400 Bad Request` | Geçersiz parametreler | +| `401 Unauthorized` | API key eksik veya geçersiz | +| `404 Not Found` | Kaynak bulunamadı | +| `429 Too Many Requests` | Rate limit aşıldı | +| `500 Internal Server Error` | Sunucu hatası | + +## Input Validation + +### Channel ID Formatı + +- `UC` ile başlamalı +- Toplam 24 karakter +- Alfanumerik karakterler + `_` ve `-` + +**Örnek:** `UC9h8BDcXwkhZtnqoQJ7PggA` + +### Channel Handle Formatı + +- `@` ile başlayabilir veya başlamayabilir +- Maksimum 30 karakter +- Alfanumerik karakterler + `_` ve `-` + +**Örnekler:** `@tavakfi`, `tavakfi` + +### Channel URL Formatı + +Sadece aşağıdaki formatlar kabul edilir: + +- `https://www.youtube.com/channel/UC...` +- `https://www.youtube.com/@username` +- `https://youtube.com/channel/UC...` +- `https://youtube.com/@username` + +### max_items + +- Tip: Integer +- Minimum: 1 +- Maksimum: 500 +- Varsayılan: 50 + +## CORS + +API CORS desteği sağlar. Preflight request'ler için `OPTIONS` metodu kullanılır. + +**İzin Verilen Methodlar:** `GET`, `OPTIONS` + +**İzin Verilen Header'lar:** `Content-Type`, `X-API-Key` + +## Örnek Kullanım Senaryoları + +### 1. RSS Reader ile Kullanım + +RSS reader uygulamanızda feed URL'si olarak kullanın: + +``` +http://your-api.com/?channel_id=UC9h8BDcXwkhZtnqoQJ7PggA&format=Rss&api_key=your_api_key +``` + +### 2. Programatik Kullanım (Python) + +```python +import requests + +api_key = "your_api_key_here" +channel_id = "UC9h8BDcXwkhZtnqoQJ7PggA" + +headers = { + "X-API-Key": api_key +} + +response = requests.get( + f"http://localhost:5000/?channel_id={channel_id}&format=Atom", + headers=headers +) + +if response.status_code == 200: + feed_content = response.text + print(feed_content) +else: + print(f"Error: {response.status_code}") + print(response.json()) +``` + +### 3. Programatik Kullanım (JavaScript) + +```javascript +const apiKey = "your_api_key_here"; +const channelId = "UC9h8BDcXwkhZtnqoQJ7PggA"; + +fetch(`http://localhost:5000/?channel_id=${channelId}&format=Atom`, { + headers: { + "X-API-Key": apiKey + } +}) + .then(response => { + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + return response.text(); + }) + .then(feedContent => { + console.log(feedContent); + }) + .catch(error => { + console.error("Error:", error); + }); +``` + +### 4. cURL ile Test + +```bash +# API key ile test +API_KEY="demo_key_12345" +CHANNEL_ID="UC9h8BDcXwkhZtnqoQJ7PggA" + +curl -H "X-API-Key: $API_KEY" \ + "http://localhost:5000/?channel_id=$CHANNEL_ID&format=Atom&max_items=10" +``` + +## Notlar + +1. **İlk İstek**: İlk istekte transcript'ler henüz işlenmemiş olabilir. Birkaç dakika bekleyip tekrar deneyin. + +2. **Rate Limiting**: Her API key için farklı rate limit tanımlanabilir. Limit aşıldığında 60 saniye beklemeniz gerekir. + +3. **Transcript İşleme**: Transcript'ler arka planda asenkron olarak işlenir. Yeni videolar için birkaç dakika gecikme olabilir. + +4. **Format Seçimi**: Atom formatı daha modern ve önerilir. RSS formatı eski RSS reader'lar için uygundur. + +5. **API Key Güvenliği**: API key'lerinizi güvenli tutun ve asla public repository'lere commit etmeyin. + +## Destek + +Sorularınız için GitHub Issues kullanabilirsiniz. + diff --git a/README.md b/README.md index fffa5b5..c4a59e0 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ YouTube video transkriptlerini otomatik olarak çıkarıp, tam metin içeren RSS - ✅ **RSS-Bridge benzeri URL template** - Kanal adı/linki ile direkt feed - ✅ **Web Server Modu** - Flask ile RESTful API +- ✅ **API Key Authentication** - Tüm endpoint'ler API key gerektirir +- ✅ **Güvenlik Önlemleri** - SQL injection, XSS, rate limiting koruması - ✅ RSS-Bridge entegrasyonu (100+ video desteği) - ✅ Async rate limiting (AIOLimiter) - ✅ SpaCy ile Sentence Boundary Detection @@ -29,27 +31,41 @@ docker-compose up -d docker-compose logs -f ``` +### API Key Yapılandırması + +**ÖNEMLİ:** Tüm endpoint'ler API key gerektirir! + +API key'leri `config/security.yaml` dosyasından yönetin: + +```yaml +security: + require_api_key: true + api_keys: + demo_key_12345: + name: "Demo API Key" + rate_limit: 100 + enabled: true +``` + ### URL Template Kullanımı RSS-Bridge benzeri URL template sistemi: -``` -# Channel ID ile -http://localhost:5000/?channel_id=UC9h8BDcXwkhZtnqoQJ7PggA&format=Atom +```bash +# Channel ID ile (API key header'da) +curl -H "X-API-Key: demo_key_12345" \ + "http://localhost:5000/?channel_id=UC9h8BDcXwkhZtnqoQJ7PggA&format=Atom" -# Channel Handle ile -http://localhost:5000/?channel=@tavakfi&format=Atom +# Channel Handle ile (API key query parametresi) +curl "http://localhost:5000/?channel=@tavakfi&format=Atom&api_key=demo_key_12345" # Channel URL ile -http://localhost:5000/?channel_url=https://www.youtube.com/@tavakfi&format=Atom - -# RSS formatı -http://localhost:5000/?channel=@tavakfi&format=Rss - -# Maksimum video sayısı -http://localhost:5000/?channel=@tavakfi&format=Atom&max_items=100 +curl -H "X-API-Key: demo_key_12345" \ + "http://localhost:5000/?channel_url=https://www.youtube.com/@tavakfi&format=Atom&max_items=100" ``` +**Detaylı API dokümantasyonu için:** [API.md](API.md) + ### Batch Mode (Manuel Çalıştırma) ```bash @@ -78,6 +94,8 @@ python main.py ## Yapılandırma +### Ana Yapılandırma + `config/config.yaml` dosyasını düzenleyin: ```yaml @@ -92,6 +110,23 @@ rss_bridge: max_items: 100 ``` +### Güvenlik Yapılandırması + +`config/security.yaml` dosyasından API key'leri ve güvenlik ayarlarını yönetin: + +```yaml +security: + require_api_key: true + api_keys: + your_api_key_here: + name: "Your API Key" + rate_limit: 100 + enabled: true + default_rate_limit: 60 +``` + +**Detaylı güvenlik dokümantasyonu için:** [SECURITY.md](SECURITY.md) + ## Proje Yapısı ``` @@ -113,6 +148,18 @@ yttranscriptrss/ └── main.py ``` +## Dokümantasyon + +- **[API.md](API.md)** - Detaylı API dokümantasyonu, endpoint'ler, örnekler +- **[SECURITY.md](SECURITY.md)** - Güvenlik önlemleri, best practices +- **[development_plan.md](development_plan.md)** - Geliştirme planı ve roadmap + +## Endpoints + +- `GET /` - RSS/Atom feed oluştur (API key gerekli) +- `GET /health` - Health check (API key gerekmez) +- `GET /info` - API bilgileri (API key gerekli) + ## Geliştirme Planı Detaylı geliştirme planı için `development_plan.md` dosyasına bakın. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..c559439 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,212 @@ +# Güvenlik Dokümantasyonu + +Bu dokümantasyon, YouTube Transcript RSS Feed projesinin güvenlik önlemlerini açıklar. + +## Güvenlik Özellikleri + +### 1. SQL Injection Koruması + +- **Parametrize Queries**: Tüm SQL sorguları parametrize edilmiş query'ler kullanır +- **Input Validation**: Veritabanına giden tüm inputlar format kontrolünden geçer +- **Video ID Validation**: YouTube video ID formatı kontrol edilir (11 karakter, alfanumerik) +- **Channel ID Validation**: YouTube channel ID formatı kontrol edilir (UC ile başlayan 24 karakter) + +**Örnek:** +```python +# Güvenli (parametrize query) +cursor.execute("SELECT * FROM videos WHERE video_id = ?", (video_id,)) + +# Güvensiz (kullanılmıyor!) +# cursor.execute(f"SELECT * FROM videos WHERE video_id = '{video_id}'") +``` + +### 2. XSS (Cross-Site Scripting) Koruması + +- **Input Sanitization**: Tüm kullanıcı inputları sanitize edilir +- **HTML Tag Removal**: Tehlikeli HTML tag'leri kaldırılır +- **Script Tag Removal**: `" +``` + +### 3. Rate Limiting Testi + +```bash +# 60+ istek gönder, 429 hatası almalı +for i in {1..70}; do curl "http://localhost:5000/?channel_id=UC..."; done +``` + +## Güvenlik Güncellemeleri + +- Düzenli olarak bağımlılıkları güncelleyin: `pip install --upgrade` +- Güvenlik açıklarını takip edin: GitHub Security Advisories +- Log'ları izleyin: Şüpheli aktiviteleri tespit edin + +## İletişim + +Güvenlik açığı bulursanız, lütfen sorumlu disclosure yapın. + diff --git a/config/security.yaml b/config/security.yaml new file mode 100644 index 0000000..f115161 --- /dev/null +++ b/config/security.yaml @@ -0,0 +1,53 @@ +# Güvenlik ayarları +security: + # API Key Authentication + require_api_key: true # true ise tüm endpoint'ler API key gerektirir + api_keys: + # API key'ler: key -> {name, rate_limit, enabled} + # Örnek API key'ler (production'da değiştirilmeli!) + demo_key_12345: + name: "Demo API Key" + rate_limit: 100 # Dakikada maksimum istek + enabled: true + created_at: "2025-01-01" + + # Daha fazla API key eklenebilir + # production_key_xyz: + # name: "Production Key" + # rate_limit: 1000 + # enabled: true + + # Rate Limiting (IP bazlı, API key yoksa) + default_rate_limit: 60 # Dakikada maksimum istek + rate_limit_by_endpoint: + "/": 60 # Ana feed endpoint + "/health": 120 # Health check daha fazla izin ver + "/info": 120 # Info endpoint + + # Input Validation + max_input_length: + channel_id: 50 + channel_handle: 50 + channel_url: 200 + max_items: 500 + + # CORS Settings + cors: + enabled: true + allowed_origins: + - "*" # Production'da spesifik domain'ler belirtilmeli + allowed_methods: + - "GET" + - "OPTIONS" + allowed_headers: + - "Content-Type" + - "X-API-Key" + + # Security Headers + security_headers: + X-Content-Type-Options: "nosniff" + X-Frame-Options: "DENY" + X-XSS-Protection: "1; mode=block" + Strict-Transport-Security: "max-age=31536000; includeSubDomains" # HTTPS için + Content-Security-Policy: "default-src 'self'" + diff --git a/src/database.py b/src/database.py index f343882..060a2b0 100644 --- a/src/database.py +++ b/src/database.py @@ -3,6 +3,7 @@ SQLite veritabanı yönetimi modülü """ import sqlite3 import os +import re from datetime import datetime, timezone from typing import Optional, List, Dict @@ -89,8 +90,24 @@ class Database: if self.conn: self.conn.close() + def _validate_video_id(self, video_id: str) -> bool: + """Video ID formatını doğrula (SQL injection koruması)""" + if not video_id or len(video_id) > 20: + return False + # YouTube video ID: 11 karakter, alfanumerik + _ - + return bool(re.match(r'^[a-zA-Z0-9_-]{11}$', video_id)) + + def _validate_channel_id(self, channel_id: str) -> bool: + """Channel ID formatını doğrula (SQL injection koruması)""" + if not channel_id or len(channel_id) > 50: + return False + # YouTube channel ID: UC ile başlayan 24 karakter + return bool(re.match(r'^UC[a-zA-Z0-9_-]{22}$', channel_id)) + def is_video_processed(self, video_id: str) -> bool: """Video işlenmiş mi kontrol et""" + if not self._validate_video_id(video_id): + raise ValueError(f"Geçersiz video_id formatı: {video_id}") cursor = self.conn.cursor() cursor.execute("SELECT video_id FROM videos WHERE video_id = ?", (video_id,)) return cursor.fetchone() is not None @@ -107,16 +124,26 @@ class Database: def add_video(self, video_data: Dict): """Yeni video ekle (status=0 olarak)""" + # Input validation + video_id = video_data.get('video_id') + channel_id = video_data.get('channel_id') + + if not self._validate_video_id(video_id): + raise ValueError(f"Geçersiz video_id formatı: {video_id}") + + if channel_id and not self._validate_channel_id(channel_id): + raise ValueError(f"Geçersiz channel_id formatı: {channel_id}") + cursor = self.conn.cursor() cursor.execute(""" INSERT OR IGNORE INTO videos (video_id, channel_id, video_title, video_url, published_at_utc, transcript_status) VALUES (?, ?, ?, ?, ?, 0) """, ( - video_data['video_id'], - video_data.get('channel_id'), - video_data.get('video_title'), - video_data.get('video_url'), + video_id, + channel_id, + video_data.get('video_title', '')[:500], # Max length + video_data.get('video_url', '')[:500], # Max length video_data.get('published_at_utc') )) self.conn.commit() @@ -124,6 +151,13 @@ class Database: def update_video_transcript(self, video_id: str, raw: str, clean: str, status: int, language: Optional[str] = None): """Video transcript'ini güncelle""" + # Input validation + if not self._validate_video_id(video_id): + raise ValueError(f"Geçersiz video_id formatı: {video_id}") + + if status not in [0, 1, 2]: + raise ValueError(f"Geçersiz status değeri: {status}") + cursor = self.conn.cursor() now_utc = datetime.now(timezone.utc).isoformat() cursor.execute(""" @@ -141,6 +175,13 @@ class Database: def get_processed_videos(self, limit: Optional[int] = None, channel_id: Optional[str] = None) -> List[Dict]: """İşlenmiş videoları getir (status=1)""" + # Input validation + if channel_id and not self._validate_channel_id(channel_id): + raise ValueError(f"Geçersiz channel_id formatı: {channel_id}") + + if limit is not None and (not isinstance(limit, int) or limit < 1 or limit > 1000): + raise ValueError(f"Geçersiz limit değeri: {limit} (1-1000 arası olmalı)") + cursor = self.conn.cursor() query = """ SELECT * FROM videos @@ -163,6 +204,10 @@ class Database: def mark_video_failed(self, video_id: str, reason: Optional[str] = None): """Video'yu başarısız olarak işaretle (status=2)""" + # Input validation + if not self._validate_video_id(video_id): + raise ValueError(f"Geçersiz video_id formatı: {video_id}") + cursor = self.conn.cursor() cursor.execute(""" UPDATE videos diff --git a/src/security.py b/src/security.py new file mode 100644 index 0000000..4460407 --- /dev/null +++ b/src/security.py @@ -0,0 +1,334 @@ +""" +Güvenlik modülü: Authentication, Rate Limiting, Input Validation +""" +import re +import hashlib +import hmac +import time +from functools import wraps +from flask import request, jsonify, g +from typing import Optional, Dict, List +from collections import defaultdict +from datetime import datetime, timedelta + + +class SecurityManager: + """Güvenlik yönetim sınıfı""" + + def __init__(self, api_keys: Dict[str, Dict] = None, rate_limit_per_minute: int = 60): + """ + Args: + api_keys: API key dict {key: {name, rate_limit, enabled}} + rate_limit_per_minute: Varsayılan rate limit + """ + self.api_keys = api_keys or {} + self.rate_limit_per_minute = rate_limit_per_minute + # Rate limiting için: {ip_or_key: [(timestamp, ...)]} + self.rate_limit_store = defaultdict(list) + # IP bazlı rate limiting + self.ip_rate_limits = defaultdict(list) + + def validate_api_key(self, api_key: Optional[str]) -> tuple[bool, Optional[Dict]]: + """ + API key doğrula + + Returns: + (is_valid, key_info) tuple + """ + if not api_key: + return False, None + + # API key'i hash'le ve karşılaştır + key_info = self.api_keys.get(api_key) + if key_info and key_info.get('enabled', True): + return True, key_info + + return False, None + + def check_rate_limit(self, identifier: str, limit: int = None) -> tuple[bool, int]: + """ + Rate limit kontrolü + + Args: + identifier: IP adresi veya API key + limit: Rate limit (None ise varsayılan kullanılır) + + Returns: + (is_allowed, remaining_requests) tuple + """ + if limit is None: + limit = self.rate_limit_per_minute + + now = time.time() + minute_ago = now - 60 + + # Eski kayıtları temizle + self.rate_limit_store[identifier] = [ + ts for ts in self.rate_limit_store[identifier] if ts > minute_ago + ] + + # Rate limit kontrolü + if len(self.rate_limit_store[identifier]) >= limit: + remaining = 0 + return False, remaining + + # Yeni isteği kaydet + self.rate_limit_store[identifier].append(now) + remaining = limit - len(self.rate_limit_store[identifier]) + + return True, remaining + + def sanitize_input(self, text: str, max_length: int = 500) -> str: + """ + Input sanitization (XSS koruması) + + Args: + text: Temizlenecek metin + max_length: Maksimum uzunluk + + Returns: + Temizlenmiş metin + """ + if not text: + return "" + + # Uzunluk kontrolü + text = text[:max_length] + + # Tehlikeli karakterleri temizle + # HTML tag'lerini kaldır (basit) + text = re.sub(r'<[^>]+>', '', text) + + # Script tag'lerini kaldır + text = re.sub(r']*>.*?', '', text, flags=re.IGNORECASE | re.DOTALL) + + # JavaScript event handler'ları kaldır + text = re.sub(r'on\w+\s*=', '', text, flags=re.IGNORECASE) + + return text.strip() + + def validate_channel_id(self, channel_id: str) -> bool: + """ + Channel ID formatını doğrula + + Args: + channel_id: YouTube Channel ID + + Returns: + Geçerli mi? + """ + if not channel_id: + return False + + # UC ile başlayan 24 karakter + if re.match(r'^UC[a-zA-Z0-9_-]{22}$', channel_id): + return True + + return False + + def validate_channel_handle(self, handle: str) -> bool: + """ + Channel handle formatını doğrula + + Args: + handle: @username veya username + + Returns: + Geçerli mi? + """ + if not handle: + return False + + # @ ile başlayan veya başlamayan, alfanumerik + _ - karakterler + handle = handle.lstrip('@') + if re.match(r'^[a-zA-Z0-9_-]{1,30}$', handle): + return True + + return False + + def validate_url(self, url: str) -> bool: + """ + URL formatını doğrula + + Args: + url: URL string + + Returns: + Geçerli mi? + """ + if not url: + return False + + # Sadece YouTube URL'lerine izin ver + youtube_patterns = [ + r'^https?://(www\.)?youtube\.com/channel/[a-zA-Z0-9_-]+', + r'^https?://(www\.)?youtube\.com/@[a-zA-Z0-9_-]+', + r'^https?://(www\.)?youtu\.be/[a-zA-Z0-9_-]+', + ] + + for pattern in youtube_patterns: + if re.match(pattern, url): + return True + + return False + + def validate_max_items(self, max_items: int) -> bool: + """ + max_items parametresini doğrula + + Args: + max_items: Maksimum item sayısı + + Returns: + Geçerli mi? + """ + return isinstance(max_items, int) and 1 <= max_items <= 500 + + +# Global security manager instance +_security_manager = None + + +def init_security(api_keys: Dict[str, Dict] = None, rate_limit: int = 60): + """Security manager'ı initialize et""" + global _security_manager + _security_manager = SecurityManager(api_keys, rate_limit) + return _security_manager + + +def get_security_manager() -> SecurityManager: + """Security manager instance'ı al""" + global _security_manager + if _security_manager is None: + _security_manager = SecurityManager() + return _security_manager + + +def require_api_key(f): + """API key gerektiren decorator""" + @wraps(f) + def decorated_function(*args, **kwargs): + security = get_security_manager() + + # API key'i header'dan veya query'den al + api_key = request.headers.get('X-API-Key') or request.args.get('api_key') + + is_valid, key_info = security.validate_api_key(api_key) + + if not is_valid: + return jsonify({ + 'error': 'Geçersiz veya eksik API key', + 'message': 'X-API-Key header veya api_key query parametresi gerekli' + }), 401 + + # Rate limit kontrolü (API key bazlı) + key_rate_limit = key_info.get('rate_limit', security.rate_limit_per_minute) + is_allowed, remaining = security.check_rate_limit(api_key, key_rate_limit) + + if not is_allowed: + return jsonify({ + 'error': 'Rate limit aşıldı', + 'message': f'Dakikada {key_rate_limit} istek limiti', + 'retry_after': 60 + }), 429 + + # API key bilgisini g context'e ekle + g.api_key = api_key + g.api_key_info = key_info + + # Response header'a remaining ekle + g.rate_limit_remaining = remaining + + return f(*args, **kwargs) + + return decorated_function + + +def rate_limit(limit_per_minute: int = 60): + """Rate limiting decorator (IP bazlı)""" + def decorator(f): + @wraps(f) + def decorated_function(*args, **kwargs): + security = get_security_manager() + + # IP adresini al + ip_address = request.environ.get('HTTP_X_FORWARDED_FOR', request.remote_addr) + if ip_address: + ip_address = ip_address.split(',')[0].strip() + + # Rate limit kontrolü + is_allowed, remaining = security.check_rate_limit(ip_address, limit_per_minute) + + if not is_allowed: + return jsonify({ + 'error': 'Rate limit aşıldı', + 'message': f'Dakikada {limit_per_minute} istek limiti', + 'retry_after': 60 + }), 429 + + g.rate_limit_remaining = remaining + + return f(*args, **kwargs) + return decorated_function + return decorator + + +def validate_input(f): + """Input validation decorator""" + @wraps(f) + def decorated_function(*args, **kwargs): + security = get_security_manager() + + # Query parametrelerini validate et + channel_id = request.args.get('channel_id') + channel = request.args.get('channel') + channel_url = request.args.get('channel_url') + max_items = request.args.get('max_items', '50') + + # Channel ID validation + if channel_id and not security.validate_channel_id(channel_id): + return jsonify({ + 'error': 'Geçersiz channel_id formatı', + 'message': 'Channel ID UC ile başlayan 24 karakter olmalı' + }), 400 + + # Channel handle validation + if channel and not security.validate_channel_handle(channel): + return jsonify({ + 'error': 'Geçersiz channel handle formatı', + 'message': 'Channel handle alfanumerik karakterler içermeli (maks 30 karakter)' + }), 400 + + # Channel URL validation + if channel_url and not security.validate_url(channel_url): + return jsonify({ + 'error': 'Geçersiz channel_url formatı', + 'message': 'Sadece YouTube URL\'lerine izin verilir' + }), 400 + + # max_items validation + try: + max_items_int = int(max_items) + if not security.validate_max_items(max_items_int): + return jsonify({ + 'error': 'Geçersiz max_items değeri', + 'message': 'max_items 1-500 arasında olmalı' + }), 400 + except ValueError: + return jsonify({ + 'error': 'Geçersiz max_items formatı', + 'message': 'max_items sayı olmalı' + }), 400 + + # Input sanitization + if channel_id: + channel_id = security.sanitize_input(channel_id, max_length=50) + if channel: + channel = security.sanitize_input(channel, max_length=50) + if channel_url: + channel_url = security.sanitize_input(channel_url, max_length=200) + + return f(*args, **kwargs) + + return decorated_function + diff --git a/src/web_server.py b/src/web_server.py index 783015f..303c930 100644 --- a/src/web_server.py +++ b/src/web_server.py @@ -1,9 +1,11 @@ """ Flask web server - RSS-Bridge benzeri URL template sistemi """ -from flask import Flask, request, Response, jsonify +from flask import Flask, request, Response, jsonify, g, after_request from typing import Optional import sys +import os +import yaml from pathlib import Path sys.path.insert(0, str(Path(__file__).parent.parent)) @@ -13,10 +15,80 @@ from src.video_fetcher import fetch_videos_from_rss_bridge, get_channel_id_from_ from src.transcript_extractor import TranscriptExtractor from src.transcript_cleaner import TranscriptCleaner from src.rss_generator import RSSGenerator +from src.security import ( + init_security, get_security_manager, + require_api_key, rate_limit, validate_input +) app = Flask(__name__) +# Security config yükle +_security_config = None +def load_security_config(): + """Security config'i yükle""" + global _security_config + if _security_config is None: + config_path = Path(__file__).parent.parent / 'config' / 'security.yaml' + if config_path.exists(): + with open(config_path, 'r', encoding='utf-8') as f: + _security_config = yaml.safe_load(f).get('security', {}) + else: + _security_config = {} + return _security_config + +# Security manager'ı initialize et +def init_app_security(): + """Security manager'ı uygulama başlangıcında initialize et""" + config = load_security_config() + api_keys = config.get('api_keys', {}) + default_rate_limit = config.get('default_rate_limit', 60) + init_security(api_keys, default_rate_limit) + +# Security headers ve CORS middleware +@app.after_request +def add_security_headers(response): + """Security header'ları ekle""" + config = load_security_config() + headers = config.get('security_headers', {}) + + for header, value in headers.items(): + response.headers[header] = value + + # CORS headers + cors_config = config.get('cors', {}) + if cors_config.get('enabled', True): + origins = cors_config.get('allowed_origins', ['*']) + if '*' in origins: + response.headers['Access-Control-Allow-Origin'] = '*' + else: + origin = request.headers.get('Origin') + if origin in origins: + response.headers['Access-Control-Allow-Origin'] = origin + + response.headers['Access-Control-Allow-Methods'] = ', '.join( + cors_config.get('allowed_methods', ['GET', 'OPTIONS']) + ) + response.headers['Access-Control-Allow-Headers'] = ', '.join( + cors_config.get('allowed_headers', ['Content-Type', 'X-API-Key']) + ) + + # Rate limit bilgisini header'a ekle + if hasattr(g, 'rate_limit_remaining'): + response.headers['X-RateLimit-Remaining'] = str(g.rate_limit_remaining) + + return response + +# OPTIONS handler for CORS +@app.route('/', methods=['OPTIONS']) +@app.route('/', methods=['OPTIONS']) +def handle_options(path=None): + """CORS preflight request handler""" + return Response(status=200) + +# Uygulama başlangıcında security'yi initialize et +init_app_security() + # Global instances (lazy loading) db = None extractor = None @@ -165,6 +237,8 @@ def process_channel(channel_id: str, max_items: int = 50) -> dict: @app.route('/', methods=['GET']) +@require_api_key # API key zorunlu +@validate_input # Input validation def generate_feed(): """ RSS-Bridge benzeri URL template: @@ -174,12 +248,15 @@ def generate_feed(): - /?channel=@tavakfi&format=Atom - /?channel_url=https://www.youtube.com/@tavakfi&format=Atom """ - # Query parametrelerini al + # Query parametrelerini al (validate_input decorator zaten sanitize etti) channel_id = request.args.get('channel_id') channel = request.args.get('channel') # @username veya username channel_url = request.args.get('channel_url') format_type = request.args.get('format', 'Atom').lower() # Atom veya Rss - max_items = int(request.args.get('max_items', 50)) + try: + max_items = int(request.args.get('max_items', 50)) + except (ValueError, TypeError): + max_items = 50 # Channel ID'yi normalize et normalized_channel_id = normalize_channel_id( @@ -226,20 +303,26 @@ def generate_feed(): generator.add_video_entry(video) # Format'a göre döndür + response_headers = {} + if hasattr(g, 'rate_limit_remaining'): + response_headers['X-RateLimit-Remaining'] = str(g.rate_limit_remaining) + if format_type == 'rss': rss_content = generator.generate_rss_string() + response_headers['Content-Type'] = 'application/rss+xml; charset=utf-8' return Response( rss_content, mimetype='application/rss+xml', - headers={'Content-Type': 'application/rss+xml; charset=utf-8'} + headers=response_headers ) else: # Atom # Feedgen Atom desteği atom_content = generator.generate_atom_string() + response_headers['Content-Type'] = 'application/atom+xml; charset=utf-8' return Response( atom_content, mimetype='application/atom+xml', - headers={'Content-Type': 'application/atom+xml; charset=utf-8'} + headers=response_headers ) except Exception as e: @@ -250,12 +333,14 @@ def generate_feed(): @app.route('/health', methods=['GET']) +@rate_limit(limit_per_minute=120) # Health check için daha yüksek limit def health(): """Health check endpoint""" return jsonify({'status': 'ok', 'service': 'YouTube Transcript RSS Feed'}) @app.route('/info', methods=['GET']) +@require_api_key # API key zorunlu def info(): """API bilgileri""" return jsonify({