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 )