Files
flaskpaste/app/api/routes.py
Username bfc238b5cf add CLI enhancements and scheduled cleanup
CLI commands:
- list: show user's pastes with pagination
- search: filter by type (glob), after/before timestamps
- update: modify content, password, or extend expiry
- export: save pastes to directory with optional decryption

API changes:
- PUT /<id>: update paste content and metadata
- GET /pastes: add type, after, before query params

Scheduled tasks:
- Thread-safe cleanup with per-task intervals
- Activate cleanup_expired_hashes (15min)
- Activate cleanup_rate_limits (5min)

Tests: 205 passing
2025-12-20 20:13:00 +01:00

1422 lines
50 KiB
Python

"""API route handlers using modern Flask patterns."""
from __future__ import annotations
import hashlib
import hmac
import json
import math
import re
import secrets
import threading
import time
from collections import defaultdict
from typing import TYPE_CHECKING, Any
from flask import Response, current_app, g, request
from flask.views import MethodView
from app.api import bp
from app.config import VERSION
from app.database import check_content_hash, get_db, hash_password, verify_password
if TYPE_CHECKING:
from sqlite3 import Row
# Compiled patterns for validation
PASTE_ID_PATTERN = re.compile(r"^[a-f0-9]+$")
CLIENT_ID_PATTERN = re.compile(r"^[a-f0-9]{40}$")
MIME_PATTERN = re.compile(r"^[a-z0-9][a-z0-9!#$&\-^_.+]*/[a-z0-9][a-z0-9!#$&\-^_.+]*$")
# Magic bytes for binary format detection
MAGIC_SIGNATURES: dict[bytes, str] = {
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",
b"PK\x03\x04": "application/zip",
b"%PDF": "application/pdf",
b"\x1f\x8b": "application/gzip",
}
# Generic MIME types to override with detection
GENERIC_MIME_TYPES = frozenset(
{
"application/octet-stream",
"application/x-www-form-urlencoded",
"text/plain",
}
)
# Runtime PoW secret cache
_pow_secret_cache: bytes | None = None
# ─────────────────────────────────────────────────────────────────────────────
# Rate Limiting (in-memory sliding window)
# ─────────────────────────────────────────────────────────────────────────────
_rate_limit_lock = threading.Lock()
_rate_limit_requests: dict[str, list[float]] = defaultdict(list)
def get_client_ip() -> str:
"""Get client IP address, respecting X-Forwarded-For from trusted proxy."""
if is_trusted_proxy():
forwarded = request.headers.get("X-Forwarded-For", "")
if forwarded:
# Take the first (client) IP from the chain
return forwarded.split(",")[0].strip()
return request.remote_addr or "unknown"
def check_rate_limit(client_ip: str, authenticated: bool = False) -> tuple[bool, int, int]:
"""Check if request is within rate limit.
Args:
client_ip: Client IP address
authenticated: Whether client is authenticated (higher limits)
Returns:
Tuple of (allowed, remaining, reset_seconds)
"""
if not current_app.config.get("RATE_LIMIT_ENABLED", True):
return True, -1, 0
window = current_app.config["RATE_LIMIT_WINDOW"]
max_requests = current_app.config["RATE_LIMIT_MAX"]
if authenticated:
max_requests *= current_app.config.get("RATE_LIMIT_AUTH_MULTIPLIER", 5)
now = time.time()
cutoff = now - window
with _rate_limit_lock:
# Clean old requests and get current list
requests = _rate_limit_requests[client_ip]
requests[:] = [t for t in requests if t > cutoff]
current_count = len(requests)
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
# Record this request
requests.append(now)
remaining = max_requests - len(requests)
return True, remaining, window
def cleanup_rate_limits(window: int | None = None) -> int:
"""Remove expired rate limit entries. Returns count of cleaned entries.
Args:
window: Rate limit window in seconds. If None, uses app config.
"""
# This should be called periodically (e.g., via cleanup task)
if window is None:
window = current_app.config.get("RATE_LIMIT_WINDOW", 60)
cutoff = time.time() - window
cleaned = 0
with _rate_limit_lock:
to_remove = []
for ip, requests in _rate_limit_requests.items():
requests[:] = [t for t in requests if t > cutoff]
if not requests:
to_remove.append(ip)
for ip in to_remove:
del _rate_limit_requests[ip]
cleaned += 1
return cleaned
def reset_rate_limits() -> None:
"""Clear all rate limit state. For testing only."""
with _rate_limit_lock:
_rate_limit_requests.clear()
# ─────────────────────────────────────────────────────────────────────────────
# Response Helpers
# ─────────────────────────────────────────────────────────────────────────────
def json_response(data: dict[str, Any], status: int = 200) -> Response:
"""Create JSON response with proper encoding."""
return Response(
json.dumps(data, ensure_ascii=False),
status=status,
mimetype="application/json",
)
def error_response(message: str, status: int, **extra: Any) -> Response:
"""Create standardized error response."""
data = {"error": message, **extra}
return json_response(data, status)
# ─────────────────────────────────────────────────────────────────────────────
# URL Helpers
# ─────────────────────────────────────────────────────────────────────────────
def url_prefix() -> str:
"""Get configured URL prefix for reverse proxy deployments."""
return current_app.config.get("URL_PREFIX", "")
def prefixed_url(path: str) -> str:
"""Generate URL with configured prefix."""
return f"{url_prefix()}{path}"
def base_url() -> str:
"""Detect full base URL from request headers."""
scheme = (
request.headers.get("X-Forwarded-Proto")
or request.headers.get("X-Scheme")
or request.scheme
)
host = request.headers.get("X-Forwarded-Host") or request.headers.get("Host") or request.host
return f"{scheme}://{host}{url_prefix()}"
# ─────────────────────────────────────────────────────────────────────────────
# Validation Helpers (used within views)
# ─────────────────────────────────────────────────────────────────────────────
def validate_paste_id(paste_id: str) -> Response | None:
"""Validate paste ID format. Returns error response or None if valid."""
expected_length = current_app.config["PASTE_ID_LENGTH"]
if len(paste_id) != expected_length or not PASTE_ID_PATTERN.match(paste_id):
return error_response("Invalid paste ID", 400)
return None
def fetch_paste(paste_id: str, check_password: bool = True) -> Response | None:
"""Fetch paste and store in g.paste. Returns error response or None if OK."""
db = get_db()
now = int(time.time())
# Update access time
db.execute("UPDATE pastes SET last_accessed = ? WHERE id = ?", (now, paste_id))
row = db.execute(
"""SELECT id, content, mime_type, owner, created_at,
length(content) as size, burn_after_read, expires_at, password_hash
FROM pastes WHERE id = ?""",
(paste_id,),
).fetchone()
if row is None:
db.commit()
return error_response("Paste not found", 404)
# Password verification
if check_password and row["password_hash"]:
provided = request.headers.get("X-Paste-Password", "")
if not provided:
db.commit()
return error_response("Password required", 401, password_protected=True)
if not verify_password(provided, row["password_hash"]):
db.commit()
return error_response("Invalid password", 403)
g.paste = row
g.db = db
return None
def require_auth() -> Response | None:
"""Check authentication. Returns error response or None if authenticated."""
client_id = get_client_id()
if not client_id:
return error_response("Authentication required", 401)
g.client_id = client_id
return None
# ─────────────────────────────────────────────────────────────────────────────
# Authentication & Security
# ─────────────────────────────────────────────────────────────────────────────
def is_trusted_proxy() -> bool:
"""Verify request comes from trusted reverse proxy via shared secret."""
expected = current_app.config.get("TRUSTED_PROXY_SECRET", "")
if not expected:
return True
provided = request.headers.get("X-Proxy-Secret", "")
return hmac.compare_digest(expected, provided)
def get_client_id() -> str | None:
"""Extract and validate client certificate fingerprint."""
if not is_trusted_proxy():
current_app.logger.warning(
"Auth header ignored: X-Proxy-Secret mismatch from %s", request.remote_addr
)
return None
sha1 = request.headers.get("X-SSL-Client-SHA1", "").strip().lower()
if sha1 and CLIENT_ID_PATTERN.match(sha1):
# Check if PKI is enabled and certificate is revoked
if current_app.config.get("PKI_ENABLED"):
from app.pki import is_certificate_valid
if not is_certificate_valid(sha1):
current_app.logger.warning(
"Auth rejected: certificate revoked or expired: %s", sha1[:12] + "..."
)
return None
return sha1
return None
# ─────────────────────────────────────────────────────────────────────────────
# Proof-of-Work
# ─────────────────────────────────────────────────────────────────────────────
def get_pow_secret() -> bytes:
"""Get or generate PoW signing secret."""
global _pow_secret_cache
configured = current_app.config.get("POW_SECRET", "")
if configured:
return configured.encode()
if _pow_secret_cache is None:
_pow_secret_cache = secrets.token_bytes(32)
return _pow_secret_cache
def generate_challenge() -> dict[str, Any]:
"""Generate new PoW challenge with signed token."""
difficulty = current_app.config["POW_DIFFICULTY"]
ttl = current_app.config["POW_CHALLENGE_TTL"]
expires = int(time.time()) + ttl
nonce = secrets.token_hex(16)
msg = f"{nonce}:{expires}:{difficulty}".encode()
sig = hmac.new(get_pow_secret(), msg, hashlib.sha256).hexdigest()
return {
"nonce": nonce,
"difficulty": difficulty,
"expires": expires,
"token": f"{nonce}:{expires}:{difficulty}:{sig}",
}
def verify_pow(token: str, solution: str) -> tuple[bool, str]:
"""Verify proof-of-work solution. Returns (valid, error_message)."""
difficulty = current_app.config["POW_DIFFICULTY"]
if difficulty == 0:
return True, ""
# Parse token
try:
parts = token.split(":")
if len(parts) != 4:
return False, "Invalid challenge format"
nonce, expires_str, diff_str, sig = parts
expires = int(expires_str)
token_diff = int(diff_str)
except (ValueError, TypeError):
return False, "Invalid challenge format"
# Verify signature
msg = f"{nonce}:{expires}:{token_diff}".encode()
expected_sig = hmac.new(get_pow_secret(), msg, hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, expected_sig):
return False, "Invalid challenge signature"
# Check expiry and difficulty
if int(time.time()) > expires:
return False, "Challenge expired"
if token_diff != difficulty:
return False, "Difficulty mismatch"
# Verify solution
try:
solution_int = int(solution)
if solution_int < 0:
return False, "Invalid solution"
except (ValueError, TypeError):
return False, "Invalid solution"
# Check hash meets difficulty
work = f"{nonce}:{solution}".encode()
hash_bytes = hashlib.sha256(work).digest()
zero_bits = 0
for byte in hash_bytes:
if byte == 0:
zero_bits += 8
else:
zero_bits += 8 - byte.bit_length()
break
if zero_bits < difficulty:
return False, f"Insufficient work: {zero_bits} < {difficulty} bits"
return True, ""
# ─────────────────────────────────────────────────────────────────────────────
# Content Processing
# ─────────────────────────────────────────────────────────────────────────────
def calculate_entropy(data: bytes) -> float:
"""Calculate Shannon entropy in bits per byte (0-8 range)."""
if not data:
return 0.0
freq = [0] * 256
for byte in data:
freq[byte] += 1
length = len(data)
entropy = 0.0
for count in freq:
if count > 0:
p = count / length
entropy -= p * math.log2(p)
return entropy
def detect_mime_type(content: bytes, content_type: str | None = None) -> str:
"""Detect MIME type using magic bytes, headers, or content analysis."""
# Magic byte detection (highest priority)
for magic, mime in MAGIC_SIGNATURES.items():
if content.startswith(magic):
# RIFF container: verify WEBP subtype
if magic == b"RIFF" and len(content) >= 12 and content[8:12] != b"WEBP":
continue
return mime
# Explicit Content-Type (if specific)
if content_type:
mime = content_type.split(";")[0].strip().lower()
if mime not in GENERIC_MIME_TYPES and MIME_PATTERN.match(mime):
return mime
# UTF-8 text detection
try:
content.decode("utf-8")
return "text/plain"
except UnicodeDecodeError:
return "application/octet-stream"
def is_recognizable_format(content: bytes) -> tuple[bool, str | None]:
"""Check if content is a recognizable (likely unencrypted) format.
Returns (is_recognizable, detected_format).
Used to enforce encryption by rejecting known formats.
"""
# Check magic bytes
for magic, mime in MAGIC_SIGNATURES.items():
if content.startswith(magic):
if magic == b"RIFF" and len(content) >= 12 and content[8:12] != b"WEBP":
continue
return True, mime
# Check if valid UTF-8 text (plaintext)
try:
content.decode("utf-8")
return True, "text/plain"
except UnicodeDecodeError:
pass
return False, None
def generate_paste_id(content: bytes) -> str:
"""Generate unique paste 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]
# ─────────────────────────────────────────────────────────────────────────────
# Class-Based Views
# ─────────────────────────────────────────────────────────────────────────────
class IndexView(MethodView):
"""Handle API info and paste creation."""
def get(self) -> Response:
"""Return API information and usage examples."""
prefix = url_prefix() or "/"
return json_response(
{
"name": "FlaskPaste",
"version": VERSION,
"prefix": prefix,
"endpoints": {
f"GET {prefixed_url('/')}": "API information",
f"GET {prefixed_url('/health')}": "Health check",
f"GET {prefixed_url('/client')}": "Download CLI client",
f"GET {prefixed_url('/challenge')}": "Get PoW challenge",
f"POST {prefixed_url('/')}": "Create paste",
f"GET {prefixed_url('/pastes')}": "List your pastes (auth required)",
f"GET {prefixed_url('/<id>')}": "Retrieve paste metadata",
f"GET {prefixed_url('/<id>/raw')}": "Retrieve raw paste content",
f"DELETE {prefixed_url('/<id>')}": "Delete paste",
},
"usage": {
"raw": f"curl --data-binary @file.txt {base_url()}/",
"pipe": f"cat file.txt | curl --data-binary @- {base_url()}/",
"json": f"curl -H \"Content-Type: application/json\" -d '...' {base_url()}/",
},
"note": "Use --data-binary (not -d) to preserve newlines",
}
)
def post(self) -> Response:
"""Create a new paste."""
# Parse content
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 error_response("No content provided", 400)
owner = get_client_id()
# Rate limiting (check before expensive operations)
client_ip = get_client_ip()
allowed, _remaining, reset_seconds = check_rate_limit(client_ip, authenticated=bool(owner))
if not allowed:
current_app.logger.warning("Rate limit exceeded: ip=%s auth=%s", client_ip, bool(owner))
response = error_response(
"Rate limit exceeded",
429,
retry_after=reset_seconds,
)
response.headers["Retry-After"] = str(reset_seconds)
response.headers["X-RateLimit-Remaining"] = "0"
response.headers["X-RateLimit-Reset"] = str(reset_seconds)
return response
# Proof-of-work verification
difficulty = current_app.config["POW_DIFFICULTY"]
if difficulty > 0:
token = request.headers.get("X-PoW-Token", "")
solution = request.headers.get("X-PoW-Solution", "")
if not token or not solution:
return error_response(
"Proof-of-work required", 400, hint="GET /challenge for a new challenge"
)
valid, err = verify_pow(token, solution)
if not valid:
current_app.logger.warning(
"PoW verification failed: %s from=%s", err, request.remote_addr
)
return error_response(f"Proof-of-work failed: {err}", 400)
# Size limits
content_size = len(content)
max_size = (
current_app.config["MAX_PASTE_SIZE_AUTH"]
if owner
else current_app.config["MAX_PASTE_SIZE_ANON"]
)
if content_size > max_size:
return error_response(
"Paste too large",
413,
size=content_size,
max_size=max_size,
authenticated=owner is not None,
)
# Minimum size check (enforces encryption overhead)
min_size = current_app.config.get("MIN_PASTE_SIZE", 0)
if min_size > 0 and content_size < min_size:
return error_response(
"Paste too small",
400,
size=content_size,
min_size=min_size,
hint="Encrypt content before uploading (fpaste encrypts by default)",
)
# Entropy check
min_entropy = current_app.config.get("MIN_ENTROPY", 0)
min_entropy_size = current_app.config.get("MIN_ENTROPY_SIZE", 256)
if min_entropy > 0 and content_size >= min_entropy_size:
entropy = calculate_entropy(content)
if entropy < min_entropy:
current_app.logger.warning(
"Low entropy rejected: %.2f < %.2f from=%s",
entropy,
min_entropy,
request.remote_addr,
)
return error_response(
"Content entropy too low",
400,
entropy=round(entropy, 2),
min_entropy=min_entropy,
hint="Encrypt content before uploading (fpaste encrypts by default)",
)
# Binary content requirement (reject recognizable formats)
if current_app.config.get("REQUIRE_BINARY", False):
is_recognized, detected_format = is_recognizable_format(content)
if is_recognized:
current_app.logger.warning(
"Recognizable format rejected: %s from=%s",
detected_format,
request.remote_addr,
)
return error_response(
"Recognizable format not allowed",
400,
detected=detected_format,
hint="Encrypt content before uploading (fpaste encrypts by default)",
)
# Deduplication check
content_hash = hashlib.sha256(content).hexdigest()
is_allowed, dedup_count = check_content_hash(content_hash)
if not is_allowed:
window = current_app.config["CONTENT_DEDUP_WINDOW"]
current_app.logger.warning(
"Dedup threshold exceeded: hash=%s count=%d from=%s",
content_hash[:16],
dedup_count,
request.remote_addr,
)
return error_response(
"Duplicate content rate limit exceeded",
429,
count=dedup_count,
window_seconds=window,
)
# Parse optional headers
burn_header = request.headers.get("X-Burn-After-Read", "").strip().lower()
burn_after_read = burn_header in ("true", "1", "yes")
expires_at = None
expiry_header = request.headers.get("X-Expiry", "").strip()
if expiry_header:
try:
expiry_seconds = int(expiry_header)
if expiry_seconds > 0:
max_expiry = current_app.config.get("MAX_EXPIRY_SECONDS", 0)
if max_expiry > 0:
expiry_seconds = min(expiry_seconds, max_expiry)
expires_at = int(time.time()) + expiry_seconds
except ValueError:
pass
password_hash = None
password_header = request.headers.get("X-Paste-Password", "")
if password_header:
if len(password_header) > 1024:
return error_response("Password too long (max 1024 chars)", 400)
password_hash = hash_password(password_header)
# Insert paste
paste_id = generate_paste_id(content)
now = int(time.time())
db = get_db()
db.execute(
"""INSERT INTO pastes
(id, content, mime_type, owner, created_at, last_accessed,
burn_after_read, expires_at, password_hash)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
paste_id,
content,
mime_type,
owner,
now,
now,
1 if burn_after_read else 0,
expires_at,
password_hash,
),
)
db.commit()
# Build response
response_data: dict[str, Any] = {
"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
if burn_after_read:
response_data["burn_after_read"] = True
if expires_at:
response_data["expires_at"] = expires_at
if password_hash:
response_data["password_protected"] = True
return json_response(response_data, 201)
class HealthView(MethodView):
"""Health check endpoint."""
def get(self) -> Response:
"""Return health status with database check."""
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)
class ChallengeView(MethodView):
"""Proof-of-work challenge endpoint."""
def get(self) -> Response:
"""Generate and return PoW challenge."""
difficulty = current_app.config["POW_DIFFICULTY"]
if difficulty == 0:
return json_response({"enabled": False, "difficulty": 0})
ch = generate_challenge()
return json_response(
{
"enabled": True,
"nonce": ch["nonce"],
"difficulty": ch["difficulty"],
"expires": ch["expires"],
"token": ch["token"],
}
)
class ClientView(MethodView):
"""CLI client download endpoint."""
def get(self) -> Response:
"""Serve fpaste CLI with server URL pre-configured."""
import os
server_url = base_url()
client_path = os.path.join(current_app.root_path, "..", "fpaste")
try:
with open(client_path) as f:
content = f.read()
# Replace default server URL
content = content.replace(
'"server": os.environ.get("FLASKPASTE_SERVER", "http://localhost:5000")',
f'"server": os.environ.get("FLASKPASTE_SERVER", "{server_url}")',
)
content = content.replace(
"http://localhost:5000)",
f"{server_url})",
)
response = Response(content, mimetype="text/x-python")
response.headers["Content-Disposition"] = "attachment; filename=fpaste"
return response
except FileNotFoundError:
return error_response("Client not available", 404)
class PasteView(MethodView):
"""Paste metadata operations."""
def get(self, paste_id: str) -> Response:
"""Retrieve paste metadata."""
# Validate and fetch
if err := validate_paste_id(paste_id):
return err
if err := fetch_paste(paste_id):
return err
row: Row = g.paste
g.db.commit()
response_data: dict[str, Any] = {
"id": row["id"],
"mime_type": row["mime_type"],
"size": row["size"],
"created_at": row["created_at"],
"raw": f"/{paste_id}/raw",
}
if row["burn_after_read"]:
response_data["burn_after_read"] = True
if row["expires_at"]:
response_data["expires_at"] = row["expires_at"]
if row["password_hash"]:
response_data["password_protected"] = True
return json_response(response_data)
def head(self, paste_id: str) -> Response:
"""Return paste metadata headers only."""
return self.get(paste_id)
def put(self, paste_id: str) -> Response:
"""Update paste content and/or metadata.
Requires authentication and ownership.
Content update: Send raw body with Content-Type header
Metadata update: Use headers with empty body
Headers:
- X-Paste-Password: Set/change password
- X-Remove-Password: true to remove password
- X-Extend-Expiry: Seconds to add to current expiry
"""
# Validate paste ID format
if err := validate_paste_id(paste_id):
return err
if err := require_auth():
return err
db = get_db()
# Fetch current paste
row = db.execute(
"""SELECT id, owner, content, mime_type, expires_at, password_hash
FROM pastes WHERE id = ?""",
(paste_id,),
).fetchone()
if row is None:
return error_response("Paste not found", 404)
if row["owner"] != g.client_id:
return error_response("Permission denied", 403)
# Check for burn-after-read (cannot update)
burn_check = db.execute(
"SELECT burn_after_read FROM pastes WHERE id = ?", (paste_id,)
).fetchone()
if burn_check and burn_check["burn_after_read"]:
return error_response("Cannot update burn-after-read paste", 400)
# Parse update parameters
new_password = request.headers.get("X-Paste-Password", "").strip() or None
remove_password = request.headers.get("X-Remove-Password", "").lower() in (
"true",
"1",
"yes",
)
extend_expiry_str = request.headers.get("X-Extend-Expiry", "").strip()
# Prepare update fields
update_fields = []
update_params: list[Any] = []
# Content update (if body provided)
content = request.get_data()
if content:
mime_type = request.content_type or "application/octet-stream"
# Sanitize MIME type
if not MIME_PATTERN.match(mime_type.split(";")[0].strip()):
mime_type = "application/octet-stream"
update_fields.append("content = ?")
update_params.append(content)
update_fields.append("mime_type = ?")
update_params.append(mime_type.split(";")[0].strip())
# Password update
if remove_password:
update_fields.append("password_hash = NULL")
elif new_password:
update_fields.append("password_hash = ?")
update_params.append(hash_password(new_password))
# Expiry extension
if extend_expiry_str:
try:
extend_seconds = int(extend_expiry_str)
if extend_seconds > 0:
current_expiry = row["expires_at"]
if current_expiry:
new_expiry = current_expiry + extend_seconds
else:
# If no expiry set, create one from now
new_expiry = int(time.time()) + extend_seconds
update_fields.append("expires_at = ?")
update_params.append(new_expiry)
except ValueError:
return error_response("Invalid X-Extend-Expiry value", 400)
if not update_fields:
return error_response("No updates provided", 400)
# Execute update (fields are hardcoded strings, safe from injection)
update_sql = f"UPDATE pastes SET {', '.join(update_fields)} WHERE id = ?" # noqa: S608
update_params.append(paste_id)
db.execute(update_sql, update_params)
db.commit()
# Fetch updated paste for response
updated = db.execute(
"""SELECT id, mime_type, length(content) as size, expires_at,
CASE WHEN password_hash IS NOT NULL THEN 1 ELSE 0 END as password_protected
FROM pastes WHERE id = ?""",
(paste_id,),
).fetchone()
response_data: dict[str, Any] = {
"id": updated["id"],
"size": updated["size"],
"mime_type": updated["mime_type"],
}
if updated["expires_at"]:
response_data["expires_at"] = updated["expires_at"]
if updated["password_protected"]:
response_data["password_protected"] = True
return json_response(response_data)
class PasteRawView(MethodView):
"""Raw paste content retrieval."""
def get(self, paste_id: str) -> Response:
"""Retrieve raw paste content."""
# Validate and fetch
if err := validate_paste_id(paste_id):
return err
if err := fetch_paste(paste_id):
return err
row: Row = g.paste
db = g.db
burn_after_read = row["burn_after_read"]
if burn_after_read:
db.execute("DELETE FROM pastes WHERE id = ?", (paste_id,))
current_app.logger.info("Burn-after-read paste deleted: %s", paste_id)
db.commit()
response = Response(row["content"], mimetype=row["mime_type"])
if row["mime_type"].startswith(("image/", "text/")):
response.headers["Content-Disposition"] = "inline"
if burn_after_read:
response.headers["X-Burn-After-Read"] = "true"
return response
def head(self, paste_id: str) -> Response:
"""Return raw paste headers without triggering burn."""
# Validate and fetch
if err := validate_paste_id(paste_id):
return err
if err := fetch_paste(paste_id):
return err
row: Row = g.paste
g.db.commit()
response = Response(mimetype=row["mime_type"])
response.headers["Content-Length"] = str(row["size"])
if row["mime_type"].startswith(("image/", "text/")):
response.headers["Content-Disposition"] = "inline"
if row["burn_after_read"]:
response.headers["X-Burn-After-Read"] = "true"
return response
class PasteDeleteView(MethodView):
"""Paste deletion with authentication."""
def delete(self, paste_id: str) -> Response:
"""Delete paste. Requires ownership."""
# Validate
if err := validate_paste_id(paste_id):
return err
if err := require_auth():
return err
db = get_db()
row = db.execute("SELECT owner FROM pastes WHERE id = ?", (paste_id,)).fetchone()
if row is None:
return error_response("Paste not found", 404)
if row["owner"] != g.client_id:
return error_response("Permission denied", 403)
db.execute("DELETE FROM pastes WHERE id = ?", (paste_id,))
db.commit()
return json_response({"message": "Paste deleted"})
class PastesListView(MethodView):
"""List authenticated user's pastes (privacy-focused)."""
def get(self) -> Response:
"""List pastes owned by authenticated user.
Privacy guarantees:
- Requires authentication (mTLS client certificate)
- Users can ONLY see their own pastes
- No admin bypass or cross-user visibility
- Content is never returned, only metadata
Query parameters:
- limit: max results (default 50, max 200)
- offset: pagination offset (default 0)
- type: filter by MIME type (glob pattern, e.g., "image/*")
- after: filter by created_at >= timestamp
- before: filter by created_at <= timestamp
"""
import fnmatch
# Strict authentication requirement
if err := require_auth():
return err
client_id = g.client_id
# Parse pagination parameters
try:
limit = min(int(request.args.get("limit", 50)), 200)
offset = max(int(request.args.get("offset", 0)), 0)
except (ValueError, TypeError):
limit, offset = 50, 0
# Parse filter parameters
type_filter = request.args.get("type", "").strip()
try:
after_ts = int(request.args.get("after", 0))
except (ValueError, TypeError):
after_ts = 0
try:
before_ts = int(request.args.get("before", 0))
except (ValueError, TypeError):
before_ts = 0
db = get_db()
# Build query with filters
where_clauses = ["owner = ?"]
params: list[Any] = [client_id]
if after_ts > 0:
where_clauses.append("created_at >= ?")
params.append(after_ts)
if before_ts > 0:
where_clauses.append("created_at <= ?")
params.append(before_ts)
where_sql = " AND ".join(where_clauses)
# Count total pastes matching filters (where_sql is safe, built from constants)
count_row = db.execute(
f"SELECT COUNT(*) as total FROM pastes WHERE {where_sql}", # noqa: S608
params,
).fetchone()
total = count_row["total"] if count_row else 0
# Fetch pastes with metadata only (where_sql is safe, built from constants)
rows = db.execute(
f"""SELECT id, mime_type, length(content) as size, created_at,
last_accessed, burn_after_read, expires_at,
CASE WHEN password_hash IS NOT NULL THEN 1 ELSE 0 END as password_protected
FROM pastes
WHERE {where_sql}
ORDER BY created_at DESC
LIMIT ? OFFSET ?""", # noqa: S608
[*params, limit, offset],
).fetchall()
# Apply MIME type filter (glob pattern matching done in Python for flexibility)
if type_filter:
rows = [r for r in rows if fnmatch.fnmatch(r["mime_type"], type_filter)]
pastes = []
for row in rows:
paste: dict[str, Any] = {
"id": row["id"],
"mime_type": row["mime_type"],
"size": row["size"],
"created_at": row["created_at"],
"last_accessed": row["last_accessed"],
"url": f"/{row['id']}",
"raw": f"/{row['id']}/raw",
}
if row["burn_after_read"]:
paste["burn_after_read"] = True
if row["expires_at"]:
paste["expires_at"] = row["expires_at"]
if row["password_protected"]:
paste["password_protected"] = True
pastes.append(paste)
return json_response(
{
"pastes": pastes,
"count": len(pastes),
"total": total,
"limit": limit,
"offset": offset,
}
)
# ─────────────────────────────────────────────────────────────────────────────
# PKI Views (Certificate Authority)
# ─────────────────────────────────────────────────────────────────────────────
def require_pki_enabled() -> Response | None:
"""Check if PKI is enabled. Returns error response or None if enabled."""
if not current_app.config.get("PKI_ENABLED"):
return error_response("PKI not enabled", 404)
return None
class PKIStatusView(MethodView):
"""PKI status endpoint."""
def get(self) -> Response:
"""Return PKI status and CA info if available."""
if not current_app.config.get("PKI_ENABLED"):
return json_response({"enabled": False})
from app.pki import get_ca_info
ca_info = get_ca_info()
if ca_info is None:
return json_response(
{
"enabled": True,
"ca_exists": False,
"hint": "POST /pki/ca to generate CA",
}
)
return json_response(
{
"enabled": True,
"ca_exists": True,
"common_name": ca_info["common_name"],
"fingerprint_sha1": ca_info["fingerprint_sha1"],
"created_at": ca_info["created_at"],
"expires_at": ca_info["expires_at"],
"key_algorithm": ca_info["key_algorithm"],
}
)
class PKICAGenerateView(MethodView):
"""CA generation endpoint (first-run only)."""
def post(self) -> Response:
"""Generate CA certificate. Only works if no CA exists."""
if err := require_pki_enabled():
return err
from app.pki import (
CAExistsError,
PKIError,
generate_ca,
get_ca_info,
)
# Check if CA already exists
if get_ca_info() is not None:
return error_response("CA already exists", 409)
# Get CA password from config
password = current_app.config.get("PKI_CA_PASSWORD", "")
if not password:
return error_response(
"PKI_CA_PASSWORD not configured",
500,
hint="Set FLASKPASTE_PKI_CA_PASSWORD environment variable",
)
# Parse request for optional common name
common_name = "FlaskPaste CA"
if request.is_json:
data = request.get_json(silent=True)
if data and isinstance(data.get("common_name"), str):
common_name = data["common_name"][:64]
# Generate CA
try:
days = current_app.config.get("PKI_CA_DAYS", 3650)
owner = get_client_id()
ca_info = generate_ca(common_name, password, days=days, owner=owner)
except CAExistsError:
return error_response("CA already exists", 409)
except PKIError as e:
current_app.logger.error("CA generation failed: %s", e)
return error_response("CA generation failed", 500)
current_app.logger.info(
"CA generated: cn=%s fingerprint=%s", common_name, ca_info["fingerprint_sha1"][:12]
)
return json_response(
{
"message": "CA generated",
"common_name": ca_info["common_name"],
"fingerprint_sha1": ca_info["fingerprint_sha1"],
"created_at": ca_info["created_at"],
"expires_at": ca_info["expires_at"],
"download": prefixed_url("/pki/ca.crt"),
},
201,
)
class PKICADownloadView(MethodView):
"""CA certificate download endpoint."""
def get(self) -> Response:
"""Download CA certificate in PEM format."""
if err := require_pki_enabled():
return err
from app.pki import get_ca_info
ca_info = get_ca_info()
if ca_info is None:
return error_response("CA not initialized", 404)
response = Response(ca_info["certificate_pem"], mimetype="application/x-pem-file")
response.headers["Content-Disposition"] = (
f"attachment; filename={ca_info['common_name'].replace(' ', '_')}.crt"
)
return response
class PKIIssueView(MethodView):
"""Certificate issuance endpoint (open registration)."""
def post(self) -> Response:
"""Issue a new client certificate."""
if err := require_pki_enabled():
return err
from app.pki import (
CANotFoundError,
PKIError,
issue_certificate,
)
# Parse request
common_name = None
if request.is_json:
data = request.get_json(silent=True)
if data and isinstance(data.get("common_name"), str):
common_name = data["common_name"][:64]
if not common_name:
return error_response(
"common_name required", 400, hint='POST {"common_name": "your-name"}'
)
# Get CA password from config
password = current_app.config.get("PKI_CA_PASSWORD", "")
if not password:
return error_response("PKI not properly configured", 500)
# Issue certificate
try:
days = current_app.config.get("PKI_CERT_DAYS", 365)
issued_to = get_client_id()
cert_info = issue_certificate(common_name, password, days=days, issued_to=issued_to)
except CANotFoundError:
return error_response("CA not initialized", 404)
except PKIError as e:
current_app.logger.error("Certificate issuance failed: %s", e)
return error_response("Certificate issuance failed", 500)
current_app.logger.info(
"Certificate issued: cn=%s serial=%s fingerprint=%s to=%s",
common_name,
cert_info["serial"][:8],
cert_info["fingerprint_sha1"][:12],
issued_to or "anonymous",
)
# Return certificate bundle
return json_response(
{
"message": "Certificate issued",
"serial": cert_info["serial"],
"common_name": cert_info["common_name"],
"fingerprint_sha1": cert_info["fingerprint_sha1"],
"created_at": cert_info["created_at"],
"expires_at": cert_info["expires_at"],
"certificate_pem": cert_info["certificate_pem"],
"private_key_pem": cert_info["private_key_pem"],
},
201,
)
class PKICertsView(MethodView):
"""Certificate listing endpoint."""
def get(self) -> Response:
"""List issued certificates."""
if err := require_pki_enabled():
return err
client_id = get_client_id()
db = get_db()
# Authenticated users see their own certs or certs they issued
# Anonymous users see nothing
if client_id:
rows = db.execute(
"""SELECT serial, common_name, fingerprint_sha1,
created_at, expires_at, issued_to, status, revoked_at
FROM issued_certificates
WHERE issued_to = ? OR fingerprint_sha1 = ?
ORDER BY created_at DESC""",
(client_id, client_id),
).fetchall()
else:
# Anonymous: empty list
rows = []
certs = []
for row in rows:
cert = {
"serial": row["serial"],
"common_name": row["common_name"],
"fingerprint_sha1": row["fingerprint_sha1"],
"created_at": row["created_at"],
"expires_at": row["expires_at"],
"status": row["status"],
}
if row["issued_to"]:
cert["issued_to"] = row["issued_to"]
if row["revoked_at"]:
cert["revoked_at"] = row["revoked_at"]
certs.append(cert)
return json_response({"certificates": certs, "count": len(certs)})
class PKIRevokeView(MethodView):
"""Certificate revocation endpoint."""
def post(self, serial: str) -> Response:
"""Revoke a certificate by serial number."""
if err := require_pki_enabled():
return err
if err := require_auth():
return err
from app.pki import CertificateNotFoundError, PKIError, revoke_certificate
db = get_db()
# Check certificate exists and get ownership info
row = db.execute(
"SELECT issued_to, fingerprint_sha1, status FROM issued_certificates WHERE serial = ?",
(serial,),
).fetchone()
if row is None:
return error_response("Certificate not found", 404)
if row["status"] == "revoked":
return error_response("Certificate already revoked", 409)
# Check permission: must be issuer or the certificate itself
client_id = g.client_id
can_revoke = row["issued_to"] == client_id or row["fingerprint_sha1"] == client_id
if not can_revoke:
return error_response("Permission denied", 403)
# Revoke
try:
revoke_certificate(serial)
except CertificateNotFoundError:
return error_response("Certificate not found", 404)
except PKIError as e:
current_app.logger.error("Revocation failed: %s", e)
return error_response("Revocation failed", 500)
current_app.logger.info("Certificate revoked: serial=%s by=%s", serial[:8], client_id[:12])
return json_response({"message": "Certificate revoked", "serial": serial})
# ─────────────────────────────────────────────────────────────────────────────
# Route Registration
# ─────────────────────────────────────────────────────────────────────────────
# Index and paste creation
bp.add_url_rule("/", view_func=IndexView.as_view("index"))
# Utility endpoints
bp.add_url_rule("/health", view_func=HealthView.as_view("health"))
bp.add_url_rule("/challenge", view_func=ChallengeView.as_view("challenge"))
bp.add_url_rule("/client", view_func=ClientView.as_view("client"))
# Paste operations
bp.add_url_rule("/pastes", view_func=PastesListView.as_view("pastes_list"))
bp.add_url_rule("/<paste_id>", view_func=PasteView.as_view("paste"), methods=["GET", "HEAD", "PUT"])
bp.add_url_rule(
"/<paste_id>/raw", view_func=PasteRawView.as_view("paste_raw"), methods=["GET", "HEAD"]
)
bp.add_url_rule(
"/<paste_id>", view_func=PasteDeleteView.as_view("paste_delete"), methods=["DELETE"]
)
# PKI endpoints
bp.add_url_rule("/pki", view_func=PKIStatusView.as_view("pki_status"))
bp.add_url_rule("/pki/ca", view_func=PKICAGenerateView.as_view("pki_ca_generate"))
bp.add_url_rule("/pki/ca.crt", view_func=PKICADownloadView.as_view("pki_ca_download"))
bp.add_url_rule("/pki/issue", view_func=PKIIssueView.as_view("pki_issue"))
bp.add_url_rule("/pki/certs", view_func=PKICertsView.as_view("pki_certs"))
bp.add_url_rule(
"/pki/revoke/<serial>", view_func=PKIRevokeView.as_view("pki_revoke"), methods=["POST"]
)