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
|
- **Abuse prevention** - Content-hash deduplication throttles spam
|
||||||
- **Proof-of-work** - Computational puzzles prevent automated abuse
|
- **Proof-of-work** - Computational puzzles prevent automated abuse
|
||||||
- **Anti-flood** - Dynamic PoW difficulty increases under attack
|
- **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
|
- **E2E encryption** - Client-side AES-256-GCM with key in URL fragment
|
||||||
- **Burn-after-read** - Single-access pastes that auto-delete
|
- **Burn-after-read** - Single-access pastes that auto-delete
|
||||||
- **Password protection** - PBKDF2-HMAC-SHA256 with 600k iterations
|
- **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.
|
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
|
## Development
|
||||||
|
|
||||||
### Running Tests
|
### Running Tests
|
||||||
@@ -385,7 +408,7 @@ flaskpaste/
|
|||||||
- **Password protection** - PBKDF2-HMAC-SHA256 with 600k iterations
|
- **Password protection** - PBKDF2-HMAC-SHA256 with 600k iterations
|
||||||
- **Security headers** - HSTS, CSP, X-Frame-Options, X-Content-Type-Options
|
- **Security headers** - HSTS, CSP, X-Frame-Options, X-Content-Type-Options
|
||||||
- **Proof-of-work** - Computational puzzles prevent automated spam
|
- **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
|
- **Request tracing** - X-Request-ID for log correlation
|
||||||
- **PKI support** - Built-in CA for client certificate issuance
|
- **PKI support** - Built-in CA for client certificate issuance
|
||||||
- **Audit logging** - PKI certificate events for compliance and forensics
|
- **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
|
| ☐ | Create Ansible deployment role
|
||||||
| ☐ | Add Kubernetes manifests (Deployment, Service, ConfigMap)
|
| ☐ | 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
|
## Priority 3: Quality
|
||||||
|
|
||||||
@@ -35,6 +28,8 @@ Prioritized, actionable tasks. Each task is small and completable in one session
|
|||||||
|
|
||||||
| Date | Task
|
| 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 PKI audit logging (CERT_ISSUED, CERT_REVOKED, AUTH_FAILURE)
|
||||||
| 2024-12 | Integrate request duration metrics (Prometheus histogram)
|
| 2024-12 | Integrate request duration metrics (Prometheus histogram)
|
||||||
| 2024-12 | Add memory leak detection tests (tracemalloc)
|
| 2024-12 | Add memory leak detection tests (tracemalloc)
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ def get_client_ip() -> str:
|
|||||||
return request.remote_addr or "unknown"
|
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.
|
"""Check if request is within rate limit.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -154,10 +154,14 @@ def check_rate_limit(client_ip: str, authenticated: bool = False) -> tuple[bool,
|
|||||||
authenticated: Whether client is authenticated (higher limits)
|
authenticated: Whether client is authenticated (higher limits)
|
||||||
|
|
||||||
Returns:
|
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):
|
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"]
|
window = current_app.config["RATE_LIMIT_WINDOW"]
|
||||||
max_requests = current_app.config["RATE_LIMIT_MAX"]
|
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)
|
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:
|
if current_count >= max_requests:
|
||||||
# Calculate reset time (when oldest request expires)
|
return False, 0, max_requests, reset_timestamp
|
||||||
reset_at = int(requests[0] + window - now) + 1 if requests else window
|
|
||||||
return False, 0, reset_at
|
|
||||||
|
|
||||||
# Record this request
|
# Record this request
|
||||||
requests.append(now)
|
requests.append(now)
|
||||||
remaining = max_requests - len(requests)
|
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:
|
def cleanup_rate_limits(window: int | None = None) -> int:
|
||||||
@@ -219,6 +224,23 @@ def reset_rate_limits() -> None:
|
|||||||
_rate_limit_requests.clear()
|
_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
|
# Response Helpers
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -774,10 +796,15 @@ class IndexView(MethodView):
|
|||||||
|
|
||||||
# Rate limiting (check before expensive operations)
|
# Rate limiting (check before expensive operations)
|
||||||
client_ip = get_client_ip()
|
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)
|
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:
|
if not allowed:
|
||||||
current_app.logger.warning(
|
current_app.logger.warning(
|
||||||
"Rate limit exceeded: ip=%s trusted=%s", client_ip, bool(trusted_client)
|
"Rate limit exceeded: ip=%s trusted=%s", client_ip, bool(trusted_client)
|
||||||
@@ -793,14 +820,14 @@ class IndexView(MethodView):
|
|||||||
client_ip=client_ip,
|
client_ip=client_ip,
|
||||||
)
|
)
|
||||||
record_rate_limit("blocked")
|
record_rate_limit("blocked")
|
||||||
|
retry_after = max(1, reset_timestamp - int(time.time()))
|
||||||
response = error_response(
|
response = error_response(
|
||||||
"Rate limit exceeded",
|
"Rate limit exceeded",
|
||||||
429,
|
429,
|
||||||
retry_after=reset_seconds,
|
retry_after=retry_after,
|
||||||
)
|
)
|
||||||
response.headers["Retry-After"] = str(reset_seconds)
|
response.headers["Retry-After"] = str(retry_after)
|
||||||
response.headers["X-RateLimit-Remaining"] = "0"
|
add_rate_limit_headers(response, 0, limit, reset_timestamp)
|
||||||
response.headers["X-RateLimit-Reset"] = str(reset_seconds)
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
# Proof-of-work verification
|
# Proof-of-work verification
|
||||||
@@ -1036,7 +1063,16 @@ class IndexView(MethodView):
|
|||||||
|
|
||||||
record_paste_created("authenticated" if owner else "anonymous", "success")
|
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):
|
class HealthView(MethodView):
|
||||||
|
|||||||
@@ -312,6 +312,37 @@ Password protected content
|
|||||||
|
|
||||||
| Header | Description |
|
| 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).
|
Retrieve paste metadata. HEAD returns headers only (no body).
|
||||||
|
|
||||||
**Request:**
|
**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:
|
finally:
|
||||||
app.config["RATE_LIMIT_MAX"] = original_max
|
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):
|
def test_rate_limit_auth_multiplier(self, client, app, auth_header):
|
||||||
"""Authenticated users get higher rate limits."""
|
"""Authenticated users get higher rate limits."""
|
||||||
original_max = app.config["RATE_LIMIT_MAX"]
|
original_max = app.config["RATE_LIMIT_MAX"]
|
||||||
|
|||||||
Reference in New Issue
Block a user