test_client_ip.py 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130
  1. """Unit tests for _get_client_ip (M-R9-A / M-R10-A).
  2. Covers:
  3. - Direct connection without TRUSTED_PROXY_IPS → returns client.host
  4. - Trusted proxy with XFF → walks right-to-left, returns first non-proxy IP
  5. - Spoofed XFF from an untrusted client → client.host is returned
  6. - Multiple trusted proxies in chain → returns leftmost non-proxy entry
  7. - All XFF entries are trusted proxies → falls back to leftmost
  8. - Empty XFF header with trusted proxy → returns direct_ip
  9. - No client (client=None) → returns unique per-request token
  10. """
  11. from __future__ import annotations
  12. from unittest.mock import MagicMock, patch
  13. def _make_request(client_host: str | None, xff: str = "") -> MagicMock:
  14. """Create a minimal mock Request with given client.host and X-Forwarded-For."""
  15. req = MagicMock()
  16. if client_host is None:
  17. req.client = None
  18. else:
  19. req.client = MagicMock()
  20. req.client.host = client_host
  21. req.headers = MagicMock()
  22. req.headers.get = lambda key, default="": xff if key == "X-Forwarded-For" else default
  23. return req
  24. def _call(request, trusted: frozenset[str]) -> str:
  25. from backend.app.api.routes.auth import _get_client_ip
  26. with patch("backend.app.api.routes.auth._TRUSTED_PROXY_IPS", trusted):
  27. return _get_client_ip(request)
  28. # ---------------------------------------------------------------------------
  29. # No proxy configured (TRUSTED_PROXY_IPS empty)
  30. # ---------------------------------------------------------------------------
  31. def test_no_proxy_returns_client_host():
  32. req = _make_request("1.2.3.4")
  33. assert _call(req, frozenset()) == "1.2.3.4"
  34. def test_no_proxy_xff_ignored():
  35. """XFF must be ignored when TRUSTED_PROXY_IPS is not set."""
  36. req = _make_request("1.2.3.4", xff="9.9.9.9")
  37. assert _call(req, frozenset()) == "1.2.3.4"
  38. # ---------------------------------------------------------------------------
  39. # Trusted proxy present; direct peer is the proxy
  40. # ---------------------------------------------------------------------------
  41. def test_trusted_proxy_returns_rightmost_non_proxy():
  42. """Single proxy: XFF = client_ip; direct_ip = proxy_ip → return client."""
  43. proxy = "10.0.0.1"
  44. client = "203.0.113.5"
  45. req = _make_request(proxy, xff=client)
  46. assert _call(req, frozenset({proxy})) == client
  47. def test_trusted_proxy_chain_skips_proxy_ips():
  48. """Multi-hop: client → proxy1 → proxy2 (direct) → app.
  49. XFF = 'client, proxy1'; direct = proxy2. Should return client."""
  50. proxy1 = "10.0.0.1"
  51. proxy2 = "10.0.0.2"
  52. client = "198.51.100.7"
  53. req = _make_request(proxy2, xff=f"{client}, {proxy1}")
  54. assert _call(req, frozenset({proxy1, proxy2})) == client
  55. def test_all_xff_entries_are_proxies_falls_back_to_leftmost():
  56. """When every XFF entry is a trusted proxy, return the leftmost (original) entry."""
  57. proxy1 = "10.0.0.1"
  58. proxy2 = "10.0.0.2"
  59. req = _make_request(proxy2, xff=f"{proxy1}, {proxy2}")
  60. assert _call(req, frozenset({proxy1, proxy2})) == proxy1
  61. def test_empty_xff_with_trusted_proxy_returns_direct_ip():
  62. """Trusted proxy but no XFF header → fall through to direct_ip."""
  63. proxy = "10.0.0.1"
  64. req = _make_request(proxy, xff="")
  65. assert _call(req, frozenset({proxy})) == proxy
  66. # ---------------------------------------------------------------------------
  67. # Spoofed XFF from an untrusted client
  68. # ---------------------------------------------------------------------------
  69. def test_spoofed_xff_from_untrusted_client_ignored():
  70. """Client not in TRUSTED_PROXY_IPS → XFF is ignored; client.host returned."""
  71. untrusted_client = "203.0.113.99"
  72. req = _make_request(untrusted_client, xff="1.1.1.1")
  73. assert _call(req, frozenset({"10.0.0.1"})) == untrusted_client
  74. # ---------------------------------------------------------------------------
  75. # No client (transport layer provides no address)
  76. # ---------------------------------------------------------------------------
  77. def test_no_client_returns_unique_token():
  78. """When request.client is None, each call returns a unique rate-limit sentinel."""
  79. req1 = _make_request(None)
  80. req2 = _make_request(None)
  81. ip1 = _call(req1, frozenset())
  82. ip2 = _call(req2, frozenset())
  83. assert ip1.startswith("__no_ip_")
  84. assert ip2.startswith("__no_ip_")
  85. assert ip1 != ip2, "Each missing-client request must get a distinct sentinel"
  86. # ---------------------------------------------------------------------------
  87. # Whitespace in XFF values
  88. # ---------------------------------------------------------------------------
  89. def test_xff_with_extra_whitespace_trimmed():
  90. """IPs in XFF with leading/trailing spaces are handled correctly."""
  91. proxy = "10.0.0.1"
  92. client = "192.0.2.33"
  93. req = _make_request(proxy, xff=f" {client} , {proxy} ")
  94. assert _call(req, frozenset({proxy})) == client