flaskpaste: initial commit with security hardening
Features: - REST API for text/binary pastes with MIME detection - Client certificate auth via X-SSL-Client-SHA1 header - SQLite with WAL mode for concurrent access - Automatic paste expiry with LRU cleanup Security: - HSTS, CSP, X-Frame-Options, X-Content-Type-Options - Cache-Control: no-store for sensitive responses - X-Request-ID tracing for log correlation - X-Proxy-Secret validation for defense-in-depth - Parameterized queries, input validation - Size limits (3 MiB anon, 50 MiB auth) Includes /health endpoint, container support, and 70 tests.
This commit is contained in:
319
app/api/routes.py
Normal file
319
app/api/routes.py
Normal file
@@ -0,0 +1,319 @@
|
||||
"""API route handlers."""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
|
||||
from flask import Response, current_app, request
|
||||
|
||||
from app.api import bp
|
||||
from app.database import get_db
|
||||
|
||||
# Valid paste ID pattern (hexadecimal only)
|
||||
PASTE_ID_PATTERN = re.compile(r"^[a-f0-9]+$")
|
||||
|
||||
# Valid client certificate SHA1 pattern (40 hex chars)
|
||||
CLIENT_ID_PATTERN = re.compile(r"^[a-f0-9]{40}$")
|
||||
|
||||
# Magic bytes for common binary formats
|
||||
MAGIC_SIGNATURES = {
|
||||
b"\x89PNG\r\n\x1a\n": "image/png",
|
||||
b"\xff\xd8\xff": "image/jpeg",
|
||||
b"GIF87a": "image/gif",
|
||||
b"GIF89a": "image/gif",
|
||||
b"RIFF": "image/webp", # WebP (check for WEBP after RIFF)
|
||||
b"PK\x03\x04": "application/zip",
|
||||
b"%PDF": "application/pdf",
|
||||
b"\x1f\x8b": "application/gzip",
|
||||
}
|
||||
|
||||
|
||||
def _is_valid_paste_id(paste_id: str) -> bool:
|
||||
"""Validate paste ID format (hexadecimal, correct length)."""
|
||||
expected_length = current_app.config["PASTE_ID_LENGTH"]
|
||||
return (
|
||||
len(paste_id) == expected_length
|
||||
and PASTE_ID_PATTERN.match(paste_id) is not None
|
||||
)
|
||||
|
||||
|
||||
def _detect_mime_type(content: bytes, content_type: str | None = None) -> str:
|
||||
"""Detect MIME type from content bytes, with magic byte detection taking priority."""
|
||||
# Check magic bytes first - most reliable method
|
||||
for magic, mime in MAGIC_SIGNATURES.items():
|
||||
if content.startswith(magic):
|
||||
# Special case for WebP (RIFF....WEBP)
|
||||
if magic == b"RIFF" and len(content) >= 12:
|
||||
if content[8:12] != b"WEBP":
|
||||
continue
|
||||
return mime
|
||||
|
||||
# Trust explicit Content-Type if it's specific (not generic defaults)
|
||||
generic_types = {
|
||||
"application/octet-stream",
|
||||
"application/x-www-form-urlencoded",
|
||||
"text/plain",
|
||||
}
|
||||
if content_type:
|
||||
mime = content_type.split(";")[0].strip().lower()
|
||||
if mime not in generic_types:
|
||||
# Sanitize: only allow safe characters in MIME type
|
||||
if re.match(r"^[a-z0-9][a-z0-9!#$&\-^_.+]*\/[a-z0-9][a-z0-9!#$&\-^_.+]*$", mime):
|
||||
return mime
|
||||
|
||||
# Try to decode as UTF-8 text
|
||||
try:
|
||||
content.decode("utf-8")
|
||||
return "text/plain"
|
||||
except UnicodeDecodeError:
|
||||
return "application/octet-stream"
|
||||
|
||||
|
||||
def _generate_id(content: bytes) -> str:
|
||||
"""Generate a short unique ID from content hash and timestamp."""
|
||||
data = content + str(time.time_ns()).encode()
|
||||
length = current_app.config["PASTE_ID_LENGTH"]
|
||||
return hashlib.sha256(data).hexdigest()[:length]
|
||||
|
||||
|
||||
def _json_response(data: dict, status: int = 200) -> Response:
|
||||
"""Create a JSON response with proper encoding and security headers."""
|
||||
response = Response(
|
||||
json.dumps(data, ensure_ascii=False),
|
||||
status=status,
|
||||
mimetype="application/json",
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
def _is_trusted_proxy() -> bool:
|
||||
"""Verify request comes from a trusted reverse proxy.
|
||||
|
||||
If TRUSTED_PROXY_SECRET is configured, the request must include a matching
|
||||
X-Proxy-Secret header. This provides defense-in-depth against header spoofing
|
||||
if an attacker bypasses the reverse proxy.
|
||||
|
||||
Returns True if no secret is configured (backwards compatible) or if the
|
||||
secret matches.
|
||||
"""
|
||||
expected_secret = current_app.config.get("TRUSTED_PROXY_SECRET", "")
|
||||
if not expected_secret:
|
||||
# No secret configured - trust all requests (backwards compatible)
|
||||
return True
|
||||
|
||||
# Constant-time comparison to prevent timing attacks
|
||||
provided_secret = request.headers.get("X-Proxy-Secret", "")
|
||||
return hmac.compare_digest(expected_secret, provided_secret)
|
||||
|
||||
|
||||
def _get_client_id() -> str | None:
|
||||
"""Extract and validate client identity from X-SSL-Client-SHA1 header.
|
||||
|
||||
Returns lowercase SHA1 fingerprint or None if not present/invalid.
|
||||
|
||||
SECURITY: The X-SSL-Client-SHA1 header is only trusted if the request
|
||||
comes from a trusted proxy (verified via X-Proxy-Secret if configured).
|
||||
"""
|
||||
# Verify request comes from trusted proxy before trusting auth headers
|
||||
if not _is_trusted_proxy():
|
||||
current_app.logger.warning(
|
||||
"Auth header ignored: X-Proxy-Secret mismatch from %s",
|
||||
request.remote_addr
|
||||
)
|
||||
return None
|
||||
|
||||
client_sha1 = request.headers.get("X-SSL-Client-SHA1", "").strip().lower()
|
||||
# Validate format: must be 40 hex characters (SHA1)
|
||||
if client_sha1 and CLIENT_ID_PATTERN.match(client_sha1):
|
||||
return client_sha1
|
||||
return None
|
||||
|
||||
|
||||
@bp.route("/health", methods=["GET"])
|
||||
def health():
|
||||
"""Health check endpoint for load balancers and monitoring."""
|
||||
try:
|
||||
db = get_db()
|
||||
db.execute("SELECT 1")
|
||||
return _json_response({"status": "healthy", "database": "ok"})
|
||||
except Exception:
|
||||
return _json_response({"status": "unhealthy", "database": "error"}, 503)
|
||||
|
||||
|
||||
@bp.route("/", methods=["GET", "POST"])
|
||||
def index():
|
||||
"""Handle API info (GET) and paste creation (POST)."""
|
||||
if request.method == "POST":
|
||||
return create_paste()
|
||||
|
||||
return _json_response(
|
||||
{
|
||||
"name": "FlaskPaste",
|
||||
"version": "1.0.0",
|
||||
"endpoints": {
|
||||
"GET /": "API information",
|
||||
"GET /health": "Health check",
|
||||
"POST /": "Create paste",
|
||||
"GET /<id>": "Retrieve paste metadata",
|
||||
"GET /<id>/raw": "Retrieve raw paste content",
|
||||
"DELETE /<id>": "Delete paste",
|
||||
},
|
||||
"usage": {
|
||||
"raw": "curl --data-binary @file.txt http://host/",
|
||||
"pipe": "cat file.txt | curl --data-binary @- http://host/",
|
||||
"json": "curl -H 'Content-Type: application/json' -d '{\"content\":\"...\"}' http://host/",
|
||||
},
|
||||
"note": "Use --data-binary (not -d) to preserve newlines",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def create_paste():
|
||||
"""Create a new paste from request body."""
|
||||
content: bytes | None = None
|
||||
mime_type: str | None = None
|
||||
|
||||
if request.is_json:
|
||||
data = request.get_json(silent=True)
|
||||
if data and isinstance(data.get("content"), str):
|
||||
content = data["content"].encode("utf-8")
|
||||
mime_type = "text/plain"
|
||||
else:
|
||||
content = request.get_data(as_text=False)
|
||||
if content:
|
||||
mime_type = _detect_mime_type(content, request.content_type)
|
||||
|
||||
if not content:
|
||||
return _json_response({"error": "No content provided"}, 400)
|
||||
|
||||
owner = _get_client_id()
|
||||
|
||||
# Enforce size limits based on authentication
|
||||
content_size = len(content)
|
||||
if owner:
|
||||
max_size = current_app.config["MAX_PASTE_SIZE_AUTH"]
|
||||
else:
|
||||
max_size = current_app.config["MAX_PASTE_SIZE_ANON"]
|
||||
|
||||
if content_size > max_size:
|
||||
return _json_response({
|
||||
"error": "Paste too large",
|
||||
"size": content_size,
|
||||
"max_size": max_size,
|
||||
"authenticated": owner is not None,
|
||||
}, 413)
|
||||
|
||||
paste_id = _generate_id(content)
|
||||
now = int(time.time())
|
||||
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"INSERT INTO pastes (id, content, mime_type, owner, created_at, last_accessed) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(paste_id, content, mime_type, owner, now, now),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
response_data = {
|
||||
"id": paste_id,
|
||||
"url": f"/{paste_id}",
|
||||
"raw": f"/{paste_id}/raw",
|
||||
"mime_type": mime_type,
|
||||
"created_at": now,
|
||||
}
|
||||
if owner:
|
||||
response_data["owner"] = owner
|
||||
|
||||
return _json_response(response_data, 201)
|
||||
|
||||
|
||||
@bp.route("/<paste_id>", methods=["GET"])
|
||||
def get_paste(paste_id: str):
|
||||
"""Retrieve paste metadata by ID."""
|
||||
if not _is_valid_paste_id(paste_id):
|
||||
return _json_response({"error": "Invalid paste ID"}, 400)
|
||||
|
||||
db = get_db()
|
||||
now = int(time.time())
|
||||
|
||||
# Update last_accessed and return paste in one transaction
|
||||
db.execute(
|
||||
"UPDATE pastes SET last_accessed = ? WHERE id = ?", (now, paste_id)
|
||||
)
|
||||
row = db.execute(
|
||||
"SELECT id, mime_type, created_at, length(content) as size FROM pastes WHERE id = ?",
|
||||
(paste_id,)
|
||||
).fetchone()
|
||||
db.commit()
|
||||
|
||||
if row is None:
|
||||
return _json_response({"error": "Paste not found"}, 404)
|
||||
|
||||
return _json_response({
|
||||
"id": row["id"],
|
||||
"mime_type": row["mime_type"],
|
||||
"size": row["size"],
|
||||
"created_at": row["created_at"],
|
||||
"raw": f"/{paste_id}/raw",
|
||||
})
|
||||
|
||||
|
||||
@bp.route("/<paste_id>/raw", methods=["GET"])
|
||||
def get_paste_raw(paste_id: str):
|
||||
"""Retrieve raw paste content with correct MIME type."""
|
||||
if not _is_valid_paste_id(paste_id):
|
||||
return _json_response({"error": "Invalid paste ID"}, 400)
|
||||
|
||||
db = get_db()
|
||||
now = int(time.time())
|
||||
|
||||
# Update last_accessed and return paste in one transaction
|
||||
db.execute(
|
||||
"UPDATE pastes SET last_accessed = ? WHERE id = ?", (now, paste_id)
|
||||
)
|
||||
row = db.execute(
|
||||
"SELECT content, mime_type FROM pastes WHERE id = ?", (paste_id,)
|
||||
).fetchone()
|
||||
db.commit()
|
||||
|
||||
if row is None:
|
||||
return _json_response({"error": "Paste not found"}, 404)
|
||||
|
||||
mime_type = row["mime_type"]
|
||||
|
||||
response = Response(row["content"], mimetype=mime_type)
|
||||
# Display inline for images and text, let browser decide for others
|
||||
if mime_type.startswith(("image/", "text/")):
|
||||
response.headers["Content-Disposition"] = "inline"
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@bp.route("/<paste_id>", methods=["DELETE"])
|
||||
def delete_paste(paste_id: str):
|
||||
"""Delete a paste by ID. Requires ownership via X-SSL-Client-SHA1 header."""
|
||||
if not _is_valid_paste_id(paste_id):
|
||||
return _json_response({"error": "Invalid paste ID"}, 400)
|
||||
|
||||
client_id = _get_client_id()
|
||||
if not client_id:
|
||||
return _json_response({"error": "Authentication required"}, 401)
|
||||
|
||||
db = get_db()
|
||||
|
||||
# Check paste exists and verify ownership
|
||||
row = db.execute(
|
||||
"SELECT owner FROM pastes WHERE id = ?", (paste_id,)
|
||||
).fetchone()
|
||||
|
||||
if row is None:
|
||||
return _json_response({"error": "Paste not found"}, 404)
|
||||
|
||||
if row["owner"] != client_id:
|
||||
return _json_response({"error": "Permission denied"}, 403)
|
||||
|
||||
db.execute("DELETE FROM pastes WHERE id = ?", (paste_id,))
|
||||
db.commit()
|
||||
|
||||
return _json_response({"message": "Paste deleted"})
|
||||
Reference in New Issue
Block a user