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:
27
README.md
27
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -312,6 +312,37 @@ Password protected content
|
||||
|
||||
| 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}
|
||||
|
||||
### HEAD /{id}
|
||||
|
||||
Retrieve paste metadata. HEAD returns headers only (no body).
|
||||
|
||||
**Request:**
|
||||
|
||||
48
examples/flaskpaste.env
Normal file
48
examples/flaskpaste.env
Normal file
@@ -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
|
||||
83
examples/flaskpaste.service
Normal file
83
examples/flaskpaste.service
Normal file
@@ -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
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user