Files
Gondulf/src/gondulf/middleware/https_enforcement.py
Phil Skentelbery 65d5dfdbd6 fix(security): exempt health endpoint from HTTPS enforcement
Docker health checks and load balancers call /health directly without
going through the reverse proxy, so they need HTTP access. This fix
exempts /health and /metrics endpoints from HTTPS enforcement in
production mode.

Fixes the issue where Docker health checks were being redirected to
HTTPS and failing because there's no TLS on localhost.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-22 11:45:06 -07:00

131 lines
4.3 KiB
Python

"""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)