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:
Username
2025-12-24 17:51:14 +01:00
parent cb6eebee59
commit cf458347ef
7 changed files with 265 additions and 22 deletions

View File

@@ -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):