Files
romm/backend/tests/utils/test_validation.py
Georges-Antoine Assi 30451d5651 fix(security): move SSRF defense into the HTTP client path
The previous validator did a preflight `socket.getaddrinfo` before each
httpx request. Two problems:

  * DNS rebinding / TOCTOU: httpx re-resolves at connect time, so a
    hostname can answer with a public IP for the validator and a
    private IP for the real request. The preflight check did not
    constrain the connection.
  * Event-loop blocking: `socket.getaddrinfo` is synchronous, and the
    media-download callers are async. Slow resolvers stalled
    unrelated requests.

Replace it with two layers, both wired automatically onto every httpx
client built by `utils.context`:

  1. A request event hook running `validate_url_for_http_request`
     (syntactic checks only: scheme, reserved hostnames, literal IPs,
     internal TLDs). No DNS, no call-site responsibility.
  2. `SSRFProtectedAsyncBackend` / `SSRFProtectedSyncBackend`, custom
     httpcore network backends that resolve the hostname inside
     `connect_tcp`, reject any address in a forbidden range, then
     connect to that *same* validated address. The async variant uses
     `loop.getaddrinfo` so it doesn't block the loop. httpcore calls
     `start_tls(server_hostname=<URL host>)` after `connect_tcp`, so
     TLS SNI and cert verification still use the original hostname
     even though the TCP layer connects by IP.

Drop the explicit `validate_url_for_http_request(...)` calls from
`resources_handler.py` — the event hook covers them. Consolidate the
URL validator and its tests under `utils/ssrf.py` /
`tests/utils/test_ssrf.py` so the SSRF surface lives in one module.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 17:58:14 -04:00

160 lines
5.7 KiB
Python

"""Tests for validation utilities."""
import pytest
from utils.validation import (
ValidationError,
validate_ascii_only,
validate_email,
validate_password,
validate_username,
)
class TestValidateAsciiOnly:
"""Test ASCII-only validation."""
def test_valid_ascii_string(self):
"""Test that valid ASCII strings pass validation."""
validate_ascii_only("hello123", "test_field")
validate_ascii_only("user_name", "test_field")
validate_ascii_only("test@example.com", "test_field")
def test_invalid_non_ascii_string(self):
"""Test that non-ASCII strings fail validation."""
with pytest.raises(ValidationError) as exc_info:
validate_ascii_only("café", "test_field")
assert "ASCII characters" in exc_info.value.message
with pytest.raises(ValidationError) as exc_info:
validate_ascii_only("naïve", "test_field")
assert "ASCII characters" in exc_info.value.message
with pytest.raises(ValidationError) as exc_info:
validate_ascii_only("résumé", "test_field")
assert "ASCII characters" in exc_info.value.message
def test_empty_string(self):
"""Test that empty strings pass validation."""
validate_ascii_only("", "test_field")
class TestValidateUsername:
"""Test username validation."""
def test_valid_usernames(self):
"""Test that valid usernames pass validation."""
validate_username("user123")
validate_username("test_user")
validate_username("admin")
validate_username("user-name")
def test_invalid_empty_username(self):
"""Test that empty usernames fail validation."""
with pytest.raises(ValidationError) as exc_info:
validate_username("")
assert "cannot be empty" in exc_info.value.message
with pytest.raises(ValidationError) as exc_info:
validate_username(" ")
assert True
def test_invalid_short_username(self):
"""Test that short usernames fail validation."""
with pytest.raises(ValidationError) as exc_info:
validate_username("ab")
assert "at least 3 characters" in exc_info.value.message
def test_invalid_long_username(self):
"""Test that long usernames fail validation."""
long_username = "a" * 256
with pytest.raises(ValidationError) as exc_info:
validate_username(long_username)
assert "no more than 255 characters" in exc_info.value.message
def test_invalid_characters_username(self):
"""Test that usernames with invalid characters fail validation."""
with pytest.raises(ValidationError) as exc_info:
validate_username("user@domain")
assert "letters, numbers, underscores, and hyphens" in exc_info.value.message
with pytest.raises(ValidationError) as exc_info:
validate_username("user.name")
assert True
def test_invalid_non_ascii_username(self):
"""Test that usernames with non-ASCII characters fail validation."""
with pytest.raises(ValidationError) as exc_info:
validate_username("naïve")
assert "ASCII characters" in exc_info.value.message
with pytest.raises(ValidationError) as exc_info:
validate_username("résumé")
assert True
class TestValidatePassword:
"""Test password validation."""
def test_valid_passwords(self):
"""Test that valid passwords pass validation."""
validate_password("password123")
validate_password("my_secret_password")
validate_password("admin123")
def test_invalid_empty_password(self):
"""Test that empty passwords fail validation."""
with pytest.raises(ValidationError) as exc_info:
validate_password("")
assert "cannot be empty" in exc_info.value.message
with pytest.raises(ValidationError) as exc_info:
validate_password(" ")
assert True
def test_invalid_short_password(self):
"""Test that short passwords fail validation."""
with pytest.raises(ValidationError) as exc_info:
validate_password("12345")
assert "at least 6 characters" in exc_info.value.message
def test_invalid_non_ascii_password(self):
"""Test that passwords with non-ASCII characters fail validation."""
with pytest.raises(ValidationError) as exc_info:
validate_password("résumé")
assert "ASCII characters" in exc_info.value.message
class TestValidateEmail:
"""Test email validation."""
def test_valid_emails(self):
"""Test that valid emails pass validation."""
validate_email("user@example.com")
validate_email("test.user@domain.org")
validate_email("admin@company.co.uk")
def test_empty_email(self):
"""Test that empty emails pass validation (email is optional)."""
validate_email("")
def test_invalid_email_format(self):
"""Test that invalid email formats fail validation."""
with pytest.raises(ValidationError) as exc_info:
validate_email("invalid-email")
assert "Invalid email format" in exc_info.value.message
with pytest.raises(ValidationError) as exc_info:
validate_email("user@")
assert True
with pytest.raises(ValidationError) as exc_info:
validate_email("@domain.com")
assert True
def test_invalid_non_ascii_email(self):
"""Test that emails with non-ASCII characters fail validation."""
with pytest.raises(ValidationError) as exc_info:
validate_email("résumé@example.com")
assert "ASCII characters" in exc_info.value.message