diff --git a/README.md b/README.md index a079687..58c98bb 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ A lightweight, secure pastebin REST API built with Flask. - **Abuse prevention** - Content-hash deduplication throttles spam - **Proof-of-work** - Computational puzzles prevent automated abuse - **Anti-flood** - Dynamic PoW difficulty increases under attack -- **Rate limiting** - Per-IP throttling with auth multiplier +- **Rate limiting** - Per-IP throttling with X-RateLimit-* headers - **E2E encryption** - Client-side AES-256-GCM with key in URL fragment - **Burn-after-read** - Single-access pastes that auto-delete - **Password protection** - PBKDF2-HMAC-SHA256 with 600k iterations @@ -338,6 +338,29 @@ podman run -d -p 5000:5000 -v flaskpaste-data:/app/data flaskpaste See `Containerfile` for container build configuration. +### Using systemd +```bash +# Create service user +sudo useradd -r -s /sbin/nologin flaskpaste + +# Copy application +sudo mkdir -p /opt/flaskpaste/data +sudo cp -r . /opt/flaskpaste/ +sudo chown -R flaskpaste:flaskpaste /opt/flaskpaste + +# Copy service unit and environment file +sudo cp examples/flaskpaste.service /etc/systemd/system/ +sudo mkdir -p /etc/flaskpaste +sudo cp examples/flaskpaste.env /etc/flaskpaste/env +sudo chmod 600 /etc/flaskpaste/env + +# Enable and start service +sudo systemctl daemon-reload +sudo systemctl enable --now flaskpaste +``` + +See `examples/` for service unit and configuration templates. + ## Development ### Running Tests @@ -385,7 +408,7 @@ flaskpaste/ - **Password protection** - PBKDF2-HMAC-SHA256 with 600k iterations - **Security headers** - HSTS, CSP, X-Frame-Options, X-Content-Type-Options - **Proof-of-work** - Computational puzzles prevent automated spam -- **Rate limiting** - Per-IP throttling with auth multiplier +- **Rate limiting** - Per-IP throttling with X-RateLimit-* headers - **Request tracing** - X-Request-ID for log correlation - **PKI support** - Built-in CA for client certificate issuance - **Audit logging** - PKI certificate events for compliance and forensics diff --git a/TASKLIST.md b/TASKLIST.md index d02cbf1..3eedafc 100644 --- a/TASKLIST.md +++ b/TASKLIST.md @@ -10,13 +10,6 @@ Prioritized, actionable tasks. Each task is small and completable in one session |--------|-------------------------------------------------------------- | ☐ | Create Ansible deployment role | ☐ | Add Kubernetes manifests (Deployment, Service, ConfigMap) -| ☐ | Add systemd service unit example - -## Priority 2: Features - -| Status | Task -|--------|-------------------------------------------------------------- -| ☐ | Add rate limit headers (X-RateLimit-*) ## Priority 3: Quality @@ -35,6 +28,8 @@ Prioritized, actionable tasks. Each task is small and completable in one session | Date | Task |------------|-------------------------------------------------------------- +| 2024-12 | Add systemd service unit example +| 2024-12 | Add rate limit headers (X-RateLimit-*) | 2024-12 | Integrate PKI audit logging (CERT_ISSUED, CERT_REVOKED, AUTH_FAILURE) | 2024-12 | Integrate request duration metrics (Prometheus histogram) | 2024-12 | Add memory leak detection tests (tracemalloc) diff --git a/app/api/routes.py b/app/api/routes.py index 09907b6..85c4674 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -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): diff --git a/documentation/api.md b/documentation/api.md index f83c9d3..248e9af 100644 --- a/documentation/api.md +++ b/documentation/api.md @@ -312,6 +312,37 @@ Password protected content - Anonymous: 3 MiB (configurable via `FLASKPASTE_MAX_ANON`) - Authenticated: 50 MiB (configurable via `FLASKPASTE_MAX_AUTH`) +**Rate Limit Headers:** + +When rate limiting is enabled, responses include standard rate limit headers: + +| Header | Description | +|--------|-------------| +| `X-RateLimit-Limit` | Maximum requests per window | +| `X-RateLimit-Remaining` | Remaining requests in current window | +| `X-RateLimit-Reset` | Unix timestamp when window resets | + +These headers appear on both successful (201) and rate-limited (429) responses: + +```http +HTTP/1.1 201 Created +X-RateLimit-Limit: 10 +X-RateLimit-Remaining: 9 +X-RateLimit-Reset: 1700000060 +``` + +```http +HTTP/1.1 429 Too Many Requests +Retry-After: 45 +X-RateLimit-Limit: 10 +X-RateLimit-Remaining: 0 +X-RateLimit-Reset: 1700000060 +``` + +Rate limits are per-IP and configurable: +- `FLASKPASTE_RATE_MAX`: Base limit (default: 10 requests/minute) +- `FLASKPASTE_RATE_AUTH_MULT`: Multiplier for authenticated users (default: 5x) + --- ### GET /{id} diff --git a/examples/flaskpaste.env b/examples/flaskpaste.env new file mode 100644 index 0000000..00c165c --- /dev/null +++ b/examples/flaskpaste.env @@ -0,0 +1,48 @@ +# FlaskPaste environment configuration +# Install: sudo mkdir -p /etc/flaskpaste && sudo cp flaskpaste.env /etc/flaskpaste/env +# Permissions: sudo chmod 600 /etc/flaskpaste/env + +# Flask environment +FLASK_ENV=production + +# Database path +FLASKPASTE_DB=/opt/flaskpaste/data/pastes.db + +# Paste limits +FLASKPASTE_MAX_ANON=3145728 +FLASKPASTE_MAX_AUTH=52428800 + +# Expiry (tiered by authentication level) +FLASKPASTE_EXPIRY_ANON=86400 +FLASKPASTE_EXPIRY_UNTRUSTED=604800 +FLASKPASTE_EXPIRY_TRUSTED=2592000 + +# Proof-of-work (set to 0 to disable) +FLASKPASTE_POW_DIFFICULTY=20 +FLASKPASTE_POW_TTL=300 + +# Anti-flood +FLASKPASTE_ANTIFLOOD=1 +FLASKPASTE_ANTIFLOOD_THRESHOLD=5 +FLASKPASTE_ANTIFLOOD_MAX=28 + +# Rate limiting +FLASKPASTE_RATE_LIMIT=1 +FLASKPASTE_RATE_WINDOW=60 +FLASKPASTE_RATE_MAX=10 +FLASKPASTE_RATE_AUTH_MULT=5 + +# Content deduplication +FLASKPASTE_DEDUP_WINDOW=3600 +FLASKPASTE_DEDUP_MAX=3 + +# URL prefix (for reverse proxy path-based routing) +# FLASKPASTE_URL_PREFIX=/paste + +# Proxy trust (set shared secret for header validation) +# FLASKPASTE_PROXY_SECRET=your-secret-here + +# PKI (uncomment to enable certificate authority) +# FLASKPASTE_PKI_ENABLED=1 +# FLASKPASTE_PKI_CA_PASSWORD=change-this-secure-password +# FLASKPASTE_PKI_CERT_DAYS=365 diff --git a/examples/flaskpaste.service b/examples/flaskpaste.service new file mode 100644 index 0000000..b5b75e7 --- /dev/null +++ b/examples/flaskpaste.service @@ -0,0 +1,83 @@ +# FlaskPaste systemd service unit +# Install: sudo cp flaskpaste.service /etc/systemd/system/ +# Enable: sudo systemctl daemon-reload && sudo systemctl enable --now flaskpaste +# +# Configuration via environment file: /etc/flaskpaste/env +# See README.md for all available environment variables + +[Unit] +Description=FlaskPaste REST API pastebin +Documentation=https://github.com/username/flaskpaste +After=network-online.target +Wants=network-online.target + +[Service] +Type=exec +User=flaskpaste +Group=flaskpaste +WorkingDirectory=/opt/flaskpaste + +# Environment configuration +EnvironmentFile=-/etc/flaskpaste/env + +# Gunicorn WSGI server +# Workers = 2 * CPU cores + 1 (adjust based on load) +ExecStart=/opt/flaskpaste/venv/bin/gunicorn \ + --bind 127.0.0.1:5000 \ + --workers 4 \ + --worker-class sync \ + --timeout 30 \ + --keep-alive 5 \ + --max-requests 1000 \ + --max-requests-jitter 50 \ + --access-logfile - \ + --error-logfile - \ + --capture-output \ + wsgi:app + +# Restart policy +Restart=on-failure +RestartSec=5s +StartLimitIntervalSec=60 +StartLimitBurst=3 + +# Resource limits +LimitNOFILE=65536 +LimitNPROC=4096 + +# Security hardening (systemd v232+) +NoNewPrivileges=yes +PrivateTmp=yes +PrivateDevices=yes +ProtectSystem=strict +ProtectHome=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectKernelLogs=yes +ProtectControlGroups=yes +ProtectClock=yes +ProtectHostname=yes +RestrictRealtime=yes +RestrictSUIDSGID=yes +RestrictNamespaces=yes +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX +LockPersonality=yes +MemoryDenyWriteExecute=yes +SystemCallArchitectures=native +SystemCallFilter=@system-service +SystemCallFilter=~@privileged @resources + +# Read-write paths (database, data directory) +ReadWritePaths=/opt/flaskpaste/data + +# Capabilities +CapabilityBoundingSet= +AmbientCapabilities= + +# Logging +StandardOutput=journal +StandardError=journal +SyslogIdentifier=flaskpaste + +[Install] +WantedBy=multi-user.target diff --git a/tests/test_rate_limiting.py b/tests/test_rate_limiting.py index 219ab16..c8fac43 100644 --- a/tests/test_rate_limiting.py +++ b/tests/test_rate_limiting.py @@ -65,6 +65,33 @@ class TestRateLimiting: finally: app.config["RATE_LIMIT_MAX"] = original_max + def test_rate_limit_headers_on_success(self, client, app): + """Successful responses include rate limit headers.""" + original_max = app.config["RATE_LIMIT_MAX"] + app.config["RATE_LIMIT_MAX"] = 5 + + try: + # First request should include rate limit headers + response = client.post("/", data="first", content_type="text/plain") + assert response.status_code == 201 + + # Check rate limit headers + assert "X-RateLimit-Limit" in response.headers + assert "X-RateLimit-Remaining" in response.headers + assert "X-RateLimit-Reset" in response.headers + + # Verify values + assert response.headers["X-RateLimit-Limit"] == "5" + assert response.headers["X-RateLimit-Remaining"] == "4" # 5 - 1 = 4 + + # Reset timestamp should be a valid unix timestamp + reset = int(response.headers["X-RateLimit-Reset"]) + import time + + assert reset > int(time.time()) # Should be in the future + finally: + app.config["RATE_LIMIT_MAX"] = original_max + def test_rate_limit_auth_multiplier(self, client, app, auth_header): """Authenticated users get higher rate limits.""" original_max = app.config["RATE_LIMIT_MAX"]