R13: Fix X-Forwarded-For trust boundary — only trust from known proxies
- Add _is_trusted_proxy() to check client IP against trusted CIDRs - Only use X-Forwarded-For if connection came from trusted proxy - Add TRUSTED_PROXY_CIDRS config (default: 127.0.0.1/32, ::1/128) - Add parse_comma_separated_lists for env var parsing - Add 3 unit tests: trusted CIDR check, untrusted ignores forwarded, trusted uses forwarded
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
@@ -9,7 +9,7 @@ from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import Base
|
||||
from app.main import _check_rate_limit
|
||||
from app.main import _check_rate_limit, _is_trusted_proxy
|
||||
from app.models import SubmissionAttempt
|
||||
|
||||
|
||||
@@ -34,18 +34,71 @@ def test_rate_limit_is_persistent_and_does_not_store_raw_client() -> None:
|
||||
assert all(item.created_at.replace(tzinfo=timezone.utc) <= datetime.now(timezone.utc) for item in attempts)
|
||||
|
||||
|
||||
def test_rate_limit_uses_forwarded_for_header() -> None:
|
||||
def test_rate_limit_uses_forwarded_for_header_from_trusted_proxy() -> None:
|
||||
"""X-Forwarded-For should be used when client is trusted proxy."""
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as db:
|
||||
mock_real = MagicMock()
|
||||
mock_real.client.host = "10.0.0.1"
|
||||
mock_real.headers.get.return_value = "198.51.100.10"
|
||||
for _ in range(5):
|
||||
_check_rate_limit(mock_real, db)
|
||||
with pytest.raises(HTTPException) as blocked:
|
||||
mock_other = MagicMock()
|
||||
mock_other.client.host = "10.0.0.2"
|
||||
mock_other.headers.get.return_value = "198.51.100.10"
|
||||
_check_rate_limit(mock_other, db)
|
||||
assert blocked.value.status_code == 429
|
||||
with patch("app.main.settings") as mock_settings:
|
||||
mock_settings.rate_limit_secret = "test-secret-for-testing"
|
||||
mock_settings.trusted_proxy_cidrs = ["127.0.0.1/32"]
|
||||
mock_proxy = MagicMock()
|
||||
mock_proxy.client.host = "127.0.0.1"
|
||||
mock_proxy.headers.get.return_value = "198.51.100.10"
|
||||
for _ in range(5):
|
||||
_check_rate_limit(mock_proxy, db)
|
||||
with pytest.raises(HTTPException) as blocked:
|
||||
mock_other = MagicMock()
|
||||
mock_other.client.host = "127.0.0.1"
|
||||
mock_other.headers.get.return_value = "198.51.100.10"
|
||||
_check_rate_limit(mock_other, db)
|
||||
assert blocked.value.status_code == 429
|
||||
|
||||
|
||||
def test_trusted_proxy_checks_cidrs() -> None:
|
||||
assert _is_trusted_proxy("127.0.0.1", ["127.0.0.1/32"]) is True
|
||||
assert _is_trusted_proxy("10.0.0.1", ["10.0.0.0/8"]) is True
|
||||
assert _is_trusted_proxy("192.168.1.1", ["192.168.1.0/24"]) is True
|
||||
assert _is_trusted_proxy("::1", ["::1/128"]) is True
|
||||
assert _is_trusted_proxy("203.0.113.5", ["127.0.0.1/32"]) is False
|
||||
assert _is_trusted_proxy("invalid", []) is False
|
||||
|
||||
|
||||
def test_rate_limit_ignores_forwarded_for_from_untrusted_client() -> None:
|
||||
"""X-Forwarded-For should be ignored when client is not in trusted CIDRs."""
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as db:
|
||||
# Client 203.0.113.42 is NOT trusted by default
|
||||
mock_untrusted = MagicMock()
|
||||
mock_untrusted.client.host = "203.0.113.42"
|
||||
mock_untrusted.headers.get.return_value = "10.0.0.99"
|
||||
for i in range(3):
|
||||
_check_rate_limit(mock_untrusted, db)
|
||||
# Should use real client 203.0.113.42, not forwarded 10.0.0.99
|
||||
# So 3 attempts from 203.0.113.42 should be allowed (limit is 5)
|
||||
mock_different = MagicMock()
|
||||
mock_different.client.host = "203.0.113.42"
|
||||
mock_different.headers.get.return_value = "10.0.0.88"
|
||||
_check_rate_limit(mock_different, db) # Should succeed, not blocked
|
||||
|
||||
|
||||
def test_rate_limit_uses_forwarded_for_from_trusted_proxy() -> None:
|
||||
"""X-Forwarded-For should be used when client IS in trusted CIDRs."""
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as db:
|
||||
# 127.0.0.1 IS trusted by default
|
||||
with patch("app.main.settings") as mock_settings:
|
||||
mock_settings.rate_limit_secret = "test-secret-for-testing"
|
||||
mock_settings.trusted_proxy_cidrs = ["127.0.0.1/32", "::1/128"]
|
||||
mock_proxy = MagicMock()
|
||||
mock_proxy.client.host = "127.0.0.1"
|
||||
mock_proxy.headers.get.return_value = "203.0.113.100"
|
||||
for _ in range(5):
|
||||
_check_rate_limit(mock_proxy, db)
|
||||
# Should use forwarded IP 203.0.113.100, so a different forwarded IP should be allowed
|
||||
mock_other_forwarded = MagicMock()
|
||||
mock_other_forwarded.client.host = "127.0.0.1"
|
||||
mock_other_forwarded.headers.get.return_value = "198.51.100.50"
|
||||
_check_rate_limit(mock_other_forwarded, db) # Should succeed
|
||||
|
||||
Reference in New Issue
Block a user