"""HTTPS enforcement middleware for Gondulf IndieAuth server.""" import logging from typing import Callable from fastapi import Request, Response from fastapi.responses import JSONResponse from starlette.middleware.base import BaseHTTPMiddleware from starlette.responses import RedirectResponse from gondulf.config import Config logger = logging.getLogger("gondulf.middleware.https_enforcement") # Internal endpoints exempt from HTTPS enforcement # These are called by Docker health checks, load balancers, and monitoring systems # that connect directly to the container without going through the reverse proxy. HTTPS_EXEMPT_PATHS = {"/health", "/metrics"} def is_https_request(request: Request) -> bool: """ Check if request is HTTPS, considering reverse proxy headers. Args: request: Incoming HTTP request Returns: True if HTTPS, False otherwise """ # Direct HTTPS if request.url.scheme == "https": return True # Behind proxy - check forwarded header # Only trust this header in production with TRUST_PROXY=true if Config.TRUST_PROXY: forwarded_proto = request.headers.get("X-Forwarded-Proto", "").lower() return forwarded_proto == "https" return False class HTTPSEnforcementMiddleware(BaseHTTPMiddleware): """ Enforce HTTPS in production mode. In production (DEBUG=False), reject or redirect HTTP requests to HTTPS. In development (DEBUG=True), allow HTTP for localhost only. Supports reverse proxy deployments via X-Forwarded-Proto header when Config.TRUST_PROXY is enabled. References: - OAuth 2.0 Security Best Practices: HTTPS required - W3C IndieAuth: TLS required for production - Clarifications: See /docs/designs/phase-4b-clarifications.md section 2 """ def __init__(self, app, debug: bool = False, redirect: bool = True): """ Initialize HTTPS enforcement middleware. Args: app: FastAPI application debug: If True, allow HTTP for localhost (development mode) redirect: If True, redirect HTTP to HTTPS. If False, return 400. """ super().__init__(app) self.debug = debug self.redirect = redirect async def dispatch(self, request: Request, call_next: Callable) -> Response: """ Process request and enforce HTTPS if in production mode. Args: request: Incoming HTTP request call_next: Next middleware/handler in chain Returns: Response (redirect to HTTPS, error, or normal response) """ hostname = request.url.hostname or "" # Debug mode: Allow HTTP for localhost only if self.debug: if not is_https_request(request) and hostname not in [ "localhost", "127.0.0.1", "::1", ]: logger.warning( f"HTTP request to non-localhost in debug mode: {hostname}" ) # Allow but log warning (for development on local networks) # Continue processing return await call_next(request) # Exempt internal endpoints from HTTPS enforcement # These are used by Docker health checks, load balancers, etc. # that connect directly without going through the reverse proxy. if request.url.path in HTTPS_EXEMPT_PATHS: return await call_next(request) # Production mode: Enforce HTTPS if not is_https_request(request): logger.warning( f"HTTP request blocked in production mode: " f"{request.method} {request.url}" ) if self.redirect: # Redirect HTTP → HTTPS https_url = request.url.replace(scheme="https") logger.info(f"Redirecting to HTTPS: {https_url}") return RedirectResponse(url=str(https_url), status_code=301) else: # Return 400 Bad Request (strict mode) return JSONResponse( status_code=400, content={ "error": "invalid_request", "error_description": "HTTPS is required", }, ) # HTTPS or allowed HTTP: Continue processing return await call_next(request)