httpd: add rate limiting and security headers
This commit is contained in:
81
httpd.py
81
httpd.py
@@ -10,9 +10,16 @@ import time
|
|||||||
import os
|
import os
|
||||||
import gc
|
import gc
|
||||||
import sys
|
import sys
|
||||||
|
from collections import defaultdict
|
||||||
import mysqlite
|
import mysqlite
|
||||||
from misc import _log
|
from misc import _log
|
||||||
|
|
||||||
|
# Rate limiting configuration
|
||||||
|
_rate_limits = defaultdict(list)
|
||||||
|
_rate_lock = threading.Lock()
|
||||||
|
_rate_limit_requests = 60 # requests per window
|
||||||
|
_rate_limit_window = 60 # window in seconds
|
||||||
|
|
||||||
# Static directories (relative to this file)
|
# Static directories (relative to this file)
|
||||||
_STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static')
|
_STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static')
|
||||||
_STATIC_LIB_DIR = os.path.join(_STATIC_DIR, 'lib')
|
_STATIC_LIB_DIR = os.path.join(_STATIC_DIR, 'lib')
|
||||||
@@ -74,6 +81,40 @@ def is_localhost(ip):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def check_rate_limit(ip):
|
||||||
|
"""Check if IP is within rate limit. Returns True if allowed."""
|
||||||
|
if is_localhost(ip):
|
||||||
|
return True # No rate limit for localhost
|
||||||
|
now = time.time()
|
||||||
|
with _rate_lock:
|
||||||
|
# Clean old entries
|
||||||
|
_rate_limits[ip] = [t for t in _rate_limits[ip] if now - t < _rate_limit_window]
|
||||||
|
if len(_rate_limits[ip]) >= _rate_limit_requests:
|
||||||
|
return False
|
||||||
|
_rate_limits[ip].append(now)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def get_security_headers(content_type):
|
||||||
|
"""Return security headers for responses."""
|
||||||
|
headers = [
|
||||||
|
('X-Content-Type-Options', 'nosniff'),
|
||||||
|
('X-Frame-Options', 'DENY'),
|
||||||
|
('Referrer-Policy', 'strict-origin-when-cross-origin'),
|
||||||
|
]
|
||||||
|
# Add CSP for HTML pages
|
||||||
|
if 'text/html' in content_type:
|
||||||
|
headers.append((
|
||||||
|
'Content-Security-Policy',
|
||||||
|
"default-src 'self'; "
|
||||||
|
"script-src 'self' 'unsafe-inline'; "
|
||||||
|
"style-src 'self' 'unsafe-inline'; "
|
||||||
|
"img-src 'self' data: https:; "
|
||||||
|
"connect-src 'self'"
|
||||||
|
))
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
def get_system_stats():
|
def get_system_stats():
|
||||||
"""Collect system resource statistics."""
|
"""Collect system resource statistics."""
|
||||||
stats = {}
|
stats = {}
|
||||||
@@ -355,8 +396,9 @@ class ProxyAPIHandler(BaseHTTPServer.BaseHTTPRequestHandler):
|
|||||||
self.send_header('Content-Type', content_type)
|
self.send_header('Content-Type', content_type)
|
||||||
self.send_header('Content-Length', len(body))
|
self.send_header('Content-Length', len(body))
|
||||||
self.send_header('Cache-Control', 'no-cache')
|
self.send_header('Cache-Control', 'no-cache')
|
||||||
if content_type == 'application/json':
|
# Add security headers
|
||||||
self.send_header('Access-Control-Allow-Origin', '*')
|
for header, value in get_security_headers(content_type):
|
||||||
|
self.send_header(header, value)
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(body)
|
self.wfile.write(body)
|
||||||
|
|
||||||
@@ -376,6 +418,18 @@ class ProxyAPIHandler(BaseHTTPServer.BaseHTTPRequestHandler):
|
|||||||
self.send_response_body(js, 'application/javascript; charset=utf-8', status)
|
self.send_response_body(js, 'application/javascript; charset=utf-8', status)
|
||||||
|
|
||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
|
# Rate limiting check
|
||||||
|
client_ip = self.client_address[0] if self.client_address else ''
|
||||||
|
if not check_rate_limit(client_ip):
|
||||||
|
self.send_response(429)
|
||||||
|
self.send_header('Content-Type', 'application/json')
|
||||||
|
self.send_header('Retry-After', str(_rate_limit_window))
|
||||||
|
for header, value in get_security_headers('application/json'):
|
||||||
|
self.send_header(header, value)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(json.dumps({'error': 'rate limited', 'retry_after': _rate_limit_window}))
|
||||||
|
return
|
||||||
|
|
||||||
path = self.path.split('?')[0]
|
path = self.path.split('?')[0]
|
||||||
routes = {
|
routes = {
|
||||||
'/': self.handle_index,
|
'/': self.handle_index,
|
||||||
@@ -572,14 +626,26 @@ class ProxyAPIServer(threading.Thread):
|
|||||||
"""WSGI application wrapper for gevent."""
|
"""WSGI application wrapper for gevent."""
|
||||||
path = environ.get('PATH_INFO', '/').split('?')[0]
|
path = environ.get('PATH_INFO', '/').split('?')[0]
|
||||||
method = environ.get('REQUEST_METHOD', 'GET')
|
method = environ.get('REQUEST_METHOD', 'GET')
|
||||||
|
remote_addr = environ.get('REMOTE_ADDR', '')
|
||||||
|
|
||||||
if method != 'GET':
|
if method != 'GET':
|
||||||
start_response('405 Method Not Allowed', [('Content-Type', 'text/plain')])
|
start_response('405 Method Not Allowed', [('Content-Type', 'text/plain')])
|
||||||
return [b'Method not allowed']
|
return [b'Method not allowed']
|
||||||
|
|
||||||
|
# Rate limiting check
|
||||||
|
if not check_rate_limit(remote_addr):
|
||||||
|
error_body = json.dumps({'error': 'rate limited', 'retry_after': _rate_limit_window})
|
||||||
|
headers = [
|
||||||
|
('Content-Type', 'application/json'),
|
||||||
|
('Content-Length', str(len(error_body))),
|
||||||
|
('Retry-After', str(_rate_limit_window)),
|
||||||
|
]
|
||||||
|
headers.extend(get_security_headers('application/json'))
|
||||||
|
start_response('429 Too Many Requests', headers)
|
||||||
|
return [error_body]
|
||||||
|
|
||||||
# Route handling
|
# Route handling
|
||||||
try:
|
try:
|
||||||
remote_addr = environ.get('REMOTE_ADDR', '')
|
|
||||||
response_body, content_type, status = self._handle_route(path, remote_addr)
|
response_body, content_type, status = self._handle_route(path, remote_addr)
|
||||||
status_line = '%d %s' % (status, 'OK' if status == 200 else 'Error')
|
status_line = '%d %s' % (status, 'OK' if status == 200 else 'Error')
|
||||||
headers = [
|
headers = [
|
||||||
@@ -587,16 +653,17 @@ class ProxyAPIServer(threading.Thread):
|
|||||||
('Content-Length', str(len(response_body))),
|
('Content-Length', str(len(response_body))),
|
||||||
('Cache-Control', 'no-cache'),
|
('Cache-Control', 'no-cache'),
|
||||||
]
|
]
|
||||||
if content_type == 'application/json':
|
headers.extend(get_security_headers(content_type))
|
||||||
headers.append(('Access-Control-Allow-Origin', '*'))
|
|
||||||
start_response(status_line, headers)
|
start_response(status_line, headers)
|
||||||
return [response_body.encode('utf-8') if isinstance(response_body, unicode) else response_body]
|
return [response_body.encode('utf-8') if isinstance(response_body, unicode) else response_body]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_body = json.dumps({'error': str(e)})
|
error_body = json.dumps({'error': str(e)})
|
||||||
start_response('500 Internal Server Error', [
|
headers = [
|
||||||
('Content-Type', 'application/json'),
|
('Content-Type', 'application/json'),
|
||||||
('Content-Length', str(len(error_body))),
|
('Content-Length', str(len(error_body))),
|
||||||
])
|
]
|
||||||
|
headers.extend(get_security_headers('application/json'))
|
||||||
|
start_response('500 Internal Server Error', headers)
|
||||||
return [error_body]
|
return [error_body]
|
||||||
|
|
||||||
def _handle_route(self, path, remote_addr=''):
|
def _handle_route(self, path, remote_addr=''):
|
||||||
|
|||||||
Reference in New Issue
Block a user