Repository navigation
Expand file tree
/
Copy pathhttp_origin.py
More file actions
366 lines (300 loc) · 14 KB
/
Copy pathhttp_origin.py
File metadata and controls
366 lines (300 loc) · 14 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
# SPDX-License-Identifier: AGPL-3.0-only
"""Request-trust classification for the ASGI edge.
Owns FXRoute's proxy-aware same-origin defence: effective scheme/host/port
resolution (a forwarded ``X-Forwarded-*`` hop is honored only from trusted
proxy peers), the cross-site verdict for ``Origin``/``Referer`` headers,
and the Host allowlist that stops DNS-rebinding -- a rebound name makes the
browser's ``Origin`` and ``Host`` agree, so same-origin comparison alone
cannot catch it.
The classification is pure request handling: no application state, locks,
or I/O. Malformed ports fail closed (untrusted) instead of raising, and
headerless CLI/systemd callers stay allowed. ``TrustedOriginMiddleware``
applies it centrally: the Host allowlist on every HTTP request, the
same-origin verdict on unsafe methods, and both checks on WebSocket
handshakes so ``/ws`` never exposes state to a foreign page. Privileged
routes may retain their local checks as defense in depth.
"""
from __future__ import annotations
import ipaddress
import json
import os
from typing import Optional
from urllib.parse import urlparse
from fastapi import Request
from fastapi.responses import JSONResponse
from starlette.requests import HTTPConnection
from starlette.types import ASGIApp, Receive, Scope, Send
from starlette.websockets import WebSocket
UNSAFE_HTTP_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
# Default Host-allowlist suffixes. Literal IPs and single-label names are
# always allowed (see ``is_host_allowed``); these extra suffixes cover the
# LAN naming conventions FXRoute installs actually use. None of them are
# delegatable in public DNS, so a remote page cannot claim a name here for
# a DNS-rebinding attack. ``FXROUTE_ALLOWED_HOSTS`` extends the list for
# reverse-proxy hostnames: comma-separated exact names or ``.suffix`` rules.
_BUILTIN_ALLOWED_HOST_SUFFIXES = (".local", ".lan", ".home.arpa")
# Direct TCP peers whose ``X-Forwarded-*`` headers are honored. Loopback
# covers the documented Caddy-on-the-same-box setup (matching uvicorn's
# default ``forwarded_allow_ips``); ``FXROUTE_TRUSTED_PROXIES`` adds peers
# (comma-separated) for a proxy on another host.
_TRUSTED_PROXY_PEERS = frozenset({"127.0.0.1", "::1", "localhost"})
def _env_list(name: str) -> list[str]:
return [
entry.strip().lower()
for entry in os.environ.get(name, "").split(",")
if entry.strip()
]
def _peer_is_trusted_proxy(request: HTTPConnection) -> bool:
"""True when the direct TCP peer may supply ``X-Forwarded-*`` headers.
Blind trust would let any raw caller forge the effective host and walk
past both the same-origin verdict and the Host allowlist. Untrusted or
missing peer information fails closed (headers ignored).
"""
scope = getattr(request, "scope", None)
client = scope.get("client") if scope else None
peer = str(client[0]).strip().lower() if client else ""
if not peer:
return False
if peer in _TRUSTED_PROXY_PEERS:
return True
return peer in _env_list("FXROUTE_TRUSTED_PROXIES")
def _forwarded_header(request: HTTPConnection, name: str) -> str:
"""First value of a forwarded header; empty string for untrusted peers."""
if not _peer_is_trusted_proxy(request):
return ""
return (request.headers.get(name) or "").split(",", 1)[0].strip().lower()
def effective_request_scheme(request: HTTPConnection) -> str:
forwarded_proto = _forwarded_header(request, "x-forwarded-proto")
scheme = forwarded_proto or (request.url.scheme or "http").lower()
scope = getattr(request, "scope", None)
if scope is not None and scope.get("type") == "websocket":
# Browsers send Origin: http(s):// on WebSocket handshakes while
# the ASGI scope speaks ws(s); compare both on HTTP terms.
scheme = {"ws": "http", "wss": "https"}.get(scheme, scheme)
return scheme
def effective_request_host(request: HTTPConnection) -> str:
"""Resolve the host the client *thinks* it is talking to.
Honors ``X-Forwarded-Host`` when the install is behind a reverse proxy
(a single hop from a trusted proxy peer only, matching the project's
trust assumptions for the optional Caddy reverse proxy on the LAN).
The value is lower-cased and stripped of optional ``:port`` so it can
be compared against ``Origin`` / ``Referer`` headers verbatim.
"""
forwarded_host = _forwarded_header(request, "x-forwarded-host")
if forwarded_host:
# Hostname only -- the matching port lives in X-Forwarded-Port.
return forwarded_host.split(":", 1)[0]
return (request.url.hostname or "").lower()
def effective_request_port(request: HTTPConnection) -> Optional[int]:
"""Resolve the frontend port the client believes it is talking to.
Honors ``X-Forwarded-Port`` from trusted proxy peers so the optional
Caddy reverse proxy on the LAN is supported without losing the
same-origin defence. When no forward headers apply, the request's
actual URL port is used.
"""
raw = _forwarded_header(request, "x-forwarded-port")
if raw.isdigit():
return int(raw)
try:
return request.url.port
except ValueError:
# Malformed Host port: return a sentinel that can never equal a
# parsed header port (those are 0-65535 or None) and never passes
# the default-port rules, so the origin comparison fails closed
# instead of raising.
return -1
class TrustedOriginMiddleware:
"""Edge gate: Host allowlist, same-origin verdict, WebSocket handshakes.
Every HTTP request must name an allowed Host; unsafe methods also need
a trusted ``Origin``/``Referer``. WebSocket handshakes get both checks,
so a foreign page can neither read ``/ws`` state nor ride a rebinding
name past the same-origin comparison. Rejections are plain 403s sent
before route dispatch.
"""
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
scope_type = scope.get("type")
if scope_type == "websocket":
detail = websocket_handshake_rejection(WebSocket(scope, receive, send))
if detail is not None:
await send_websocket_denial(scope, send, detail)
return
elif scope_type == "http":
request = Request(scope, receive=receive)
detail = http_request_rejection_detail(
request, str(scope.get("method") or "")
)
if detail is not None:
response = JSONResponse(
status_code=403,
content={"detail": detail},
)
await response(scope, receive, send)
return
await self.app(scope, receive, send)
def is_request_origin_trusted(request: HTTPConnection) -> bool:
"""Cross-site defence for state-changing endpoints.
FXRoute's LAN security baseline is "trusted LAN, no auth, no cookies"
(see ``AGENTS.md``). Within that baseline, requests originating from
a foreign web page in the same browser are *not* a trusted caller, so
we cannot rely solely on the LAN assumption.
The routine therefore refuses an unsafe request whose ``Origin`` or
``Referer`` header points to a different scheme + host + port than the one
the request is actually reaching -- the cheap, no-cookie CSRF defence
recommended when an application cannot introduce a new auth surface.
Requests without either header (the legitimate CLI / curl / systemd
path) are allowed so existing LAN operators do not lose their
workflows.
Returns ``True`` when the call is allowed, ``False`` when it must be
rejected as a cross-site POST.
"""
trusted_host = effective_request_host(request)
if not trusted_host:
# No host to compare against: the request is malformed enough that
# we refuse to make a decision and let the caller choose.
return False
trusted_scheme = effective_request_scheme(request)
# If the request did not run on a known port (httpx test client with
# weird hosts) the comparison falls back to comparing host only.
trusted_port = effective_request_port(request)
def _netloc_matches(parsed) -> bool:
if not parsed.hostname:
return False
if parsed.hostname.lower() != trusted_host:
return False
try:
header_port = parsed.port
except ValueError:
# Malformed header port (out of range / non-numeric): refuse the
# request instead of letting the comparison raise a 500.
return False
if header_port is None and trusted_port in (None, 80, 443):
return True
if header_port is None:
# Header did not include a port; fall back to comparing
# against the request's effective default.
if (trusted_scheme == "https" and trusted_port == 443) or (
trusted_scheme == "http" and trusted_port in (None, 80)
):
return True
return False
return header_port == trusted_port
origin = (request.headers.get("origin") or "").strip().lower()
if origin:
if origin == "null":
# Browsers emit Origin: null for sandboxed documents and
# cross-origin redirects under specific referrer policies. We
# cannot confirm the caller's site, so refuse.
return False
try:
parsed = urlparse(origin)
except ValueError:
return False
if parsed.scheme and parsed.scheme.lower() != trusted_scheme:
return False
return _netloc_matches(parsed)
referer = (
request.headers.get("referer")
or request.headers.get("referrer")
or ""
).strip()
if referer:
try:
parsed = urlparse(referer)
except ValueError:
return False
if parsed.scheme and parsed.scheme.lower() != trusted_scheme:
return False
return _netloc_matches(parsed)
# No Origin AND no Referer: a CLI / systemd caller. Allowed because
# the LAN security baseline treats direct callers as trusted.
return True
def is_host_allowed(host: Optional[str]) -> bool:
"""Host-allowlist verdict: may this name identify the install?
DNS rebinding makes a foreign page's ``Origin`` and the request's
``Host`` agree on the attacker's name, which satisfies the same-origin
check -- so the host itself must be verified separately:
* literal IPs are always allowed (an address cannot be rebound),
* single-label names are always allowed (``localhost``, short machine
names; not claimable in public DNS),
* the built-in LAN suffixes ``.local`` / ``.lan`` / ``.home.arpa``
are allowed,
* every other dotted name must appear in ``FXROUTE_ALLOWED_HOSTS``
(comma-separated exact names or ``.suffix`` rules).
Returns ``True`` when the host may be used, ``False`` otherwise.
"""
if not host:
return False
name = host.strip().lower().rstrip(".")
if not name:
return False
try:
ipaddress.ip_address(name)
return True
except ValueError:
pass
if "." not in name:
return True
rules = [*_env_list("FXROUTE_ALLOWED_HOSTS"), *_BUILTIN_ALLOWED_HOST_SUFFIXES]
for rule in rules:
if rule.startswith("."):
if name.endswith(rule):
return True
elif name == rule:
return True
return False
def is_request_host_allowed(request: HTTPConnection) -> bool:
"""Allowlist check for the effective Host of ``request``."""
return is_host_allowed(effective_request_host(request))
def http_request_rejection_detail(request: HTTPConnection, method: str) -> Optional[str]:
"""Central HTTP verdict for the middleware; ``None`` lets the call run.
The Host allowlist applies to every method (it is the DNS-rebinding
gate); the same-origin verdict still covers unsafe methods only.
"""
if not is_request_host_allowed(request):
return "Host not allowed"
if method.upper() in UNSAFE_HTTP_METHODS and not is_request_origin_trusted(request):
return "Cross-site request rejected"
return None
def websocket_handshake_rejection(request: HTTPConnection) -> Optional[str]:
"""WebSocket-handshake verdict; ``None`` lets the upgrade proceed.
Browsers always send ``Origin`` on a handshake, so the same rules as
unsafe HTTP methods apply (headerless non-browser clients stay
allowed, matching the CLI baseline). The Host allowlist also covers
the handshake: a rebinding page's ``ws://attacker.name`` upgrade would
otherwise agree with itself and pass the same-origin check.
"""
if not is_request_host_allowed(request):
return "Host not allowed"
if not is_request_origin_trusted(request):
return "Cross-site request rejected"
return None
async def send_websocket_denial(scope: Scope, send: Send, detail: str) -> None:
"""Deny a WebSocket handshake before the endpoint sees any request.
Prefers the ASGI ``websocket.http.response`` extension (advertised by
uvicorn and the Starlette test client) so the caller gets a real 403
with a JSON body; falls back to a policy-violation close, which
uvicorn answers with an empty 403 handshake denial.
"""
if "websocket.http.response" in scope.get("extensions", {}):
body = json.dumps({"detail": detail}).encode("utf-8")
await send(
{
"type": "websocket.http.response.start",
"status": 403,
"headers": [
(b"content-type", b"application/json"),
(b"content-length", str(len(body)).encode("ascii")),
],
}
)
await send({"type": "websocket.http.response.body", "body": body})
else:
await send(
{
"type": "websocket.close",
"code": 1008,
"reason": "policy violation",
}
)