forked from username/flaskpaste
add systemd service unit and rate limit headers
Systemd deployment: - examples/flaskpaste.service with security hardening - examples/flaskpaste.env with all config options - README deployment section updated Rate limit headers (X-RateLimit-*): - Limit, Remaining, Reset on 201 and 429 responses - Per-IP tracking with auth multiplier - api.md documented
This commit is contained in:
@@ -146,7 +146,7 @@ def get_client_ip() -> str:
|
||||
return request.remote_addr or "unknown"
|
||||
|
||||
|
||||
def check_rate_limit(client_ip: str, authenticated: bool = False) -> tuple[bool, int, int]:
|
||||
def check_rate_limit(client_ip: str, authenticated: bool = False) -> tuple[bool, int, int, int]:
|
||||
"""Check if request is within rate limit.
|
||||
|
||||
Args:
|
||||
@@ -154,10 +154,14 @@ def check_rate_limit(client_ip: str, authenticated: bool = False) -> tuple[bool,
|
||||
authenticated: Whether client is authenticated (higher limits)
|
||||
|
||||
Returns:
|
||||
Tuple of (allowed, remaining, reset_seconds)
|
||||
Tuple of (allowed, remaining, limit, reset_timestamp)
|
||||
- allowed: Whether request is within rate limit
|
||||
- remaining: Requests remaining in current window
|
||||
- limit: Maximum requests per window
|
||||
- reset_timestamp: Unix timestamp when window resets
|
||||
"""
|
||||
if not current_app.config.get("RATE_LIMIT_ENABLED", True):
|
||||
return True, -1, 0
|
||||
return True, -1, -1, 0
|
||||
|
||||
window = current_app.config["RATE_LIMIT_WINDOW"]
|
||||
max_requests = current_app.config["RATE_LIMIT_MAX"]
|
||||
@@ -175,16 +179,17 @@ def check_rate_limit(client_ip: str, authenticated: bool = False) -> tuple[bool,
|
||||
|
||||
current_count = len(requests)
|
||||
|
||||
# Calculate reset timestamp (when oldest request expires, or now + window if empty)
|
||||
reset_timestamp = int(requests[0] + window) if requests else int(now + window)
|
||||
|
||||
if current_count >= max_requests:
|
||||
# Calculate reset time (when oldest request expires)
|
||||
reset_at = int(requests[0] + window - now) + 1 if requests else window
|
||||
return False, 0, reset_at
|
||||
return False, 0, max_requests, reset_timestamp
|
||||
|
||||
# Record this request
|
||||
requests.append(now)
|
||||
remaining = max_requests - len(requests)
|
||||
|
||||
return True, remaining, window
|
||||
return True, remaining, max_requests, reset_timestamp
|
||||
|
||||
|
||||
def cleanup_rate_limits(window: int | None = None) -> int:
|
||||
@@ -219,6 +224,23 @@ def reset_rate_limits() -> None:
|
||||
_rate_limit_requests.clear()
|
||||
|
||||
|
||||
def add_rate_limit_headers(
|
||||
response: Response, remaining: int, limit: int, reset_timestamp: int
|
||||
) -> Response:
|
||||
"""Add standard rate limit headers to response.
|
||||
|
||||
Headers follow draft-ietf-httpapi-ratelimit-headers convention:
|
||||
- X-RateLimit-Limit: Maximum requests per window
|
||||
- X-RateLimit-Remaining: Requests remaining in current window
|
||||
- X-RateLimit-Reset: Unix timestamp when window resets
|
||||
"""
|
||||
if limit > 0: # Only add headers if rate limiting is enabled
|
||||
response.headers["X-RateLimit-Limit"] = str(limit)
|
||||
response.headers["X-RateLimit-Remaining"] = str(max(0, remaining))
|
||||
response.headers["X-RateLimit-Reset"] = str(reset_timestamp)
|
||||
return response
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Response Helpers
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -774,10 +796,15 @@ class IndexView(MethodView):
|
||||
|
||||
# Rate limiting (check before expensive operations)
|
||||
client_ip = get_client_ip()
|
||||
allowed, _remaining, reset_seconds = check_rate_limit(
|
||||
allowed, remaining, limit, reset_timestamp = check_rate_limit(
|
||||
client_ip, authenticated=bool(trusted_client)
|
||||
)
|
||||
|
||||
# Store rate limit info for response headers
|
||||
g.rate_limit_remaining = remaining
|
||||
g.rate_limit_limit = limit
|
||||
g.rate_limit_reset = reset_timestamp
|
||||
|
||||
if not allowed:
|
||||
current_app.logger.warning(
|
||||
"Rate limit exceeded: ip=%s trusted=%s", client_ip, bool(trusted_client)
|
||||
@@ -793,14 +820,14 @@ class IndexView(MethodView):
|
||||
client_ip=client_ip,
|
||||
)
|
||||
record_rate_limit("blocked")
|
||||
retry_after = max(1, reset_timestamp - int(time.time()))
|
||||
response = error_response(
|
||||
"Rate limit exceeded",
|
||||
429,
|
||||
retry_after=reset_seconds,
|
||||
retry_after=retry_after,
|
||||
)
|
||||
response.headers["Retry-After"] = str(reset_seconds)
|
||||
response.headers["X-RateLimit-Remaining"] = "0"
|
||||
response.headers["X-RateLimit-Reset"] = str(reset_seconds)
|
||||
response.headers["Retry-After"] = str(retry_after)
|
||||
add_rate_limit_headers(response, 0, limit, reset_timestamp)
|
||||
return response
|
||||
|
||||
# Proof-of-work verification
|
||||
@@ -1036,7 +1063,16 @@ class IndexView(MethodView):
|
||||
|
||||
record_paste_created("authenticated" if owner else "anonymous", "success")
|
||||
|
||||
return json_response(response_data, 201)
|
||||
response = json_response(response_data, 201)
|
||||
|
||||
# Add rate limit headers to successful response
|
||||
rl_remaining = getattr(g, "rate_limit_remaining", -1)
|
||||
rl_limit = getattr(g, "rate_limit_limit", -1)
|
||||
rl_reset = getattr(g, "rate_limit_reset", 0)
|
||||
if rl_limit > 0:
|
||||
add_rate_limit_headers(response, rl_remaining, rl_limit, rl_reset)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class HealthView(MethodView):
|
||||
|
||||
Reference in New Issue
Block a user