Files
wehub-resource-sync 0ef5fcb1c5
Security / Dependency audit (pip-audit) (push) Has been cancelled
Security / CodeQL (javascript-typescript) (push) Has been cancelled
Security / CodeQL (python) (push) Has been cancelled
Security / Secret scan (gitleaks) (push) Has been cancelled
rust / test (ubuntu) (push) Has been cancelled
rust / simulator e2e (macos-latest) (push) Has been cancelled
rust / simulator e2e (ubuntu-latest) (push) Has been cancelled
rust / simulator e2e (windows-latest) (push) Has been cancelled
rust / wheels (aarch64-apple-darwin) (push) Has been cancelled
rust / wheels (x86_64-unknown-linux-gnu) (push) Has been cancelled
rust / wheels (x86_64-apple-darwin) (push) Has been cancelled
rust / audit (push) Has been cancelled
rust / parity (nightly, allowed to fail during Phase 0) (push) Has been cancelled
CI / commitlint (push) Has been skipped
Dev Containers / validate (.devcontainer/devcontainer.json, default) (push) Failing after 0s
Dev Containers / validate (.devcontainer/memory-stack/devcontainer.json, memory-stack) (push) Failing after 0s
Dev Containers / validate-worktree (push) Failing after 0s
CI / changes (push) Failing after 4s
Deploy Documentation / validate (push) Has been skipped
Deploy Documentation / deploy (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, claude) (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, codex) (push) Failing after 1s
Install Native E2E / install-native (ubuntu-latest) (push) Failing after 1s
OpenCode Plugin / typecheck + build + test (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, copilot) (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
Wrap E2E / docker-wrap-e2e (push) Failing after 1s
Wrap Native E2E / wrap-native (ubuntu-latest) (push) Failing after 1s
Init E2E / docker-init-e2e (push) Failing after 4s
Merge Conflicts / merge-conflicts (push) Failing after 4s
CI / lint (push) Has been cancelled
CI / build-wheel (push) Has been cancelled
CI / build-wheel-windows (push) Has been cancelled
CI / prefetch-model (push) Has been cancelled
CI / test-dashboard-ui (push) Has been cancelled
CI / test (1) (push) Has been cancelled
CI / test (2) (push) Has been cancelled
CI / test (3) (push) Has been cancelled
CI / test (4) (push) Has been cancelled
CI / test-extras (push) Has been cancelled
CI / test-agno (push) Has been cancelled
CI / build (push) Has been cancelled
CI / workflow-validation (push) Has been cancelled
CI / docker-native-e2e (push) Has been cancelled
CI / windows-native-wrapper (push) Has been cancelled
CI / macos-native-wrapper (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / promote-latest (push) Has been cancelled
Init Native E2E / init-native (macos-latest, claude) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, codex) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, copilot) (push) Has been cancelled
Install Native E2E / install-native (macos-latest) (push) Has been cancelled
Wrap Native E2E / wrap-native (macos-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:03:20 +08:00

175 lines
6.8 KiB
Python

"""Loopback-only access guard for /debug/* endpoints.
Unit 5 of the Codex-proxy resilience plan. A FastAPI dependency that
raises :class:`fastapi.HTTPException` with status 404 — *not* 403 — for
any request whose client address is not the loopback interface. 404 is
deliberate: debug endpoints should be invisible to external scanners,
not merely forbidden.
The guard is a ``Depends(...)``-friendly function (rather than a
middleware) because:
* FastAPI's dependency injection makes the guard explicit on each
route, so ``ruff``/reviewers can see which endpoints are guarded.
* ``TestClient`` lets us override a dependency with
``app.dependency_overrides``, which is the cleanest way to simulate
a non-loopback client in tests.
* The set of debug endpoints is small and co-located; a middleware
would be disproportionate.
DNS-rebinding defence
---------------------
A loopback-IP check alone is not enough to keep these endpoints local.
A malicious site can use DNS rebinding to make a victim's browser send
requests to ``127.0.0.1`` while the ``Host:`` header (and the JS
``fetch`` URL) still reads ``attacker.com``. From the proxy's point of
view ``request.client.host`` is ``127.0.0.1`` (the browser, which IS on
loopback) and the IP check passes. The proxy ships a wide-open CORS
policy (``allow_origins=['*']``), so attacker JS can then read the
response.
To close that gap the guard also requires the ``Host:`` header to name
loopback — ``127.0.0.1[:port]``, ``[::1][:port]``, or
``localhost[:port]``. Same-origin XHR from a real local tool always
sets one of those values; cross-origin rebinding does not. This is the
canonical Host-header allowlist mitigation called out in OWASP's
CSRF / DNS-rebinding guidance and the standard Starlette
``TrustedHostMiddleware`` pattern.
"""
from __future__ import annotations
import ipaddress
try:
from fastapi import HTTPException, Request
except ImportError: # pragma: no cover - fastapi is a hard dep in practice
HTTPException = None # type: ignore[assignment,misc]
Request = None # type: ignore[assignment,misc]
__all__ = [
"LOOPBACK_HOSTS",
"is_loopback_host",
"is_loopback_host_header",
"require_loopback",
]
# Legacy canonical loopback literal set. Retained for backwards
# compatibility with callers/tests that still import it; the real check
# now goes through :func:`ipaddress.ip_address(...).is_loopback` so we
# also accept IPv6-mapped IPv4 (``::ffff:127.0.0.1``) and other valid
# loopback literals on dual-stack sockets.
LOOPBACK_HOSTS: frozenset[str] = frozenset({"127.0.0.1", "::1", "localhost"})
def is_loopback_host(host: str | None) -> bool:
"""Return True if ``host`` represents a loopback interface.
``None`` is treated as loopback — this covers ``TestClient`` /
UDS-style requests where FastAPI does not populate
``request.client``.
``"localhost"`` is special-cased as a string since it is not a
valid IP literal. The comparison is case-insensitive because
hostnames are (RFC 4343), so a ``Host: LOCALHOST`` from a local
tool is still accepted. Every other host is parsed with
:func:`ipaddress.ip_address`; this accepts IPv6-mapped IPv4
(``::ffff:127.0.0.1``) which Linux dual-stack sockets emit by
default. Malformed input returns ``False``.
"""
if host is None:
return True
if host.lower() == "localhost":
return True
try:
address = ipaddress.ip_address(host)
except ValueError:
return False
if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped is not None:
return address.ipv4_mapped.is_loopback
return address.is_loopback
def is_loopback_host_header(header_value: str | None) -> bool:
"""Return True if a ``Host:`` header names a loopback address.
The header can include a port (``127.0.0.1:8787``,
``[::1]:8787``, ``localhost:8787``) and uses bracket notation for
raw IPv6 literals per RFC 3986. This helper strips brackets and
the trailing ``:port`` (if any) and delegates the address-vs-name
decision to :func:`is_loopback_host`.
Missing / empty headers return ``False`` rather than ``True`` —
a real local browser or CLI always sets ``Host:``, so absence is
suspicious. Server-internal callers that bypass HTTP entirely
(``TestClient`` with a manual call) do not hit the guard.
"""
if not header_value:
return False
candidate = header_value.strip()
if not candidate:
return False
# Bracketed IPv6: [::1] or [::1]:8787 — strip the brackets and
# everything after the matching ``]`` (which is the port suffix).
if candidate.startswith("["):
closing = candidate.find("]")
if closing == -1:
return False
host_part = candidate[1:closing]
elif candidate.count(":") == 1:
# Single colon = host:port for IPv4 / hostname. A bare IPv6
# literal without brackets has multiple colons and would be
# ambiguous, so we don't strip in that case.
host_part = candidate.rsplit(":", 1)[0]
else:
host_part = candidate
return is_loopback_host(host_part)
def require_loopback(request: Request) -> None: # type: ignore[valid-type]
"""FastAPI dependency: 404 any non-loopback caller.
Usage::
@app.get("/debug/tasks", dependencies=[Depends(require_loopback)])
async def debug_tasks() -> list[dict]:
...
Two gates have to pass:
1. ``request.client.host`` must be a loopback IP. Stops anyone
who actually reaches the listener from outside ``127.0.0.0/8``
/ ``::1``.
2. The inbound ``Host:`` header must also name loopback. Stops
DNS-rebinding attacks where a browser sends requests to the
loopback IP but the page origin is ``attacker.com`` — the IP
check alone passes, but the ``Host:`` header still reads
``attacker.com`` and we reject the request here.
Returning 404 (not 403) keeps debug endpoints invisible to
external scanners — indistinguishable from "no such route".
"""
if HTTPException is None: # pragma: no cover - defensive
raise RuntimeError("FastAPI is required for the loopback guard")
client = getattr(request, "client", None)
host = getattr(client, "host", None) if client is not None else None
if not is_loopback_host(host):
# No body: minimal FastAPI default, behaves like "no route".
raise HTTPException(status_code=404)
headers = getattr(request, "headers", None)
if headers is None:
# Manual ``Request`` stub with no ``headers`` attribute — used
# by older unit tests that pre-date this gate. Treat the same
# way as the IP-only path did and accept.
return
try:
host_header = headers.get("host")
except AttributeError:
host_header = None
if not is_loopback_host_header(host_header):
raise HTTPException(status_code=404)