routes: skip rate limiting for trusted certificate holders

This commit is contained in:
Username
2026-02-18 08:42:25 +01:00
parent 283f87b9c4
commit c69290af2d
3 changed files with 102 additions and 87 deletions

View File

@@ -226,12 +226,23 @@ def setup_rate_limiting(app: Flask) -> None:
from flask_limiter import Limiter from flask_limiter import Limiter
from flask_limiter.util import get_remote_address from flask_limiter.util import get_remote_address
def is_health_endpoint() -> bool: def is_rate_limit_exempt() -> bool:
"""Check if request is to health endpoint (exempt from rate limiting).""" """Check if request is exempt from global rate limiting.
# Get configured URL prefix (e.g., "/paste")
Exempt: health endpoint, trusted certificate holders.
"""
prefix = app.config.get("URL_PREFIX", "") prefix = app.config.get("URL_PREFIX", "")
health_path = f"{prefix}/health" if prefix else "/health" health_path = f"{prefix}/health" if prefix else "/health"
return request.path == health_path if request.path == health_path:
return True
# Trusted certificate holders bypass rate limiting
try:
from app.api.routes import get_client_id
return get_client_id() is not None
except Exception:
return False
limiter = Limiter( limiter = Limiter(
key_func=get_remote_address, key_func=get_remote_address,
@@ -239,7 +250,7 @@ def setup_rate_limiting(app: Flask) -> None:
default_limits=["200 per day", "60 per hour"], default_limits=["200 per day", "60 per hour"],
storage_uri="memory://", storage_uri="memory://",
strategy="fixed-window", strategy="fixed-window",
default_limits_exempt_when=is_health_endpoint, default_limits_exempt_when=is_rate_limit_exempt,
) )
# Store limiter on app for use in routes # Store limiter on app for use in routes

View File

