CodeRabbit: the 6-digit share PIN is brute-forceable (10^6, no lockout), so letting it unlock admin over the network was still weak. Admin now requires the API key (a long operator secret) or loopback; the PIN is consumption-only and never gates /system/* or /api/settings/*. A PIN-only deployment keeps admin loopback-only. Docs (api-auth.md, remote-gpu.md) aligned with the conditional gate (no credential -> admin open; API key -> admin) and the PIN exclusion. Test inverted: presenting the PIN over the network is now 403 on admin.
240 lines
10 KiB
Python
240 lines
10 KiB
Python
"""`require_loopback` gate contract (issue #261).
|
|
|
|
The gate must stay strict on the desktop build (non-loopback → 403, which is the
|
|
PR #81 trust boundary), but become a no-op in the headless Docker server mode,
|
|
where Docker's NAT makes the loopback origin unenforceable and exposure is
|
|
governed by the port mapping + the share PIN instead.
|
|
"""
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
|
|
from api.dependencies import is_loopback, is_local_host, require_local, require_loopback
|
|
|
|
|
|
def _req(host):
|
|
"""Minimal stand-in for a Starlette Request — the gate only reads client.host."""
|
|
return SimpleNamespace(client=SimpleNamespace(host=host) if host else None)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clear_loopback_env(monkeypatch):
|
|
# Start each test from the strict desktop default regardless of ambient env.
|
|
monkeypatch.delenv("OMNIVOICE_SERVER_MODE", raising=False)
|
|
monkeypatch.delenv("OMNIVOICE_TRUSTED_NETWORKS", raising=False)
|
|
|
|
|
|
@pytest.mark.parametrize("host", ["127.0.0.1", "::1", "localhost"])
|
|
def test_loopback_always_allowed(host):
|
|
require_loopback(_req(host)) # must not raise
|
|
|
|
|
|
def test_non_loopback_rejected_by_default():
|
|
with pytest.raises(HTTPException) as exc:
|
|
require_loopback(_req("172.17.0.1")) # Docker bridge gateway
|
|
assert exc.value.status_code == 403
|
|
assert "loopback" in str(exc.value.detail).lower()
|
|
|
|
|
|
def test_missing_client_rejected_by_default():
|
|
with pytest.raises(HTTPException):
|
|
require_loopback(_req(None))
|
|
|
|
|
|
@pytest.mark.parametrize("val", ["1", "true", "TRUE", "yes", "on"])
|
|
def test_server_mode_allows_non_loopback(monkeypatch, val):
|
|
monkeypatch.setenv("OMNIVOICE_SERVER_MODE", val)
|
|
require_loopback(_req("172.17.0.1")) # must not raise
|
|
require_loopback(_req("127.0.0.1")) # loopback still fine
|
|
|
|
|
|
@pytest.mark.parametrize("val", ["0", "false", "no", "", "off"])
|
|
def test_falsey_server_mode_keeps_gate_strict(monkeypatch, val):
|
|
monkeypatch.setenv("OMNIVOICE_SERVER_MODE", val)
|
|
with pytest.raises(HTTPException):
|
|
require_loopback(_req("10.0.0.5"))
|
|
|
|
|
|
# Trusted local networks (OMNIVOICE_TRUSTED_NETWORKS) — issue #1170.
|
|
# A self-hoster can name CIDRs treated as trusted by the CONSUMPTION gates
|
|
# (PIN/API-key/WS), so a LAN or reverse proxy is exempted. Admin gates
|
|
# (require_loopback) stay true-loopback-only — two-tier privilege model.
|
|
|
|
|
|
@pytest.mark.parametrize("host", ["127.0.0.1", "::1", "localhost"])
|
|
def test_is_loopback_true_for_loopback_only(host):
|
|
assert is_loopback(host) is True
|
|
|
|
|
|
@pytest.mark.parametrize("host", ["192.168.1.50", "10.0.0.1", "8.8.8.8"])
|
|
def test_is_loopback_false_for_non_loopback(monkeypatch, host):
|
|
monkeypatch.setenv("OMNIVOICE_TRUSTED_NETWORKS", "192.168.1.0/24")
|
|
assert is_loopback(host) is False # trusted-network ≠ loopback
|
|
|
|
|
|
@pytest.mark.parametrize("host", ["127.0.0.1", "::1", "localhost"])
|
|
def test_is_local_host_loopback_always(monkeypatch, host):
|
|
monkeypatch.delenv("OMNIVOICE_TRUSTED_NETWORKS", raising=False)
|
|
assert is_local_host(host) is True
|
|
|
|
|
|
def test_is_local_host_trusts_configured_cidr(monkeypatch):
|
|
monkeypatch.setenv("OMNIVOICE_TRUSTED_NETWORKS", "192.168.1.0/24,10.0.0.0/8")
|
|
assert is_local_host("192.168.1.50") is True
|
|
assert is_local_host("10.5.5.5") is True
|
|
|
|
|
|
def test_is_local_host_rejects_outside_configured_cidr(monkeypatch):
|
|
monkeypatch.setenv("OMNIVOICE_TRUSTED_NETWORKS", "192.168.1.0/24")
|
|
assert is_local_host("8.8.8.8") is False
|
|
assert is_local_host("192.168.2.1") is False # adjacent subnet
|
|
|
|
|
|
@pytest.mark.parametrize("host", ["192.168.1.5", "example.com"])
|
|
def test_is_local_host_untrusted_without_config(monkeypatch, host):
|
|
# No trust configured → no behavior change vs. the desktop default.
|
|
monkeypatch.delenv("OMNIVOICE_TRUSTED_NETWORKS", raising=False)
|
|
assert is_local_host(host) is False
|
|
|
|
|
|
def test_is_local_host_ignores_malformed_cidr(monkeypatch):
|
|
# A garbage entry is skipped, not fatal — the gate must never wedge.
|
|
monkeypatch.setenv("OMNIVOICE_TRUSTED_NETWORKS", "not-a-cidr,192.168.1.0/24")
|
|
assert is_local_host("192.168.1.5") is True
|
|
assert is_local_host("8.8.8.8") is False
|
|
|
|
|
|
def test_require_loopback_rejects_trusted_network(monkeypatch):
|
|
# Admin gate stays true-loopback-only: a trusted CIDR exempts consumption
|
|
# (PIN/API-key/WS) but NOT admin routes like /system/set-env (RCE-class).
|
|
monkeypatch.setenv("OMNIVOICE_TRUSTED_NETWORKS", "172.16.0.0/12")
|
|
with pytest.raises(HTTPException) as exc:
|
|
require_loopback(_req("172.20.0.9"))
|
|
assert exc.value.status_code == 403
|
|
|
|
|
|
def test_require_loopback_still_rejects_untrusted_non_loopback(monkeypatch):
|
|
monkeypatch.setenv("OMNIVOICE_TRUSTED_NETWORKS", "172.16.0.0/12")
|
|
with pytest.raises(HTTPException) as exc:
|
|
require_loopback(_req("8.8.8.8"))
|
|
assert exc.value.status_code == 403
|
|
|
|
|
|
def test_require_local_allows_trusted_network(monkeypatch):
|
|
# Consumption-tier: a trusted-network client IS exempted (unlike require_loopback).
|
|
monkeypatch.setenv("OMNIVOICE_TRUSTED_NETWORKS", "172.16.0.0/12")
|
|
require_local(_req("172.20.0.9")) # must not raise
|
|
|
|
|
|
def test_require_local_rejects_untrusted_non_loopback(monkeypatch):
|
|
monkeypatch.setenv("OMNIVOICE_TRUSTED_NETWORKS", "172.16.0.0/12")
|
|
with pytest.raises(HTTPException) as exc:
|
|
require_local(_req("8.8.8.8"))
|
|
assert exc.value.status_code == 403
|
|
|
|
|
|
def _req_full(host, *, headers=None, query=None, cookies=None, pin=None):
|
|
"""Richer stub carrying the channels the admin-credential check reads:
|
|
headers, query params, cookies, and app.state.network_share.pin."""
|
|
ns = SimpleNamespace(pin=pin) if pin is not None else None
|
|
app = SimpleNamespace(state=SimpleNamespace(network_share=ns))
|
|
return SimpleNamespace(
|
|
client=SimpleNamespace(host=host) if host else None,
|
|
headers=headers or {},
|
|
query_params=query or {},
|
|
cookies=cookies or {},
|
|
app=app,
|
|
)
|
|
|
|
|
|
# Server mode + trusted network + credential — issue #1213.
|
|
# Regression for the two-tier collapse: with OMNIVOICE_SERVER_MODE=1 the
|
|
# loopback origin is unenforceable, so admin can't require true loopback. But a
|
|
# configured credential (API key / PIN) must still gate admin — a trusted-network
|
|
# client that presents NO credential must NOT reach /system/* or /api/settings/*
|
|
# just because is_local_host exempts it from the consumption middleware.
|
|
|
|
|
|
def test_server_mode_trusted_network_no_credential_reaches_admin(monkeypatch):
|
|
# No credential configured → admin stays open in server mode (the #261
|
|
# Docker flow: operator reaches /system/* off the bridge gateway).
|
|
monkeypatch.setenv("OMNIVOICE_SERVER_MODE", "1")
|
|
monkeypatch.setenv("OMNIVOICE_TRUSTED_NETWORKS", "10.0.0.0/8")
|
|
monkeypatch.delenv("OMNIVOICE_API_KEY", raising=False)
|
|
require_loopback(_req_full("10.1.2.3")) # must not raise
|
|
|
|
|
|
def test_server_mode_trusted_network_blocked_when_api_key_set(monkeypatch):
|
|
# THE FIX: API key set to lock the backend + trusted CIDR for consumption.
|
|
# A trusted-network client with NO key must be 403'd on the admin surface —
|
|
# trusted-network membership is a consumption exemption, never admin.
|
|
monkeypatch.setenv("OMNIVOICE_SERVER_MODE", "1")
|
|
monkeypatch.setenv("OMNIVOICE_TRUSTED_NETWORKS", "10.0.0.0/8")
|
|
monkeypatch.setenv("OMNIVOICE_API_KEY", "s3cret")
|
|
with pytest.raises(HTTPException) as exc:
|
|
require_loopback(_req_full("10.1.2.3"))
|
|
assert exc.value.status_code == 403
|
|
# ...but consumption stays exempt for that same trusted client.
|
|
require_local(_req_full("10.1.2.3")) # must not raise
|
|
|
|
|
|
def test_server_mode_admin_allowed_with_api_key_header(monkeypatch):
|
|
monkeypatch.setenv("OMNIVOICE_SERVER_MODE", "1")
|
|
monkeypatch.setenv("OMNIVOICE_API_KEY", "s3cret")
|
|
require_loopback(
|
|
_req_full("172.17.0.1", headers={"authorization": "Bearer s3cret"})
|
|
) # must not raise
|
|
|
|
|
|
def test_server_mode_admin_allowed_with_api_key_cookie_or_query(monkeypatch):
|
|
monkeypatch.setenv("OMNIVOICE_SERVER_MODE", "1")
|
|
monkeypatch.setenv("OMNIVOICE_API_KEY", "s3cret")
|
|
require_loopback(_req_full("172.17.0.1", cookies={"ov_key": "s3cret"}))
|
|
require_loopback(_req_full("172.17.0.1", query={"api_key": "s3cret"}))
|
|
|
|
|
|
def test_server_mode_admin_rejects_wrong_api_key(monkeypatch):
|
|
monkeypatch.setenv("OMNIVOICE_SERVER_MODE", "1")
|
|
monkeypatch.setenv("OMNIVOICE_API_KEY", "s3cret")
|
|
with pytest.raises(HTTPException) as exc:
|
|
require_loopback(_req_full("172.17.0.1", headers={"authorization": "Bearer nope"}))
|
|
assert exc.value.status_code == 403
|
|
|
|
|
|
def test_server_mode_pin_does_not_unlock_admin(monkeypatch):
|
|
# CodeRabbit #1213: the 6-digit share PIN is a CONSUMPTION credential and is
|
|
# brute-forceable (10^6, no lockout), so it must NEVER gate the RCE-class
|
|
# admin surface. With a PIN set but no API key, admin is still *gated* (not
|
|
# left open) — but only loopback or the long API key can reach it. Presenting
|
|
# even the correct PIN over the network does NOT unlock admin.
|
|
monkeypatch.setenv("OMNIVOICE_SERVER_MODE", "1")
|
|
monkeypatch.setenv("OMNIVOICE_TRUSTED_NETWORKS", "10.0.0.0/8")
|
|
monkeypatch.delenv("OMNIVOICE_API_KEY", raising=False)
|
|
# No PIN presented → 403.
|
|
with pytest.raises(HTTPException):
|
|
require_loopback(_req_full("10.1.2.3", pin="1234"))
|
|
# Correct PIN presented → STILL 403 (the PIN never gates admin).
|
|
with pytest.raises(HTTPException):
|
|
require_loopback(_req_full("10.1.2.3", pin="1234", headers={"x-omnivoice-pin": "1234"}))
|
|
# Loopback admin still needs no credential (the local operator path)…
|
|
require_loopback(_req_full("127.0.0.1", pin="1234"))
|
|
# …and the trusted client keeps its consumption exemption.
|
|
require_local(_req_full("10.1.2.3"))
|
|
|
|
|
|
def test_server_mode_loopback_admin_never_needs_credential(monkeypatch):
|
|
# The local operator on the Docker host (loopback) reaches admin with no
|
|
# credential even when one is configured — the desktop shell path.
|
|
monkeypatch.setenv("OMNIVOICE_SERVER_MODE", "1")
|
|
monkeypatch.setenv("OMNIVOICE_API_KEY", "s3cret")
|
|
require_loopback(_req_full("127.0.0.1")) # must not raise
|
|
|
|
|
|
def test_is_local_host_unwraps_ipv4_mapped_ipv6(monkeypatch):
|
|
# Dual-stack proxies (Caddy, Node.js) pass ::ffff:192.168.1.5 — should
|
|
# match an IPv4 CIDR after unwrapping the mapped address.
|
|
monkeypatch.setenv("OMNIVOICE_TRUSTED_NETWORKS", "192.168.1.0/24")
|
|
assert is_local_host("::ffff:192.168.1.5") is True
|
|
assert is_local_host("::ffff:8.8.8.8") is False
|