From 017f383096651eafd5232dccf5fa5bdad36e6df5 Mon Sep 17 00:00:00 2001 From: BigBodyCobain <43977454+BigBodyCobain@users.noreply.github.com> Date: Thu, 28 May 2026 01:24:14 -0600 Subject: [PATCH] Fix BadHost path handling --- backend/auth.py | 18 ++++---- backend/main.py | 9 ++-- backend/pyproject.toml | 3 +- backend/services/mesh/mesh_signed_events.py | 9 +++- backend/tests/test_badhost_hardening.py | 50 +++++++++++++++++++++ uv.lock | 28 +++++++++--- 6 files changed, 95 insertions(+), 22 deletions(-) create mode 100644 backend/tests/test_badhost_hardening.py diff --git a/backend/auth.py b/backend/auth.py index e060042..461c134 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -113,8 +113,14 @@ def _scoped_admin_tokens() -> dict[str, list[str]]: return normalized +def _request_scope_path(request: Request) -> str: + """Return the ASGI request-line path, not the Host-derived URL path.""" + scope = getattr(request, "scope", {}) or {} + return str(scope.get("path") or "") + + def _required_scope_for_request(request: Request) -> str: - path = str(request.url.path or "") + path = _request_scope_path(request) if path.startswith("/api/wormhole/gate/"): return "gate" if path.startswith("/api/wormhole/dm/"): @@ -443,7 +449,7 @@ async def _verify_openclaw_hmac(request: Request) -> bool: # Compute expected signature: HMAC-SHA256(secret, METHOD|path|ts|nonce|body_digest) method = str(request.method or "").upper() - path = str(request.url.path or "") + path = _request_scope_path(request) message = f"{method}|{path}|{ts_str}|{nonce}|{body_digest}" expected = hmac.new( secret.encode("utf-8"), @@ -744,8 +750,7 @@ def _is_debug_test_request(request: Request) -> bool: if not _debug_mode_enabled(): return False client_host = (request.client.host or "").lower() if request.client else "" - url_host = (request.url.hostname or "").lower() if request.url else "" - return client_host == "test" or url_host == "test" + return client_host == "test" # --------------------------------------------------------------------------- @@ -1397,10 +1402,7 @@ def _peer_hmac_url_from_request(request: Request) -> str: header_url = normalize_peer_url(str(request.headers.get("x-peer-url", "") or "")) if header_url: return header_url - if not request.url: - return "" - base_url = f"{request.url.scheme}://{request.url.netloc}".rstrip("/") - return normalize_peer_url(base_url) + return "" def _verify_peer_push_hmac(request: Request, body_bytes: bytes) -> bool: diff --git a/backend/main.py b/backend/main.py index 64d6ee4..8d71a61 100644 --- a/backend/main.py +++ b/backend/main.py @@ -310,6 +310,7 @@ from auth import ( _private_plane_access_denied_payload, _private_infonet_policy_snapshot, _private_plane_refusal_response, + _request_scope_path, _scoped_admin_tokens, _scoped_view_authenticated as _scoped_view_authenticated_auth, _security_headers, @@ -2728,7 +2729,7 @@ async def json_decode_error_handler(_request: Request, _exc: JSONDecodeError): @app.exception_handler(StarletteHTTPException) async def private_plane_http_exception_handler(request: Request, exc: StarletteHTTPException): - if exc.status_code == 403 and _is_private_plane_access_path(request.url.path, request.method): + if exc.status_code == 403 and _is_private_plane_access_path(_request_scope_path(request), request.method): return await _private_plane_refusal_response( request, status_code=403, @@ -2762,7 +2763,7 @@ async def mesh_security_headers(request: Request, call_next): @app.middleware("http") async def mesh_no_store_headers(request: Request, call_next): response = await call_next(request) - if request.url.path.startswith("/api/mesh/"): + if _request_scope_path(request).startswith("/api/mesh/"): response.headers["Cache-Control"] = "no-store, max-age=0" response.headers["Pragma"] = "no-cache" return response @@ -3464,7 +3465,7 @@ def _refresh_lookup_handle_rotation_background(*, reason: str) -> dict[str, Any] @app.middleware("http") async def enforce_high_privacy_mesh(request: Request, call_next): - path = request.url.path + path = _request_scope_path(request) private_mesh_path = path.startswith("/api/mesh") and not _is_public_meshtastic_lane_path( path, request.method, @@ -3624,7 +3625,7 @@ async def enforce_high_privacy_mesh(request: Request, call_next): @app.middleware("http") async def apply_no_store_to_sensitive_paths(request: Request, call_next): response = await call_next(request) - if _is_sensitive_no_store_path(request.url.path): + if _is_sensitive_no_store_path(_request_scope_path(request)): for key, value in _NO_STORE_HEADERS.items(): response.headers[key] = value return response diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 7fd1dd3..3416fc9 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -15,7 +15,7 @@ dependencies = [ "cachetools==5.5.2", "cryptography>=41.0.0", "defusedxml>=0.7.1", - "fastapi==0.115.12", + "fastapi==0.136.3", "feedparser==6.0.10", "httpx==0.28.1", "playwright==1.59.0", @@ -33,6 +33,7 @@ dependencies = [ "paho-mqtt>=1.6.0,<2.0.0", "PyNaCl>=1.5.0", "slowapi==0.1.9", + "starlette==1.0.1", "vaderSentiment>=3.3.0", "uvicorn==0.34.0", "yfinance==1.3.0", diff --git a/backend/services/mesh/mesh_signed_events.py b/backend/services/mesh/mesh_signed_events.py index 76386ac..d3346cd 100644 --- a/backend/services/mesh/mesh_signed_events.py +++ b/backend/services/mesh/mesh_signed_events.py @@ -38,6 +38,11 @@ _REVOCATION_TTL_CACHE: dict[str, dict[str, Any]] = {} _REVOCATION_TTL_LOCK = threading.Lock() _REVOCATION_REFRESH_LOCK = threading.Lock() _REVOCATION_REFRESH_FAIL_FAST_WINDOW_S = 5.0 + + +def _request_scope_path(request: Request) -> str: + scope = getattr(request, "scope", {}) or {} + return str(scope.get("path") or "") _REVOCATION_REFRESH_RETRY_AFTER_S = 5 _REVOCATION_PRECHECK_UNAVAILABLE_DETAIL = "Signed event integrity preflight unavailable" @@ -166,7 +171,7 @@ def _canonical_signed_write_retry_payload( signed_context = build_signed_context( event_type=prepared.event_type, kind=prepared.kind.value, - endpoint=str(request.url.path or ""), + endpoint=_request_scope_path(request), lane_floor=_content_private_required_transport_tier(prepared.kind), sequence_domain=_signed_context_sequence_domain(prepared), node_id=prepared.node_id, @@ -540,7 +545,7 @@ def _apply_signed_context_policy(prepared: "PreparedSignedWrite", request: Reque ok, reason = validate_signed_context( event_type=prepared.event_type, kind=prepared.kind.value, - endpoint=str(request.url.path or ""), + endpoint=_request_scope_path(request), lane_floor=_content_private_required_transport_tier(prepared.kind), sequence_domain=_signed_context_sequence_domain(prepared), node_id=prepared.node_id, diff --git a/backend/tests/test_badhost_hardening.py b/backend/tests/test_badhost_hardening.py new file mode 100644 index 0000000..939caf7 --- /dev/null +++ b/backend/tests/test_badhost_hardening.py @@ -0,0 +1,50 @@ +from starlette.requests import Request + +import auth + + +async def _empty_receive(): + return {"type": "http.request", "body": b"", "more_body": False} + + +def _request(path: str, *, host: str = "example.com/health?x=", client_host: str = "203.0.113.10") -> Request: + return Request( + { + "type": "http", + "method": "GET", + "scheme": "http", + "server": ("127.0.0.1", 8000), + "client": (client_host, 12345), + "path": path, + "raw_path": path.encode("ascii"), + "query_string": b"", + "headers": [(b"host", host.encode("ascii"))], + }, + receive=_empty_receive, + ) + + +def test_scope_auth_uses_asgi_path_not_host_derived_url_path(): + request = _request("/api/mesh/gate/alpha/message") + + assert auth._request_scope_path(request) == "/api/mesh/gate/alpha/message" + assert auth._required_scope_for_request(request) == "mesh" + + +def test_debug_test_request_does_not_trust_host_header(monkeypatch): + monkeypatch.setattr(auth, "_debug_mode_enabled", lambda: True) + + request = _request("/api/admin", host="test/api/public?x=") + + assert auth._is_debug_test_request(request) is False + + +def test_peer_hmac_identity_requires_explicit_peer_url_header(): + request = _request("/api/mesh/infonet/push", host="https://peer.example/api/public?x=") + + assert auth._peer_hmac_url_from_request(request) == "" + + request = _request("/api/mesh/infonet/push") + request.scope["headers"].append((b"x-peer-url", b"https://peer.example/")) + + assert auth._peer_hmac_url_from_request(request) == "https://peer.example" diff --git a/uv.lock b/uv.lock index 8332a06..423b69e 100644 --- a/uv.lock +++ b/uv.lock @@ -17,6 +17,15 @@ members = [ "shadowbroker", ] +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -100,6 +109,7 @@ dependencies = [ { name = "reverse-geocoder" }, { name = "sgp4" }, { name = "slowapi" }, + { name = "starlette" }, { name = "uvicorn" }, { name = "vadersentiment" }, { name = "yfinance" }, @@ -120,7 +130,7 @@ requires-dist = [ { name = "cachetools", specifier = "==5.5.2" }, { name = "cryptography", specifier = ">=41.0.0" }, { name = "defusedxml", specifier = ">=0.7.1" }, - { name = "fastapi", specifier = "==0.115.12" }, + { name = "fastapi", specifier = "==0.136.3" }, { name = "feedparser", specifier = "==6.0.10" }, { name = "httpx", specifier = "==0.28.1" }, { name = "meshtastic", specifier = ">=2.5.0" }, @@ -138,6 +148,7 @@ requires-dist = [ { name = "reverse-geocoder", specifier = "==1.5.1" }, { name = "sgp4", specifier = "==2.25" }, { name = "slowapi", specifier = "==0.1.9" }, + { name = "starlette", specifier = "==1.0.1" }, { name = "uvicorn", specifier = "==0.34.0" }, { name = "vadersentiment", specifier = ">=3.3.0" }, { name = "yfinance", specifier = "==1.3.0" }, @@ -621,16 +632,18 @@ wheels = [ [[package]] name = "fastapi" -version = "0.115.12" +version = "0.136.3" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "annotated-doc" }, { name = "pydantic" }, { name = "starlette" }, { name = "typing-extensions" }, + { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f4/55/ae499352d82338331ca1e28c7f4a63bfd09479b16395dce38cf50a39e2c2/fastapi-0.115.12.tar.gz", hash = "sha256:1e2c2a2646905f9e83d32f04a3f86aff4a286669c6c950ca95b5fd68c2602681", size = 295236, upload-time = "2025-03-23T22:55:43.822Z" } +sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/50/b3/b51f09c2ba432a576fe63758bddc81f78f0c6309d9e5c10d194313bf021e/fastapi-0.115.12-py3-none-any.whl", hash = "sha256:e94613d6c05e27be7ffebdd6ea5f388112e5e430c8f7d6494a9d1d88d43e814d", size = 95164, upload-time = "2025-03-23T22:55:42.101Z" }, + { url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" }, ] [[package]] @@ -2271,14 +2284,15 @@ wheels = [ [[package]] name = "starlette" -version = "0.46.2" +version = "1.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ce/20/08dfcd9c983f6a6f4a1000d934b9e6d626cff8d2eeb77a89a68eef20a2b7/starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5", size = 2580846, upload-time = "2025-04-13T13:56:17.942Z" } +sdist = { url = "https://files.pythonhosted.org/packages/08/a3/84e821cc54b4ab50ae6dbc6ac3800a651b65ec35f045cc73785380654057/starlette-1.0.1.tar.gz", hash = "sha256:512399c5f1de7fac99c88572212ded9ddeddef2fb32afa82d724000e88b38f4f", size = 2659596, upload-time = "2026-05-21T21:58:58.433Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037, upload-time = "2025-04-13T13:56:16.21Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/b2df4bc09a1e51ff664c1e17018a4274b42e5e9352e4a478ea540512dc88/starlette-1.0.1-py3-none-any.whl", hash = "sha256:7c0e69b2ee1c848bd54669d908500117a3ee13de603a21427e5c6fc1adf98dcd", size = 72802, upload-time = "2026-05-21T21:58:56.551Z" }, ] [[package]]