@@ -504,6 +504,8 @@ def validate_paste_id(paste_id: str) -> Response | None:
def fetch_paste(paste_id: str, check_password: bool = True) -> Response | None: def fetch_paste(paste_id: str, check_password: bool = True) -> Response | None:
"""Fetch paste and store in g.paste. Returns error response or None if OK.""" """Fetch paste and store in g.paste. Returns error response or None if OK."""
# ENUM-001: Rate limit lookups to prevent enumeration attacks # ENUM-001: Rate limit lookups to prevent enumeration attacks
# Trusted certificate holders are exempt
if not get_client_id():
client_ip = get_client_ip() client_ip = get_client_ip()
allowed, retry_after = check_lookup_rate_limit(client_ip) allowed, retry_after = check_lookup_rate_limit(client_ip)
if not allowed: if not allowed:
@@ -588,6 +590,8 @@ def validate_target_url(url: str) -> Response | None:
def fetch_short_url(short_id: str, increment_counter: bool = True) -> Response | None: def fetch_short_url(short_id: str, increment_counter: bool = True) -> Response | None:
"""Fetch short URL and store in g.short_url. Returns error response or None if OK.""" """Fetch short URL and store in g.short_url. Returns error response or None if OK."""
# Trusted certificate holders are exempt from lookup rate limiting
if not get_client_id():
client_ip = get_client_ip() client_ip = get_client_ip()
allowed, retry_after = check_lookup_rate_limit(client_ip) allowed, retry_after = check_lookup_rate_limit(client_ip)
if not allowed: if not allowed:
@@ -983,7 +987,7 @@ class IndexView(MethodView):
"authentication": { "authentication": {
"anonymous": "Create pastes only (strict limits)", "anonymous": "Create pastes only (strict limits)",
"client_cert": "Create + manage own pastes (strict limits)", "client_cert": "Create + manage own pastes (strict limits)",
"trusted_cert": "All operations (relaxed limits)", "trusted_cert": "All operations (no rate limits)",
}, },
"limits": { "limits": {
"anonymous": { "anonymous": {
@@ -992,7 +996,7 @@ class IndexView(MethodView):
}, },
"trusted": { "trusted": {
"max_size": current_app.config["MAX_PASTE_SIZE_AUTH"], "max_size": current_app.config["MAX_PASTE_SIZE_AUTH"],
"rate": f"{current_app.config['RATE_LIMIT_MAX'] * current_app.config.get('RATE_LIMIT_AUTH_MULTIPLIER', 5)}/min", # noqa: E501 "rate": "unlimited",
}, },
}, },
"pow": { "pow": {
@@ -1036,10 +1040,11 @@ class IndexView(MethodView):
trusted_client = get_client_id() # Only trusted certs get elevated limits trusted_client = get_client_id() # Only trusted certs get elevated limits
owner = get_client_fingerprint() # Any cert can own pastes owner = get_client_fingerprint() # Any cert can own pastes
# Rate limiting (check before expensive operations) # Rate limiting (trusted certs exempt)
client_ip = get_client_ip() client_ip = get_client_ip()
if not trusted_client:
allowed, remaining, limit, reset_timestamp = check_rate_limit( allowed, remaining, limit, reset_timestamp = check_rate_limit(
client_ip, authenticated=bool(trusted_client) client_ip, authenticated=False
) )
# Store rate limit info for response headers # Store rate limit info for response headers
@@ -1049,7 +1054,7 @@ class IndexView(MethodView):
if not allowed: if not allowed:
current_app.logger.warning( current_app.logger.warning(
"Rate limit exceeded: ip=%s trusted=%s", client_ip, bool(trusted_client) "Rate limit exceeded: ip=%s", client_ip
) )
# Audit log rate limit event # Audit log rate limit event
if current_app.config.get("AUDIT_ENABLED", True): if current_app.config.get("AUDIT_ENABLED", True):
@@ -1994,16 +1999,22 @@ class ShortURLCreateView(MethodView):
owner = get_client_fingerprint() owner = get_client_fingerprint()
client_ip = get_client_ip() client_ip = get_client_ip()
# Rate limiting (trusted certs exempt)
if not trusted_client:
allowed, remaining, limit, reset_timestamp = check_rate_limit( allowed, remaining, limit, reset_timestamp = check_rate_limit(
client_ip, authenticated=bool(trusted_client) client_ip, authenticated=False
) )
if not allowed: if not allowed:
record_rate_limit("blocked") record_rate_limit("blocked")
retry_after = max(1, reset_timestamp - int(time.time())) retry_after = max(1, reset_timestamp - int(time.time()))
response = error_response("Rate limit exceeded", 429, retry_after=retry_after) response = error_response(
"Rate limit exceeded", 429, retry_after=retry_after
)
response.headers["Retry-After"] = str(retry_after) response.headers["Retry-After"] = str(retry_after)
add_rate_limit_headers(response, 0, limit, reset_timestamp) add_rate_limit_headers(response, 0, limit, reset_timestamp)
return response return response
else:
remaining, limit, reset_timestamp = -1, -1, 0
# Proof-of-work (trusted certs exempt) # Proof-of-work (trusted certs exempt)
difficulty = current_app.config["POW_DIFFICULTY"] difficulty = current_app.config["POW_DIFFICULTY"]

View File

@@ -92,44 +92,37 @@ class TestRateLimiting:
finally: finally:
app.config["RATE_LIMIT_MAX"] = original_max app.config["RATE_LIMIT_MAX"] = original_max
def test_rate_limit_auth_multiplier(self, client, app, auth_header): def test_trusted_cert_exempt_from_rate_limit(self, client, app, auth_header):
"""Authenticated users get higher rate limits.""" """Trusted certificate holders are exempt from rate limiting."""
original_max = app.config["RATE_LIMIT_MAX"] original_max = app.config["RATE_LIMIT_MAX"]
original_mult = app.config["RATE_LIMIT_AUTH_MULTIPLIER"]
app.config["RATE_LIMIT_MAX"] = 2 app.config["RATE_LIMIT_MAX"] = 2
app.config["RATE_LIMIT_AUTH_MULTIPLIER"] = 3 # 2 * 3 = 6 for auth users
try: try:
# Authenticated user can make more requests than base limit # Trusted client can exceed the base limit without being blocked
for i in range(5): for i in range(5):
response = client.post( response = client.post(
"/", "/",
data=f"auth {i}", data=f"trusted {i}",
content_type="text/plain", content_type="text/plain",
headers=auth_header, headers=auth_header,
) )
assert response.status_code == 201 assert response.status_code == 201
# 6th request should succeed (limit is 2*3=6) # Anonymous user hits the limit
response = client.post( for i in range(2):
client.post(
"/", "/",
data="auth 6", data=f"anon {i}",
content_type="text/plain", content_type="text/plain",
headers=auth_header,
) )
assert response.status_code == 201
# 7th should fail
response = client.post( response = client.post(
"/", "/",
data="auth 7", data="anon overflow",
content_type="text/plain", content_type="text/plain",
headers=auth_header,
) )
assert response.status_code == 429 assert response.status_code == 429
finally: finally:
app.config["RATE_LIMIT_MAX"] = original_max app.config["RATE_LIMIT_MAX"] = original_max
app.config["RATE_LIMIT_AUTH_MULTIPLIER"] = original_mult
def test_rate_limit_can_be_disabled(self, client, app): def test_rate_limit_can_be_disabled(self, client, app):
"""Rate limiting can be disabled via config.""" """Rate limiting can be disabled via config."""