Files
flaskpaste/app/__init__.py
Username 8f9868f0d9 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.
2025-12-16 04:42:18 +01:00

226 lines
6.6 KiB
Python

"""Flask application factory."""
import logging
import os
import sys
import uuid
from flask import Flask, Response, g, request
from app.config import config
def setup_logging(app: Flask) -> None:
"""Configure structured logging."""
log_level = logging.DEBUG if app.debug else logging.INFO
log_format = (
"%(asctime)s %(levelname)s [%(name)s] %(message)s"
if app.debug
else '{"time":"%(asctime)s","level":"%(levelname)s","logger":"%(name)s","message":"%(message)s"}'
)
logging.basicConfig(
level=log_level,
format=log_format,
stream=sys.stdout,
)
# Reduce noise from werkzeug in production
if not app.debug:
logging.getLogger("werkzeug").setLevel(logging.WARNING)
app.logger.info("FlaskPaste starting", extra={"config": type(app.config).__name__})
def setup_security_headers(app: Flask) -> None:
"""Add security headers to all responses."""
@app.after_request
def add_security_headers(response: Response) -> Response:
# Prevent MIME type sniffing
response.headers["X-Content-Type-Options"] = "nosniff"
# Prevent clickjacking
response.headers["X-Frame-Options"] = "DENY"
# XSS protection (legacy but still useful)
response.headers["X-XSS-Protection"] = "1; mode=block"
# Referrer policy
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
# Content Security Policy (restrictive for API)
response.headers["Content-Security-Policy"] = "default-src 'none'; frame-ancestors 'none'"
# Permissions policy
response.headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()"
# HSTS - enforce HTTPS (1 year, include subdomains)
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
# Prevent caching of sensitive paste data
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, private"
response.headers["Pragma"] = "no-cache"
return response
def setup_request_id(app: Flask) -> None:
"""Add request ID tracking for log correlation and tracing."""
@app.before_request
def assign_request_id():
# Use incoming X-Request-ID from proxy, or generate a new one
request_id = request.headers.get("X-Request-ID", "").strip()
if not request_id:
request_id = str(uuid.uuid4())
g.request_id = request_id
@app.after_request
def add_request_id_header(response: Response) -> Response:
# Echo request ID back to client for tracing
request_id = getattr(g, "request_id", None)
if request_id:
response.headers["X-Request-ID"] = request_id
# Access logging with request ID
app.logger.info(
"%s %s %s [rid=%s]",
request.method,
request.path,
response.status_code,
request_id or "-",
)
return response
def setup_error_handlers(app: Flask) -> None:
"""Register global error handlers."""
import json
@app.errorhandler(400)
def bad_request(error):
app.logger.warning("Bad request: %s [rid=%s]", request.path, getattr(g, "request_id", "-"))
return Response(
json.dumps({"error": "Bad request"}),
status=400,
mimetype="application/json",
)
@app.errorhandler(404)
def not_found(error):
return Response(
json.dumps({"error": "Not found"}),
status=404,
mimetype="application/json",
)
@app.errorhandler(429)
def rate_limit_exceeded(error):
app.logger.warning(
"Rate limit exceeded: %s from %s [rid=%s]",
request.path, request.remote_addr, getattr(g, "request_id", "-")
)
return Response(
json.dumps({"error": "Rate limit exceeded", "retry_after": error.description}),
status=429,
mimetype="application/json",
)
@app.errorhandler(500)
def internal_error(error):
app.logger.error(
"Internal error: %s - %s [rid=%s]",
request.path, str(error), getattr(g, "request_id", "-")
)
return Response(
json.dumps({"error": "Internal server error"}),
status=500,
mimetype="application/json",
)
@app.errorhandler(Exception)
def handle_exception(error):
app.logger.exception(
"Unhandled exception: %s [rid=%s]",
str(error), getattr(g, "request_id", "-")
)
return Response(
json.dumps({"error": "Internal server error"}),
status=500,
mimetype="application/json",
)
def setup_rate_limiting(app: Flask) -> None:
"""Configure rate limiting."""
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
limiter = Limiter(
key_func=get_remote_address,
app=app,
default_limits=["200 per day", "60 per hour"],
storage_uri="memory://",
strategy="fixed-window",
)
# Store limiter on app for use in routes
app.extensions["limiter"] = limiter
return limiter
def setup_metrics(app: Flask) -> None:
"""Configure Prometheus metrics."""
# Only enable metrics in production
if app.config.get("TESTING"):
return
try:
from prometheus_flask_exporter import PrometheusMetrics
metrics = PrometheusMetrics(app)
# Add app info
metrics.info("flaskpaste_info", "FlaskPaste application info", version="1.0.0")
app.extensions["metrics"] = metrics
except ImportError:
app.logger.warning("prometheus_flask_exporter not available, metrics disabled")
def create_app(config_name: str | None = None) -> Flask:
"""Create and configure the Flask application."""
if config_name is None:
config_name = os.environ.get("FLASK_ENV", "default")
app = Flask(__name__)
app.config.from_object(config[config_name])
# Setup logging first
setup_logging(app)
# Setup request ID tracking
setup_request_id(app)
# Setup security headers
setup_security_headers(app)
# Setup error handlers
setup_error_handlers(app)
# Setup rate limiting (skip in testing)
if not app.config.get("TESTING"):
setup_rate_limiting(app)
# Setup metrics (skip in testing)
setup_metrics(app)
# Initialize database
from app import database
database.init_app(app)
# Register blueprints
from app.api import bp as api_bp
app.register_blueprint(api_bp)
app.logger.info("FlaskPaste initialized successfully")
return app