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>
131 lines
4.3 KiB
Python
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)
|