v0.166.0
  1"""The cross-origin decision shared by the CSRF middleware and WebSocket upgrades."""
  2
  3from typing import TYPE_CHECKING
  4from urllib.parse import urlparse
  5
  6from plain.runtime import settings
  7
  8if TYPE_CHECKING:
  9    from plain.http import Request
 10
 11
 12def check_cross_origin(request: Request) -> tuple[bool, str]:
 13    """Decide whether a browser-originated request is same-origin.
 14
 15    The cross-origin half of the CSRF rule, shared with the WebSocket
 16    upgrade path (a websocket handshake is a GET, so the middleware
 17    lets it through, but session cookies ride along on it exactly as
 18    they do on a POST). Trusted origins first, then `Sec-Fetch-Site`,
 19    then allow requests with neither header (non-browser clients),
 20    then fall back to a scheme-agnostic Origin-vs-Host comparison.
 21    """
 22    origin = request.headers.get("Origin")
 23    sec_fetch_site = request.headers.get("Sec-Fetch-Site", "").lower()
 24
 25    # Check trusted origins allow-list
 26
 27    if origin and origin in settings.CSRF_TRUSTED_ORIGINS:
 28        return True, f"CSRF allowed: Trusted origin: {origin}"
 29
 30    # Primary protection: Check Sec-Fetch-Site header
 31    if sec_fetch_site in ("same-origin", "none"):
 32        return (
 33            True,
 34            f"CSRF allowed: Same-origin request from Sec-Fetch-Site: {sec_fetch_site}",
 35        )
 36    elif sec_fetch_site in ("cross-site", "same-site"):
 37        return (
 38            False,
 39            f"CSRF rejected: Cross-origin request from Sec-Fetch-Site: {sec_fetch_site}",
 40        )
 41
 42    # No fetch metadata or Origin headers - allow (non-browser requests)
 43    if not origin and not sec_fetch_site:
 44        return (
 45            True,
 46            "CSRF allowed: No Origin or Sec-Fetch-Site header - likely non-browser or old browser",
 47        )
 48
 49    # Fallback: Origin vs Host comparison for older browsers
 50    # Note: On pre-2023 browsers, HTTP→HTTPS transitions may cause mismatches
 51    # (Origin shows :443, request sees :80 if TLS terminated upstream).
 52    # HSTS helps here; otherwise add external origins to CSRF_TRUSTED_ORIGINS.
 53    if origin == "null":
 54        return False, "CSRF rejected: Null Origin header"
 55
 56    if (parsed_origin := urlparse(origin)) and (host := request.host):
 57        try:
 58            # Scheme-agnostic host:port comparison
 59            origin_host = parsed_origin.hostname
 60            origin_port = parsed_origin.port or (
 61                80
 62                if parsed_origin.scheme == "http"
 63                else 443
 64                if parsed_origin.scheme == "https"
 65                else None
 66            )
 67
 68            # Extract hostname from request host (similar to how we parse origin)
 69            # Use a fake scheme since we only care about host parsing
 70            parsed_host = urlparse(f"http://{host}")
 71            request_host = parsed_host.hostname or host
 72            request_port = request.port
 73
 74            # Compare hostname and port (scheme-agnostic)
 75            # Both origin_host and request_host are normalized by urlparse (IPv6 brackets stripped)
 76            if origin_host and origin_port and request_host and request_port:
 77                host_match = origin_host.lower() == request_host.lower()
 78                port_match = origin_port == int(request_port)
 79
 80                if host_match and port_match:
 81                    return (
 82                        True,
 83                        f"CSRF allowed: Same-origin request - Origin {origin} matches Host {host}",
 84                    )
 85
 86                # Build detailed error message based on what mismatched
 87                if host_match:
 88                    # Port mismatch only - show ports since they're relevant
 89                    return (
 90                        False,
 91                        f"CSRF rejected: Origin {origin_host}:{origin_port} does not match Host {request_host}:{request_port} (port mismatch)",
 92                    )
 93                elif port_match:
 94                    # Host mismatch only - no need to show ports
 95                    return (
 96                        False,
 97                        f"CSRF rejected: Origin {origin_host} does not match Host {request_host}",
 98                    )
 99                else:
100                    # Both mismatch - show full details
101                    return (
102                        False,
103                        f"CSRF rejected: Origin {origin_host}:{origin_port} does not match Host {request_host}:{request_port}",
104                    )
105        except ValueError:
106            pass
107
108    # Origin present but couldn't parse/compare properly
109    return (
110        False,
111        f"CSRF rejected: Origin {origin} could not be validated against Host {request.host}",
112    )