mirror of
https://github.com/rommapp/romm.git
synced 2026-06-28 06:46:00 +00:00
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>
160 lines
5.7 KiB
Python
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
|