mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 11:12:35 +02:00
feat: Two-Factor Authentication (TOTP, Email OTP) and OIDC/SSO – full implementation with admin UI (#933)
131 lines
4.8 KiB
Python
131 lines
4.8 KiB
Python
"""Unit tests for _get_client_ip (M-R9-A / M-R10-A).
|
|
|
|
Covers:
|
|
- Direct connection without TRUSTED_PROXY_IPS → returns client.host
|
|
- Trusted proxy with XFF → walks right-to-left, returns first non-proxy IP
|
|
- Spoofed XFF from an untrusted client → client.host is returned
|
|
- Multiple trusted proxies in chain → returns leftmost non-proxy entry
|
|
- All XFF entries are trusted proxies → falls back to leftmost
|
|
- Empty XFF header with trusted proxy → returns direct_ip
|
|
- No client (client=None) → returns unique per-request token
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
|
|
def _make_request(client_host: str | None, xff: str = "") -> MagicMock:
|
|
"""Create a minimal mock Request with given client.host and X-Forwarded-For."""
|
|
req = MagicMock()
|
|
if client_host is None:
|
|
req.client = None
|
|
else:
|
|
req.client = MagicMock()
|
|
req.client.host = client_host
|
|
req.headers = MagicMock()
|
|
req.headers.get = lambda key, default="": xff if key == "X-Forwarded-For" else default
|
|
return req
|
|
|
|
|
|
def _call(request, trusted: frozenset[str]) -> str:
|
|
from backend.app.api.routes.auth import _get_client_ip
|
|
|
|
with patch("backend.app.api.routes.auth._TRUSTED_PROXY_IPS", trusted):
|
|
return _get_client_ip(request)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# No proxy configured (TRUSTED_PROXY_IPS empty)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_no_proxy_returns_client_host():
|
|
req = _make_request("1.2.3.4")
|
|
assert _call(req, frozenset()) == "1.2.3.4"
|
|
|
|
|
|
def test_no_proxy_xff_ignored():
|
|
"""XFF must be ignored when TRUSTED_PROXY_IPS is not set."""
|
|
req = _make_request("1.2.3.4", xff="9.9.9.9")
|
|
assert _call(req, frozenset()) == "1.2.3.4"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Trusted proxy present; direct peer is the proxy
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_trusted_proxy_returns_rightmost_non_proxy():
|
|
"""Single proxy: XFF = client_ip; direct_ip = proxy_ip → return client."""
|
|
proxy = "10.0.0.1"
|
|
client = "203.0.113.5"
|
|
req = _make_request(proxy, xff=client)
|
|
assert _call(req, frozenset({proxy})) == client
|
|
|
|
|
|
def test_trusted_proxy_chain_skips_proxy_ips():
|
|
"""Multi-hop: client → proxy1 → proxy2 (direct) → app.
|
|
XFF = 'client, proxy1'; direct = proxy2. Should return client."""
|
|
proxy1 = "10.0.0.1"
|
|
proxy2 = "10.0.0.2"
|
|
client = "198.51.100.7"
|
|
req = _make_request(proxy2, xff=f"{client}, {proxy1}")
|
|
assert _call(req, frozenset({proxy1, proxy2})) == client
|
|
|
|
|
|
def test_all_xff_entries_are_proxies_falls_back_to_leftmost():
|
|
"""When every XFF entry is a trusted proxy, return the leftmost (original) entry."""
|
|
proxy1 = "10.0.0.1"
|
|
proxy2 = "10.0.0.2"
|
|
req = _make_request(proxy2, xff=f"{proxy1}, {proxy2}")
|
|
assert _call(req, frozenset({proxy1, proxy2})) == proxy1
|
|
|
|
|
|
def test_empty_xff_with_trusted_proxy_returns_direct_ip():
|
|
"""Trusted proxy but no XFF header → fall through to direct_ip."""
|
|
proxy = "10.0.0.1"
|
|
req = _make_request(proxy, xff="")
|
|
assert _call(req, frozenset({proxy})) == proxy
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Spoofed XFF from an untrusted client
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_spoofed_xff_from_untrusted_client_ignored():
|
|
"""Client not in TRUSTED_PROXY_IPS → XFF is ignored; client.host returned."""
|
|
untrusted_client = "203.0.113.99"
|
|
req = _make_request(untrusted_client, xff="1.1.1.1")
|
|
assert _call(req, frozenset({"10.0.0.1"})) == untrusted_client
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# No client (transport layer provides no address)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_no_client_returns_unique_token():
|
|
"""When request.client is None, each call returns a unique rate-limit sentinel."""
|
|
req1 = _make_request(None)
|
|
req2 = _make_request(None)
|
|
ip1 = _call(req1, frozenset())
|
|
ip2 = _call(req2, frozenset())
|
|
assert ip1.startswith("__no_ip_")
|
|
assert ip2.startswith("__no_ip_")
|
|
assert ip1 != ip2, "Each missing-client request must get a distinct sentinel"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Whitespace in XFF values
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_xff_with_extra_whitespace_trimmed():
|
|
"""IPs in XFF with leading/trailing spaces are handled correctly."""
|
|
proxy = "10.0.0.1"
|
|
client = "192.0.2.33"
|
|
req = _make_request(proxy, xff=f" {client} , {proxy} ")
|
|
assert _call(req, frozenset({proxy})) == client
|