forked from username/flaskpaste
routes: skip rate limiting for trusted certificate holders
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
Reference in New Issue
Block a user