""" Shared FastAPI dependencies. These are intentionally tiny — one concern per dependency — so they can be composed at the route or router level without surprises. Currently exposed: - `require_loopback`: 403 unless the request came from a loopback origin (read-only bootstrap is allowed in explicit server mode; mutations still require the admin API key — see `_server_mode`). - `require_admin`: method-aware admin gate for privileged routers. - `require_admin_action`: strict admin gate for side-effectful GET actions. - `require_native_access`: true-loopback-only access to the host filesystem; unlike `require_loopback`, it is never bypassed by server mode. - `ws_remote_authorized`: whether a WebSocket handshake from a non-loopback client carries the remote API key (Wave 2.3) — used by WS endpoints that keep their own inline loopback guards. """ import ipaddress import os import secrets from fastapi import HTTPException, Request # IPv4 + IPv6 loopback literals + the conventional `localhost` hostname. # `request.client.host` carries an address, not a hostname, so the literal # "localhost" entry is defensive — some upstream wrappers (TestClient with # a custom client tuple, certain reverse-proxy headers) may pass strings # rather than parsed addresses. We accept the broader set without weakening # the guard: nothing here matches a non-loopback origin. _LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"}) def _trusted_networks(): """CIDR networks from OMNIVOICE_TRUSTED_NETWORKS (comma-separated) treated as loopback-trusted — e.g. a reverse proxy or self-hosted LAN, so the API-key / PIN gates don't block LAN clients that can't present the credential (a proxy that strips the Authorization header). Read at call time (matching `_server_mode` / `remote_api_key`) so tests can monkeypatch the env; restart to apply changes in production.""" nets = [] for cidr in os.environ.get("OMNIVOICE_TRUSTED_NETWORKS", "").split(","): cidr = cidr.strip() if cidr: try: nets.append(ipaddress.ip_network(cidr, strict=False)) except ValueError: pass # malformed entry ignored — never wedge the auth gate return nets def is_loopback(host): """True loopback address only (127.0.0.1, ::1, localhost) — NOT a trusted network. Admin gates (``require_admin`` → ``/system/set-env``, ``/api/settings/*``) use this so a trusted-network CIDR exempts consumption (TTS / dictation) but never the RCE-class admin surface.""" return host in _LOOPBACK_HOSTS def is_local_host(host): """Loopback address, OR on a configured trusted network. The consumption gates (PIN/API-key middleware, WS guard) call this so a trusted LAN/proxy is exempted. Admin gates use :func:`is_loopback` — NOT this — to preserve the two-tier privilege model: consumption trust ≠ admin trust.""" if is_loopback(host): return True try: ip = ipaddress.ip_address(host) except (ValueError, TypeError): return False # Unwrap IPv4-mapped IPv6 (::ffff:192.168.1.5) so it matches IPv4 CIDRs — # dual-stack proxies (Caddy, Node.js) frequently pass the mapped form. if getattr(ip, "ipv4_mapped", None): ip = ip.ipv4_mapped return any(ip in net for net in _trusted_networks()) _TRUTHY = frozenset({"1", "true", "yes", "on"}) _READ_ONLY_METHODS = frozenset({"GET", "HEAD", "OPTIONS"}) def _server_mode() -> bool: """Whether this process is a headless server deployment (Docker image). In Docker the loopback gate is *unenforceable*: Docker's network NAT rewrites ``request.client.host`` to the bridge gateway (e.g. 172.17.0.1) even for a localhost-only ``-p 127.0.0.1:3900:3900`` mapping, so every request looks non-loopback and the gate 403s the operator out of the system/settings routes they need (issue #261 — incl. ``/system/info``, which blanks the version display). The Docker image sets ``OMNIVOICE_SERVER_MODE=1`` to opt out of the gate. Network exposure then rests on the operator's port mapping plus the optional share PIN (``NetworkAccessMiddleware`` still 401s unauthenticated non-loopback clients whenever a PIN is set). The desktop build never sets this, so its loopback boundary — including denying LAN share guests access to admin routes — is unchanged. Read at call time so it stays testable. """ return os.environ.get("OMNIVOICE_SERVER_MODE", "").strip().lower() in _TRUTHY def remote_api_key() -> str | None: """The normalized remote-backend bearer key, or None when remote mode is off. Surrounding whitespace is configuration noise, never a valid secret. Read at call time so tests can monkeypatch the environment.""" return os.environ.get("OMNIVOICE_API_KEY", "").strip() or None def presented_api_key(connection) -> str: """Return the first non-empty normalized API key on an HTTP/WS connection. Authorization wins over query, which wins over cookie. Each channel is stripped before fallback so whitespace in a higher-priority channel cannot shadow a valid lower-priority credential. """ headers = getattr(connection, "headers", None) or {} query = getattr(connection, "query_params", None) or {} cookies = getattr(connection, "cookies", None) or {} auth = headers.get("authorization", "") supplied = auth[7:].strip() if auth.lower().startswith("bearer ") else "" if supplied: return supplied supplied = (query.get("api_key") or "").strip() if supplied: return supplied return (cookies.get("ov_key") or "").strip() def _configured_pin(request) -> str | None: """The active share PIN (``app.state.network_share.pin``) or None. Read via getattr so a bare Request stub (or a request that hit before lifespan set the state) never raises — a missing PIN just means 'no PIN gate'.""" app = getattr(request, "app", None) state = getattr(app, "state", None) if app is not None else None ns = getattr(state, "network_share", None) if state is not None else None return getattr(ns, "pin", None) if ns is not None else None def _admin_credential_configured(request) -> bool: """Whether an API key or share PIN is configured. The PIN cannot authorize admin access, but its presence means the operator opted out of bare-server discovery. Remote admin then remains closed until they configure and present the long API key. """ if remote_api_key(): return True return bool(_configured_pin(request)) def _request_presents_admin_credential(request) -> bool: """Whether the request carries a valid **API key** via the channels the middleware accepts (``Authorization: Bearer`` / ``?api_key`` / ``ov_key`` cookie). Admin is RCE-class (``/system/set-env`` + ``/api/settings/*``), so only the API key — a long operator-chosen secret — unlocks it. The 6-digit share PIN is deliberately NOT accepted here: it is a *consumption* credential for LAN playback and is short enough to brute-force (10^6, no lockout), so it must never gate the admin surface (CodeRabbit #1213). A trusted-network CIDR (``is_local_host`` — also a consumption exemption) likewise never unlocks admin. Net: remote admin in server mode requires the API key; a PIN-only deployment keeps admin loopback-only. getattr-defensive so a minimal Request stub never raises.""" api_key = remote_api_key() or "" if not api_key: return False supplied = presented_api_key(request) return bool(supplied and secrets.compare_digest(supplied, api_key)) def require_loopback(request: Request) -> None: """Reject any request whose `client.host` is not a loopback address. Use as a router-level dependency to protect every route on the router in one place: router = APIRouter(dependencies=[Depends(require_loopback)]) Or as a per-route dependency for narrower scope: @router.post("/foo", dependencies=[Depends(require_loopback)]) Returns None on success (FastAPI dependency convention). Raises 403 on rejection — the response body is `{"detail": "loopback origin required"}` so existing tests for `/system/set-env` keep passing without modification. In server mode (Docker, see `_server_mode`) the loopback origin is unenforceable, so the gate can't require true loopback. It then applies the admin-credential rule instead: - No credential configured (no API key, no PIN) → read-only requests are open, matching the #261 Docker bootstrap flow. State-changing requests fail closed even if a route accidentally kept this legacy dependency. - A credential IS configured → the request must present the **API key**. This keeps the two-tier privilege model intact under server mode: ``OMNIVOICE_TRUSTED_NETWORKS`` is a *consumption* exemption (``is_local_host``) that bypasses the PIN / API-key middleware, and it must NEVER by itself unlock the admin surface (``/system/set-env`` — RCE-class — and ``/api/settings/*``). The 6-digit share PIN is a consumption credential too and does not gate admin, so a PIN-only deployment keeps admin loopback-only; remote admin requires the long API key. See docs/api-auth.md (#1213). """ host = request.client.host if request.client else None if is_loopback(host): return if _server_mode(): method = str(getattr(request, "method", "GET")).upper() if method not in _READ_ONLY_METHODS: # Defense in depth. Privileged routers should declare # ``require_admin`` directly, but a missed migration must not turn # into an unauthenticated Docker write primitive. require_admin(request) return if not _admin_credential_configured(request): return if _request_presents_admin_credential(request): return raise HTTPException(status_code=403, detail="loopback origin required") def require_admin(request: Request) -> None: """Gate RCE/filesystem-capable admin routers. Desktop callers keep the loopback-only contract. Docker cannot reliably observe the host operator as loopback, so authenticated remote admin stays available there, but every state-changing request must present the long API key. An unconfigured server must never expose executable-path or filesystem settings to every client that can reach its published port. Read-only requests retain the bare-Docker bootstrap behaviour until an API key is configured. Share PINs and trusted CIDRs are consumption credentials; neither authorizes this gate. """ host = request.client.host if request.client else None if is_loopback(host): return if _server_mode(): method = str(getattr(request, "method", "GET")).upper() read_only = method in _READ_ONLY_METHODS if read_only and not _admin_credential_configured(request): return if _request_presents_admin_credential(request): return raise HTTPException(status_code=403, detail="loopback origin or admin API key required") def require_admin_action(request: Request) -> None: """Gate an administrative action even when its HTTP method is read-only. A small number of legacy GET endpoints have real side effects. For example, an engine health check may spawn a sidecar process. Such routes cannot use :func:`require_admin`'s bare-server discovery exception. """ host = request.client.host if request.client else None if is_loopback(host): return if _server_mode() and _request_presents_admin_credential(request): return raise HTTPException(status_code=403, detail="loopback origin or admin API key required") def require_desktop(request: Request) -> None: """Gate capabilities that may select or execute host filesystem paths. An API key authorizes remote administration, not access to the desktop shell's native file-picker boundary. These capabilities therefore remain strictly loopback-only even when server mode is enabled. """ host = request.client.host if request.client else None if is_loopback(host): return raise HTTPException(status_code=403, detail="desktop origin required") def require_local(request: Request) -> None: """Reject any request whose client.host is not loopback OR on a configured trusted network. The consumption-tier companion to :func:`require_loopback`: use on routes a trusted-network client (LAN/proxy) should reach without a PIN or API key — e.g. the dictation model/prefs endpoints that pair with the dictation WebSocket. Admin routes stay on :func:`require_admin`. In server mode this consumption gate is a no-op. Admin dependencies remain method-aware and independent from this exemption.""" host = request.client.host if request.client else None if is_local_host(host): return if _server_mode(): return raise HTTPException(status_code=403, detail="loopback origin required") def require_native_access(request: Request) -> None: """Protect capabilities that read or write operator-chosen host paths. Docker server mode deliberately relaxes the ordinary admin gate because a bridge makes even local traffic appear remote. That exception is unsafe for native file pickers: a remote API caller must never probe or overwrite an arbitrary path on the backend host, even with the server API key. """ host = request.client.host if request.client else None if not is_loopback(host): raise HTTPException(status_code=403, detail="native filesystem access requires loopback origin") def ws_remote_authorized(websocket) -> bool: """Whether a WebSocket handshake presents the remote API key. Browser WebSockets cannot set an Authorization header, so the key may arrive as ``?api_key=`` or via the ``ov_key`` cookie that the bearer middleware sets on the first authenticated HTTP request. Returns False when remote mode is off — callers keep their loopback-only behavior. """ key = remote_api_key() if not key: return False return secrets.compare_digest(presented_api_key(websocket), key)