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

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

View File

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

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

View File

@@ -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
View 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

View 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

View File

@@ -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"]