fix(validation): implement W3C IndieAuth compliant client_id validation
Implements complete W3C IndieAuth Section 3.2 client identifier validation including: - Fragment rejection - HTTP scheme support for localhost/loopback only - Username/password component rejection - Non-loopback IP address rejection - Path traversal prevention (.. and . segments) - Hostname case normalization - Default port removal (80/443) - Path component enforcement All 75 validation tests passing with 99% coverage. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ import pytest
|
||||
|
||||
from gondulf.utils.validation import (
|
||||
mask_email,
|
||||
validate_client_id,
|
||||
normalize_client_id,
|
||||
validate_redirect_uri,
|
||||
extract_domain_from_url,
|
||||
@@ -35,6 +36,160 @@ class TestMaskEmail:
|
||||
assert mask_email("") == ""
|
||||
|
||||
|
||||
class TestValidateClientId:
|
||||
"""Tests for validate_client_id function."""
|
||||
|
||||
def test_valid_https_basic(self):
|
||||
"""Test valid basic HTTPS URL."""
|
||||
is_valid, error = validate_client_id("https://example.com")
|
||||
assert is_valid is True
|
||||
assert error == ""
|
||||
|
||||
def test_valid_https_with_path(self):
|
||||
"""Test valid HTTPS URL with path."""
|
||||
is_valid, error = validate_client_id("https://example.com/app")
|
||||
assert is_valid is True
|
||||
assert error == ""
|
||||
|
||||
def test_valid_https_with_trailing_slash(self):
|
||||
"""Test valid HTTPS URL with trailing slash."""
|
||||
is_valid, error = validate_client_id("https://example.com/")
|
||||
assert is_valid is True
|
||||
assert error == ""
|
||||
|
||||
def test_valid_https_with_query(self):
|
||||
"""Test valid HTTPS URL with query string."""
|
||||
is_valid, error = validate_client_id("https://example.com?foo=bar")
|
||||
assert is_valid is True
|
||||
assert error == ""
|
||||
|
||||
def test_valid_https_with_subdomain(self):
|
||||
"""Test valid HTTPS URL with subdomain."""
|
||||
is_valid, error = validate_client_id("https://sub.example.com")
|
||||
assert is_valid is True
|
||||
assert error == ""
|
||||
|
||||
def test_valid_https_with_non_default_port(self):
|
||||
"""Test valid HTTPS URL with non-default port."""
|
||||
is_valid, error = validate_client_id("https://example.com:8080")
|
||||
assert is_valid is True
|
||||
assert error == ""
|
||||
|
||||
def test_valid_http_localhost(self):
|
||||
"""Test valid HTTP URL with localhost."""
|
||||
is_valid, error = validate_client_id("http://localhost")
|
||||
assert is_valid is True
|
||||
assert error == ""
|
||||
|
||||
def test_valid_http_localhost_with_port(self):
|
||||
"""Test valid HTTP URL with localhost and port."""
|
||||
is_valid, error = validate_client_id("http://localhost:3000")
|
||||
assert is_valid is True
|
||||
assert error == ""
|
||||
|
||||
def test_valid_http_127_0_0_1(self):
|
||||
"""Test valid HTTP URL with 127.0.0.1."""
|
||||
is_valid, error = validate_client_id("http://127.0.0.1")
|
||||
assert is_valid is True
|
||||
assert error == ""
|
||||
|
||||
def test_valid_http_127_0_0_1_with_port(self):
|
||||
"""Test valid HTTP URL with 127.0.0.1 and port."""
|
||||
is_valid, error = validate_client_id("http://127.0.0.1:8080")
|
||||
assert is_valid is True
|
||||
assert error == ""
|
||||
|
||||
def test_valid_http_ipv6_loopback(self):
|
||||
"""Test valid HTTP URL with IPv6 loopback."""
|
||||
is_valid, error = validate_client_id("http://[::1]")
|
||||
assert is_valid is True
|
||||
assert error == ""
|
||||
|
||||
def test_valid_http_ipv6_loopback_with_port(self):
|
||||
"""Test valid HTTP URL with IPv6 loopback and port."""
|
||||
is_valid, error = validate_client_id("http://[::1]:8080")
|
||||
assert is_valid is True
|
||||
assert error == ""
|
||||
|
||||
def test_invalid_ftp_scheme(self):
|
||||
"""Test that FTP scheme is rejected."""
|
||||
is_valid, error = validate_client_id("ftp://example.com")
|
||||
assert is_valid is False
|
||||
assert "must use https or http scheme" in error
|
||||
|
||||
def test_invalid_no_scheme(self):
|
||||
"""Test that URL without scheme is rejected."""
|
||||
is_valid, error = validate_client_id("example.com")
|
||||
assert is_valid is False
|
||||
assert "must use https or http scheme" in error
|
||||
|
||||
def test_invalid_fragment(self):
|
||||
"""Test that URL with fragment is rejected."""
|
||||
is_valid, error = validate_client_id("https://example.com#fragment")
|
||||
assert is_valid is False
|
||||
assert "must not contain a fragment" in error
|
||||
|
||||
def test_invalid_username(self):
|
||||
"""Test that URL with username is rejected."""
|
||||
is_valid, error = validate_client_id("https://user@example.com")
|
||||
assert is_valid is False
|
||||
assert "must not contain username or password" in error
|
||||
|
||||
def test_invalid_username_and_password(self):
|
||||
"""Test that URL with username and password is rejected."""
|
||||
is_valid, error = validate_client_id("https://user:pass@example.com")
|
||||
assert is_valid is False
|
||||
assert "must not contain username or password" in error
|
||||
|
||||
def test_invalid_single_dot_path_segment(self):
|
||||
"""Test that URL with single-dot path segment is rejected."""
|
||||
is_valid, error = validate_client_id("https://example.com/./invalid")
|
||||
assert is_valid is False
|
||||
assert "must not contain single-dot (.) or double-dot (..) path segments" in error
|
||||
|
||||
def test_invalid_double_dot_path_segment(self):
|
||||
"""Test that URL with double-dot path segment is rejected."""
|
||||
is_valid, error = validate_client_id("https://example.com/../invalid")
|
||||
assert is_valid is False
|
||||
assert "must not contain single-dot (.) or double-dot (..) path segments" in error
|
||||
|
||||
def test_invalid_http_non_localhost(self):
|
||||
"""Test that HTTP scheme is rejected for non-localhost."""
|
||||
is_valid, error = validate_client_id("http://example.com")
|
||||
assert is_valid is False
|
||||
assert "http scheme is only allowed for localhost" in error
|
||||
|
||||
def test_invalid_non_loopback_ipv4(self):
|
||||
"""Test that non-loopback IPv4 address is rejected."""
|
||||
is_valid, error = validate_client_id("https://192.168.1.1")
|
||||
assert is_valid is False
|
||||
assert "must not use IP address" in error
|
||||
|
||||
def test_invalid_non_loopback_ipv4_private(self):
|
||||
"""Test that private IPv4 address is rejected."""
|
||||
is_valid, error = validate_client_id("https://10.0.0.1")
|
||||
assert is_valid is False
|
||||
assert "must not use IP address" in error
|
||||
|
||||
def test_invalid_non_loopback_ipv6(self):
|
||||
"""Test that non-loopback IPv6 address is rejected."""
|
||||
is_valid, error = validate_client_id("https://[2001:db8::1]")
|
||||
assert is_valid is False
|
||||
assert "must not use IP address" in error
|
||||
|
||||
def test_invalid_empty_string(self):
|
||||
"""Test that empty string is rejected."""
|
||||
is_valid, error = validate_client_id("")
|
||||
assert is_valid is False
|
||||
assert "must be a valid URL" in error or "must use https or http scheme" in error
|
||||
|
||||
def test_invalid_malformed_url(self):
|
||||
"""Test that malformed URL is rejected."""
|
||||
is_valid, error = validate_client_id("not-a-url")
|
||||
assert is_valid is False
|
||||
assert "must use https or http scheme" in error
|
||||
|
||||
|
||||
class TestNormalizeClientId:
|
||||
"""Tests for normalize_client_id function."""
|
||||
|
||||
@@ -42,10 +197,30 @@ class TestNormalizeClientId:
|
||||
"""Test normalizing basic HTTPS URL."""
|
||||
assert normalize_client_id("https://example.com/") == "https://example.com/"
|
||||
|
||||
def test_normalize_remove_default_port(self):
|
||||
def test_normalize_basic_https_no_path(self):
|
||||
"""Test normalizing HTTPS URL without path adds trailing slash."""
|
||||
assert normalize_client_id("https://example.com") == "https://example.com/"
|
||||
|
||||
def test_normalize_uppercase_hostname(self):
|
||||
"""Test normalizing URL with uppercase hostname."""
|
||||
assert normalize_client_id("HTTPS://EXAMPLE.COM") == "https://example.com/"
|
||||
|
||||
def test_normalize_mixed_case_hostname(self):
|
||||
"""Test normalizing URL with mixed case hostname."""
|
||||
assert normalize_client_id("https://Example.Com/app") == "https://example.com/app"
|
||||
|
||||
def test_normalize_preserve_path_case(self):
|
||||
"""Test that path case is preserved."""
|
||||
assert normalize_client_id("https://example.com/APP") == "https://example.com/APP"
|
||||
|
||||
def test_normalize_remove_default_https_port(self):
|
||||
"""Test normalizing URL with default HTTPS port."""
|
||||
assert normalize_client_id("https://example.com:443/") == "https://example.com/"
|
||||
|
||||
def test_normalize_remove_default_http_port(self):
|
||||
"""Test normalizing URL with default HTTP port for localhost."""
|
||||
assert normalize_client_id("http://localhost:80/") == "http://localhost/"
|
||||
|
||||
def test_normalize_preserve_non_default_port(self):
|
||||
"""Test normalizing URL with non-default port."""
|
||||
assert normalize_client_id("https://example.com:8443/") == "https://example.com:8443/"
|
||||
@@ -58,16 +233,60 @@ class TestNormalizeClientId:
|
||||
"""Test normalizing URL with query string."""
|
||||
assert normalize_client_id("https://example.com/?foo=bar") == "https://example.com/?foo=bar"
|
||||
|
||||
def test_normalize_http_scheme_raises_error(self):
|
||||
"""Test that HTTP scheme raises ValueError."""
|
||||
with pytest.raises(ValueError, match="must use https scheme"):
|
||||
def test_normalize_query_without_path(self):
|
||||
"""Test normalizing URL with query but no path."""
|
||||
assert normalize_client_id("https://example.com?foo=bar") == "https://example.com/?foo=bar"
|
||||
|
||||
def test_normalize_http_localhost(self):
|
||||
"""Test normalizing HTTP localhost URL."""
|
||||
assert normalize_client_id("http://localhost") == "http://localhost/"
|
||||
|
||||
def test_normalize_http_localhost_with_port(self):
|
||||
"""Test normalizing HTTP localhost URL with port."""
|
||||
assert normalize_client_id("http://localhost:3000") == "http://localhost:3000/"
|
||||
|
||||
def test_normalize_http_127_0_0_1(self):
|
||||
"""Test normalizing HTTP 127.0.0.1 URL."""
|
||||
assert normalize_client_id("http://127.0.0.1") == "http://127.0.0.1/"
|
||||
|
||||
def test_normalize_http_ipv6_loopback(self):
|
||||
"""Test normalizing HTTP IPv6 loopback URL."""
|
||||
assert normalize_client_id("http://[::1]") == "http://[::1]/"
|
||||
|
||||
def test_normalize_http_ipv6_loopback_with_port(self):
|
||||
"""Test normalizing HTTP IPv6 loopback URL with port."""
|
||||
assert normalize_client_id("http://[::1]:8080") == "http://[::1]:8080/"
|
||||
|
||||
def test_normalize_invalid_http_non_localhost_raises_error(self):
|
||||
"""Test that HTTP non-localhost raises ValueError."""
|
||||
with pytest.raises(ValueError, match="http scheme is only allowed for localhost"):
|
||||
normalize_client_id("http://example.com/")
|
||||
|
||||
def test_normalize_fragment_raises_error(self):
|
||||
"""Test that URL with fragment raises ValueError."""
|
||||
with pytest.raises(ValueError, match="must not contain a fragment"):
|
||||
normalize_client_id("https://example.com#fragment")
|
||||
|
||||
def test_normalize_username_raises_error(self):
|
||||
"""Test that URL with username raises ValueError."""
|
||||
with pytest.raises(ValueError, match="must not contain username or password"):
|
||||
normalize_client_id("https://user@example.com")
|
||||
|
||||
def test_normalize_path_traversal_raises_error(self):
|
||||
"""Test that URL with path traversal raises ValueError."""
|
||||
with pytest.raises(ValueError, match="must not contain single-dot"):
|
||||
normalize_client_id("https://example.com/./app")
|
||||
|
||||
def test_normalize_no_scheme_raises_error(self):
|
||||
"""Test that missing scheme raises ValueError."""
|
||||
with pytest.raises(ValueError, match="must use https scheme"):
|
||||
with pytest.raises(ValueError, match="must use https or http scheme"):
|
||||
normalize_client_id("example.com")
|
||||
|
||||
def test_normalize_invalid_scheme_raises_error(self):
|
||||
"""Test that invalid scheme raises ValueError."""
|
||||
with pytest.raises(ValueError, match="must use https or http scheme"):
|
||||
normalize_client_id("ftp://example.com")
|
||||
|
||||
|
||||
class TestValidateRedirectUri:
|
||||
"""Tests for validate_redirect_uri function."""
|
||||
|
||||
Reference in New Issue
Block a user