Implement comprehensive rate limiting to protect against DoS attacks
- Add token bucket rate limiter with sliding window algorithm - Implement per-endpoint configurable rate limits - Add automatic IP blocking for excessive requests - Implement global request limits and concurrent request throttling - Add request size validation for all endpoints - Create admin endpoints for rate limit management - Add rate limit headers to responses - Implement cleanup thread for old rate limit buckets - Create detailed rate limiting documentation Rate limits: - Transcription: 10/min, 100/hour, max 10MB - Translation: 20/min, 300/hour, max 100KB - Streaming: 10/min, 150/hour, max 100KB - TTS: 15/min, 200/hour, max 50KB - Global: 1000/min, 10000/hour, 50 concurrent Security features: - Automatic temporary IP blocking (1 hour) for abuse - Manual IP blocking via admin endpoint - Request size validation to prevent large payload attacks - Burst control to limit sudden traffic spikes 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
133
app.py
133
app.py
@@ -23,6 +23,7 @@ from validators import Validators
|
||||
import atexit
|
||||
import threading
|
||||
from datetime import datetime, timedelta
|
||||
from rate_limiter import rate_limit, rate_limiter, cleanup_rate_limiter, ip_filter_check
|
||||
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
@@ -168,6 +169,17 @@ def run_cleanup_loop():
|
||||
cleanup_thread = threading.Thread(target=run_cleanup_loop, daemon=True)
|
||||
cleanup_thread.start()
|
||||
|
||||
# Rate limiter cleanup thread
|
||||
def run_rate_limiter_cleanup():
|
||||
"""Run rate limiter cleanup periodically"""
|
||||
while True:
|
||||
time.sleep(3600) # Run every hour
|
||||
cleanup_rate_limiter()
|
||||
logger.info("Rate limiter cleanup completed")
|
||||
|
||||
rate_limiter_thread = threading.Thread(target=run_rate_limiter_cleanup, daemon=True)
|
||||
rate_limiter_thread.start()
|
||||
|
||||
# Cleanup on app shutdown
|
||||
@atexit.register
|
||||
def cleanup_on_exit():
|
||||
@@ -288,10 +300,12 @@ def serve_icon(filename):
|
||||
return send_from_directory('static/icons', filename)
|
||||
|
||||
@app.route('/api/push-public-key', methods=['GET'])
|
||||
@rate_limit(requests_per_minute=30)
|
||||
def push_public_key():
|
||||
return jsonify({'publicKey': vapid_public_key_base64})
|
||||
|
||||
@app.route('/api/push-subscribe', methods=['POST'])
|
||||
@rate_limit(requests_per_minute=10, requests_per_hour=50)
|
||||
def push_subscribe():
|
||||
try:
|
||||
subscription = request.json
|
||||
@@ -569,15 +583,9 @@ def index():
|
||||
return render_template('index.html', languages=sorted(SUPPORTED_LANGUAGES.values()))
|
||||
|
||||
@app.route('/transcribe', methods=['POST'])
|
||||
@rate_limit(requests_per_minute=10, requests_per_hour=100, check_size=True)
|
||||
@with_error_boundary
|
||||
def transcribe():
|
||||
# Rate limiting
|
||||
client_ip = request.remote_addr
|
||||
if not Validators.rate_limit_check(
|
||||
client_ip, 'transcribe', max_requests=30, window_seconds=60, storage=rate_limit_storage
|
||||
):
|
||||
return jsonify({'error': 'Rate limit exceeded. Please wait before trying again.'}), 429
|
||||
|
||||
if 'audio' not in request.files:
|
||||
return jsonify({'error': 'No audio file provided'}), 400
|
||||
|
||||
@@ -678,16 +686,10 @@ def transcribe():
|
||||
gc.collect()
|
||||
|
||||
@app.route('/translate', methods=['POST'])
|
||||
@rate_limit(requests_per_minute=20, requests_per_hour=300, check_size=True)
|
||||
@with_error_boundary
|
||||
def translate():
|
||||
try:
|
||||
# Rate limiting
|
||||
client_ip = request.remote_addr
|
||||
if not Validators.rate_limit_check(
|
||||
client_ip, 'translate', max_requests=30, window_seconds=60, storage=rate_limit_storage
|
||||
):
|
||||
return jsonify({'error': 'Rate limit exceeded. Please wait before trying again.'}), 429
|
||||
|
||||
# Validate request size
|
||||
if not Validators.validate_json_size(request.json, max_size_kb=100):
|
||||
return jsonify({'error': 'Request too large'}), 413
|
||||
@@ -752,17 +754,11 @@ def translate():
|
||||
return jsonify({'error': f'Translation failed: {str(e)}'}), 500
|
||||
|
||||
@app.route('/translate/stream', methods=['POST'])
|
||||
@rate_limit(requests_per_minute=10, requests_per_hour=150, check_size=True)
|
||||
@with_error_boundary
|
||||
def translate_stream():
|
||||
"""Streaming translation endpoint for reduced latency"""
|
||||
try:
|
||||
# Rate limiting
|
||||
client_ip = request.remote_addr
|
||||
if not Validators.rate_limit_check(
|
||||
client_ip, 'translate_stream', max_requests=20, window_seconds=60, storage=rate_limit_storage
|
||||
):
|
||||
return jsonify({'error': 'Rate limit exceeded. Please wait before trying again.'}), 429
|
||||
|
||||
# Validate request size
|
||||
if not Validators.validate_json_size(request.json, max_size_kb=100):
|
||||
return jsonify({'error': 'Request too large'}), 413
|
||||
@@ -855,6 +851,7 @@ def translate_stream():
|
||||
return jsonify({'error': f'Translation failed: {str(e)}'}), 500
|
||||
|
||||
@app.route('/speak', methods=['POST'])
|
||||
@rate_limit(requests_per_minute=15, requests_per_hour=200, check_size=True)
|
||||
@with_error_boundary
|
||||
def speak():
|
||||
try:
|
||||
@@ -991,6 +988,7 @@ def get_audio(filename):
|
||||
|
||||
# Error logging endpoint for frontend error reporting
|
||||
@app.route('/api/log-error', methods=['POST'])
|
||||
@rate_limit(requests_per_minute=10, requests_per_hour=100)
|
||||
def log_error():
|
||||
"""Log frontend errors for monitoring"""
|
||||
try:
|
||||
@@ -1215,10 +1213,15 @@ def manual_cleanup():
|
||||
app.start_time = time.time()
|
||||
app.request_count = 0
|
||||
|
||||
# Middleware to count requests
|
||||
# Middleware to count requests and check IP filtering
|
||||
@app.before_request
|
||||
def before_request():
|
||||
app.request_count = getattr(app, 'request_count', 0) + 1
|
||||
|
||||
# Check IP filtering
|
||||
response = ip_filter_check()
|
||||
if response:
|
||||
return response
|
||||
|
||||
# Global error handlers
|
||||
@app.errorhandler(404)
|
||||
@@ -1261,5 +1264,91 @@ def handle_exception(error):
|
||||
'status': 500
|
||||
}), 500
|
||||
|
||||
@app.route('/admin/rate-limits', methods=['GET'])
|
||||
@rate_limit(requests_per_minute=10)
|
||||
def get_rate_limits():
|
||||
"""Get current rate limit configuration"""
|
||||
try:
|
||||
# Simple authentication check
|
||||
auth_token = request.headers.get('X-Admin-Token')
|
||||
expected_token = os.environ.get('ADMIN_TOKEN', 'default-admin-token')
|
||||
|
||||
if auth_token != expected_token:
|
||||
return jsonify({'error': 'Unauthorized'}), 401
|
||||
|
||||
return jsonify({
|
||||
'default_limits': rate_limiter.default_limits,
|
||||
'endpoint_limits': rate_limiter.endpoint_limits,
|
||||
'global_limits': rate_limiter.global_limits
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get rate limits: {str(e)}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/admin/rate-limits/stats', methods=['GET'])
|
||||
@rate_limit(requests_per_minute=10)
|
||||
def get_rate_limit_stats():
|
||||
"""Get rate limiting statistics"""
|
||||
try:
|
||||
# Simple authentication check
|
||||
auth_token = request.headers.get('X-Admin-Token')
|
||||
expected_token = os.environ.get('ADMIN_TOKEN', 'default-admin-token')
|
||||
|
||||
if auth_token != expected_token:
|
||||
return jsonify({'error': 'Unauthorized'}), 401
|
||||
|
||||
# Get client ID from query param or header
|
||||
client_id = request.args.get('client_id')
|
||||
if client_id:
|
||||
stats = rate_limiter.get_client_stats(client_id)
|
||||
return jsonify({'client_stats': stats})
|
||||
|
||||
# Return global stats
|
||||
return jsonify({
|
||||
'total_buckets': len(rate_limiter.buckets),
|
||||
'concurrent_requests': rate_limiter.concurrent_requests,
|
||||
'blocked_ips': list(rate_limiter.blocked_ips),
|
||||
'temp_blocked_ips': len(rate_limiter.temp_blocked_ips)
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get rate limit stats: {str(e)}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/admin/block-ip', methods=['POST'])
|
||||
@rate_limit(requests_per_minute=5)
|
||||
def block_ip():
|
||||
"""Block an IP address"""
|
||||
try:
|
||||
# Simple authentication check
|
||||
auth_token = request.headers.get('X-Admin-Token')
|
||||
expected_token = os.environ.get('ADMIN_TOKEN', 'default-admin-token')
|
||||
|
||||
if auth_token != expected_token:
|
||||
return jsonify({'error': 'Unauthorized'}), 401
|
||||
|
||||
data = request.json
|
||||
ip = data.get('ip')
|
||||
duration = data.get('duration', 3600) # Default 1 hour
|
||||
permanent = data.get('permanent', False)
|
||||
|
||||
if not ip:
|
||||
return jsonify({'error': 'IP address required'}), 400
|
||||
|
||||
if permanent:
|
||||
rate_limiter.blocked_ips.add(ip)
|
||||
logger.warning(f"IP {ip} permanently blocked by admin")
|
||||
else:
|
||||
rate_limiter.block_ip_temporarily(ip, duration)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'ip': ip,
|
||||
'permanent': permanent,
|
||||
'duration': duration if not permanent else None
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to block IP: {str(e)}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(host='0.0.0.0', port=5005, debug=True)
|
||||
|
||||
Reference in New Issue
Block a user