Files
VoiceStudio/backend/api/dependencies.py
T

321 lines
14 KiB
Python

"""
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)