""" Extended security-focused tests for URLValidator. Covers edge cases and attack vectors beyond the base test suite, with particular emphasis on is_safe_redirect_url bypass attempts and subtle encoding tricks. """ import pytest from local_deep_research.security.url_validator import ( URLValidator, URLValidationError, ) # --------------------------------------------------------------------------- # is_unsafe_scheme -- case & whitespace edge cases # --------------------------------------------------------------------------- class TestIsUnsafeSchemeExtended: """Extended edge-case tests for is_unsafe_scheme.""" @pytest.mark.parametrize( "url", [ "JaVaScRiPt:void(0)", "JAVASCRIPT:alert(1)", "Javascript:alert(document.cookie)", "jAvAsCrIpT:alert('XSS')", ], ) def test_javascript_case_variations(self, url): """All mixed-case variants of javascript: are detected.""" assert URLValidator.is_unsafe_scheme(url) is True @pytest.mark.parametrize( "url", [ "DATA:text/html,

hi

", "Data:text/html;base64,PHNjcmlwdD4=", "VBSCRIPT:MsgBox", "VbScript:Execute", "ABOUT:blank", "About:config", "BLOB:https://evil.com/id", "Blob:http://evil.com/id", "FILE:///etc/shadow", "File:///C:/Windows/System32", ], ) def test_all_unsafe_schemes_case_insensitive(self, url): """Every unsafe scheme is caught regardless of casing.""" assert URLValidator.is_unsafe_scheme(url) is True def test_tab_inside_scheme_not_detected(self): """Tab character inside scheme string is not stripped (raw input).""" # "java\tscript:" does not match "javascript:" after strip+lower assert URLValidator.is_unsafe_scheme("java\tscript:alert(1)") is False def test_scheme_with_leading_whitespace(self): """Leading spaces are stripped before checking.""" assert URLValidator.is_unsafe_scheme(" data:text/html,hi") is True def test_scheme_with_trailing_whitespace(self): """Trailing spaces are stripped before checking.""" assert URLValidator.is_unsafe_scheme("file:///etc/passwd ") is True @pytest.mark.parametrize( "safe_url", [ "http://example.com", "https://example.com", "ftp://ftp.example.com/file", "ftps://secure.example.com/file", "mailto:user@example.com", ], ) def test_safe_schemes_not_flagged(self, safe_url): """Known-safe schemes are never reported as unsafe.""" assert URLValidator.is_unsafe_scheme(safe_url) is False def test_empty_string(self): """Empty string returns False.""" assert URLValidator.is_unsafe_scheme("") is False def test_none_input(self): """None returns False.""" assert URLValidator.is_unsafe_scheme(None) is False def test_plain_text_no_colon(self): """Plain text without colon is not unsafe.""" assert URLValidator.is_unsafe_scheme("just some text") is False # --------------------------------------------------------------------------- # is_safe_url -- comprehensive scheme / option / pattern tests # --------------------------------------------------------------------------- class TestIsSafeUrlExtended: """Extended tests for is_safe_url covering all parameter combos.""" # -- unsafe scheme rejection via is_safe_url -------- @pytest.mark.parametrize( "url", [ "javascript:void(0)", "data:image/png;base64,iVBOR", "vbscript:Run", "about:blank", "blob:null/abc", "file:///etc/hosts", ], ) def test_all_unsafe_schemes_rejected(self, url): """Every unsafe scheme is rejected by is_safe_url.""" assert URLValidator.is_safe_url(url) is False # -- fragment-only URLs -------- def test_fragment_only_allowed_by_default(self): assert URLValidator.is_safe_url("#top") is True def test_fragment_only_disallowed(self): assert URLValidator.is_safe_url("#top", allow_fragments=False) is False def test_fragment_with_complex_id(self): """Fragment with complex anchor name is allowed when fragments on.""" assert URLValidator.is_safe_url("#section-2.3_foo") is True # -- mailto handling -------- def test_mailto_default_rejected(self): assert URLValidator.is_safe_url("mailto:a@b.com") is False def test_mailto_allowed_flag(self): assert ( URLValidator.is_safe_url("mailto:a@b.com", allow_mailto=True) is True ) def test_mailto_with_subject(self): assert ( URLValidator.is_safe_url( "mailto:a@b.com?subject=Hi", allow_mailto=True ) is True ) # -- require_scheme -------- def test_no_scheme_rejected_by_default(self): assert URLValidator.is_safe_url("example.com/page") is False def test_no_scheme_allowed_when_disabled(self): assert ( URLValidator.is_safe_url("example.com/page", require_scheme=False) is True ) # -- trusted_domains filtering -------- def test_trusted_domain_exact(self): assert ( URLValidator.is_safe_url( "https://example.com/p", trusted_domains=["example.com"] ) is True ) def test_trusted_domain_subdomain(self): assert ( URLValidator.is_safe_url( "https://sub.example.com/p", trusted_domains=["example.com"] ) is True ) def test_untrusted_domain_blocked(self): assert ( URLValidator.is_safe_url( "https://evil.com/p", trusted_domains=["example.com"] ) is False ) def test_partial_domain_match_rejected(self): """notexample.com should NOT match trusted domain example.com.""" assert ( URLValidator.is_safe_url( "https://notexample.com/p", trusted_domains=["example.com"] ) is False ) def test_trusted_domains_case_insensitive(self): """Domain matching is case-insensitive.""" assert ( URLValidator.is_safe_url( "https://EXAMPLE.COM/p", trusted_domains=["example.com"] ) is True ) # -- suspicious patterns blocked -------- def test_double_encoded_slash(self): assert URLValidator.is_safe_url("https://x.com/%252F..") is False def test_null_byte_in_path(self): assert URLValidator.is_safe_url("https://x.com/f%00.txt") is False def test_html_entity_amp(self): assert URLValidator.is_safe_url("https://x.com/path&extra") is False def test_html_entity_hex(self): assert ( URLValidator.is_safe_url("https://x.com/pathAextra") is False ) def test_normal_percent_encoding_ok(self): """Standard percent-encoding (%20 for space) is NOT suspicious.""" assert URLValidator.is_safe_url("https://x.com/hello%20world") is True # -- non-string input -------- def test_integer_input(self): assert URLValidator.is_safe_url(12345) is False def test_list_input(self): assert URLValidator.is_safe_url(["https://x.com"]) is False # -- unknown scheme -------- def test_gopher_scheme_rejected(self): assert URLValidator.is_safe_url("gopher://gopher.example.com") is False def test_telnet_scheme_rejected(self): assert URLValidator.is_safe_url("telnet://192.168.1.1") is False # --------------------------------------------------------------------------- # _has_suspicious_patterns -- direct tests on the private helper # --------------------------------------------------------------------------- class TestHasSuspiciousPatternsExtended: """Direct tests on _has_suspicious_patterns for attack payloads.""" def test_double_encoding_percent_25_hex(self): """%2500 is double-encoded null byte.""" assert ( URLValidator._has_suspicious_patterns("https://x.com/%2500") is True ) def test_double_encoding_percent_25_generic(self): """%25XX matches double encoding.""" assert ( URLValidator._has_suspicious_patterns("https://x.com/%252F") is True ) def test_null_byte_percent_00(self): """%00 null byte detected.""" assert ( URLValidator._has_suspicious_patterns("https://x.com/%00") is True ) def test_null_byte_mid_path(self): assert ( URLValidator._has_suspicious_patterns("https://x.com/a%00b") is True ) def test_unicode_escape_lowercase(self): """\\u0041 unicode escape detected.""" assert ( URLValidator._has_suspicious_patterns("https://x.com/path\\u0041") is True ) def test_unicode_escape_uppercase(self): assert ( URLValidator._has_suspicious_patterns("https://x.com/path\\u004F") is True ) def test_html_entity_named(self): """Named HTML entities like & detected.""" assert ( URLValidator._has_suspicious_patterns("https://x.com/&") is True ) def test_html_entity_decimal(self): """Decimal HTML entities like A detected.""" assert ( URLValidator._has_suspicious_patterns("https://x.com/A") is True ) def test_html_entity_hex_entity(self): """Hex HTML entities like A detected.""" assert ( URLValidator._has_suspicious_patterns("https://x.com/A") is True ) def test_html_entity_lt_gt(self): assert ( URLValidator._has_suspicious_patterns( "https://x.com/<script>" ) is True ) def test_clean_url_not_flagged(self): """Ordinary HTTPS URL is not suspicious.""" assert ( URLValidator._has_suspicious_patterns( "https://example.com/path?q=hello&page=2" ) is False ) def test_clean_percent_encoding_not_flagged(self): """Standard percent encoding is clean.""" assert ( URLValidator._has_suspicious_patterns( "https://example.com/hello%20world" ) is False ) # --------------------------------------------------------------------------- # sanitize_url # --------------------------------------------------------------------------- class TestSanitizeUrlExtended: """Extended tests for sanitize_url.""" def test_empty_returns_none(self): assert URLValidator.sanitize_url("") is None def test_none_returns_none(self): assert URLValidator.sanitize_url(None) is None def test_unsafe_javascript_returns_none(self): assert URLValidator.sanitize_url("javascript:void(0)") is None def test_unsafe_data_returns_none(self): assert URLValidator.sanitize_url("data:text/html,hi") is None def test_unsafe_file_returns_none(self): assert URLValidator.sanitize_url("file:///etc/passwd") is None def test_adds_default_https(self): result = URLValidator.sanitize_url("example.com/page") assert result == "https://example.com/page" def test_adds_custom_http_scheme(self): result = URLValidator.sanitize_url("example.com", default_scheme="http") assert result == "http://example.com" def test_preserves_existing_https(self): result = URLValidator.sanitize_url("https://example.com/p") assert result == "https://example.com/p" def test_preserves_existing_http(self): result = URLValidator.sanitize_url("http://example.com/p") assert result == "http://example.com/p" def test_strips_whitespace_then_adds_scheme(self): result = URLValidator.sanitize_url(" example.com ") assert result == "https://example.com" def test_url_with_suspicious_pattern_returns_none(self): """URL with double encoding is rejected after scheme addition.""" result = URLValidator.sanitize_url("example.com/%2500") assert result is None def test_url_with_null_byte_returns_none(self): result = URLValidator.sanitize_url("example.com/path%00") assert result is None def test_url_with_existing_scheme_and_port(self): """URL that already has a scheme and port is preserved.""" result = URLValidator.sanitize_url("https://example.com:8080/api") assert result == "https://example.com:8080/api" def test_bare_host_with_port_returns_none(self): """Bare host:port without scheme is ambiguous for urlparse. urlparse treats 'example.com' as the scheme and '8080/api' as the path, so the resulting URL is invalid and returns None. """ result = URLValidator.sanitize_url("example.com:8080/api") assert result is None # --------------------------------------------------------------------------- # is_academic_url -- every trusted domain + subdomains + negatives # --------------------------------------------------------------------------- class TestIsAcademicUrlExtended: """Extended tests for is_academic_url covering all TRUSTED_ACADEMIC_DOMAINS.""" @pytest.mark.parametrize( "domain", [ "arxiv.org", "pubmed.ncbi.nlm.nih.gov", "ncbi.nlm.nih.gov", "biorxiv.org", "medrxiv.org", "doi.org", "nature.com", "science.org", "sciencedirect.com", "springer.com", "wiley.com", "plos.org", "pnas.org", "ieee.org", "acm.org", ], ) def test_each_trusted_domain_recognized(self, domain): """Each trusted academic domain is recognized.""" assert URLValidator.is_academic_url(f"https://{domain}/article") is True @pytest.mark.parametrize( "domain", [ "arxiv.org", "nature.com", "springer.com", "ieee.org", "acm.org", ], ) def test_www_subdomain_recognized(self, domain): """www. subdomain of each trusted domain is recognized.""" assert ( URLValidator.is_academic_url(f"https://www.{domain}/page") is True ) def test_deep_subdomain_recognized(self): """Deep subdomain of trusted domain is recognized.""" assert ( URLValidator.is_academic_url("https://deep.sub.nature.com/paper") is True ) def test_non_academic_domain(self): assert ( URLValidator.is_academic_url("https://example.com/paper") is False ) def test_empty_url(self): assert URLValidator.is_academic_url("") is False def test_no_hostname(self): """URL with no parseable hostname returns False.""" assert URLValidator.is_academic_url("not-a-url") is False def test_partial_domain_name_not_matched(self): """fakearxiv.org should NOT match arxiv.org.""" assert ( URLValidator.is_academic_url("https://fakearxiv.org/abs/123") is False ) def test_domain_as_path_not_matched(self): """arxiv.org appearing only in path (not hostname) is not academic.""" assert ( URLValidator.is_academic_url("https://evil.com/arxiv.org/abs/123") is False ) # --------------------------------------------------------------------------- # extract_doi # --------------------------------------------------------------------------- class TestExtractDoiExtended: """Extended DOI extraction tests.""" def test_doi_org_standard(self): result = URLValidator.extract_doi("https://doi.org/10.1038/nature12373") assert result == "10.1038/nature12373" def test_direct_doi_pattern(self): result = URLValidator.extract_doi("10.1234/test.paper.v2") assert result is not None assert result.startswith("10.1234/") def test_doi_with_parentheses(self): result = URLValidator.extract_doi("https://doi.org/10.1002/(SICI)1234") assert result is not None assert "10.1002" in result def test_doi_with_semicolons_underscores(self): result = URLValidator.extract_doi("10.1000/journal_name;v2") assert result is not None def test_no_doi_returns_none(self): assert URLValidator.extract_doi("https://example.com/page") is None def test_empty_string_returns_none(self): assert URLValidator.extract_doi("") is None def test_doi_embedded_in_text(self): """DOI embedded in longer text is extracted.""" result = URLValidator.extract_doi( "See paper at doi.org/10.5555/some-article for details" ) assert result is not None assert "10.5555" in result def test_five_digit_registrant(self): """DOI with 5-digit registrant code is extracted.""" result = URLValidator.extract_doi("10.12345/long-registrant") assert result is not None assert result.startswith("10.12345/") # --------------------------------------------------------------------------- # validate_http_url # --------------------------------------------------------------------------- class TestValidateHttpUrlExtended: """Extended tests for validate_http_url strict validation.""" def test_empty_string_raises(self): with pytest.raises(URLValidationError): URLValidator.validate_http_url("") def test_none_raises(self): with pytest.raises(URLValidationError): URLValidator.validate_http_url(None) def test_no_scheme_raises(self): with pytest.raises(URLValidationError, match="(?i)scheme"): URLValidator.validate_http_url("example.com/callback") def test_ftp_scheme_raises(self): with pytest.raises(URLValidationError, match="(?i)http"): URLValidator.validate_http_url("ftp://example.com/file") def test_javascript_scheme_raises(self): with pytest.raises(URLValidationError): URLValidator.validate_http_url("javascript:alert(1)") def test_data_scheme_raises(self): with pytest.raises(URLValidationError): URLValidator.validate_http_url("data:text/html,hi") def test_valid_https_passes(self): assert URLValidator.validate_http_url("https://example.com/cb") is True def test_valid_http_passes(self): assert ( URLValidator.validate_http_url("http://localhost:5000/cb") is True ) def test_missing_netloc_raises(self): with pytest.raises(URLValidationError, match="(?i)hostname"): URLValidator.validate_http_url("http:///path/only") def test_leading_dot_in_netloc_raises(self): with pytest.raises(URLValidationError): URLValidator.validate_http_url("https://.example.com/p") def test_trailing_dot_in_netloc_raises(self): with pytest.raises(URLValidationError): URLValidator.validate_http_url("https://example.com./p") def test_url_with_suspicious_pattern_raises(self): """URL that passes scheme/netloc but has suspicious patterns raises.""" with pytest.raises(URLValidationError): URLValidator.validate_http_url("https://example.com/%2500") def test_integer_input_raises(self): with pytest.raises(URLValidationError): URLValidator.validate_http_url(12345) def test_valid_url_with_path_query_fragment(self): assert ( URLValidator.validate_http_url( "https://example.com/api/v1?key=val#sec" ) is True ) def test_valid_url_with_port(self): assert ( URLValidator.validate_http_url("https://example.com:443/path") is True ) # --------------------------------------------------------------------------- # is_safe_redirect_url -- CRITICAL SECURITY # --------------------------------------------------------------------------- class TestIsSafeRedirectUrlExtended: """ Exhaustive security tests for is_safe_redirect_url. This is the most security-critical method because it prevents open-redirect attacks which can be chained with phishing and OAuth token theft. """ HOST = "http://example.com/" # -- empty / None target -- def test_empty_target_returns_false(self): assert URLValidator.is_safe_redirect_url("", self.HOST) is False def test_none_target_returns_false(self): """None target should return False (caught by 'if not target').""" assert URLValidator.is_safe_redirect_url(None, self.HOST) is False # -- CRLF injection -- def test_crlf_rn(self): assert ( URLValidator.is_safe_redirect_url( "/path\r\nSet-Cookie: evil=1", self.HOST ) is False ) def test_cr_only(self): assert ( URLValidator.is_safe_redirect_url( "/path\rX-Injected: yes", self.HOST ) is False ) def test_lf_only(self): assert ( URLValidator.is_safe_redirect_url( "/path\nX-Injected: yes", self.HOST ) is False ) def test_crlf_at_start(self): assert ( URLValidator.is_safe_redirect_url("\r\n/path", self.HOST) is False ) # -- protocol-relative URL bypass -- def test_double_slash_evil(self): """//evil.com is protocol-relative -- must be blocked.""" assert ( URLValidator.is_safe_redirect_url("//evil.com/path", self.HOST) is False ) def test_triple_slash_evil(self): """///evil.com is another bypass variant.""" assert ( URLValidator.is_safe_redirect_url("///evil.com", self.HOST) is False ) def test_double_slash_with_user_info(self): """//user@evil.com bypass attempt.""" assert ( URLValidator.is_safe_redirect_url("//user@evil.com/path", self.HOST) is False ) # -- backslash bypass -- def test_backslash_evil(self): """\\evil.com treated as /evil.com by some browsers.""" assert ( URLValidator.is_safe_redirect_url("\\evil.com", self.HOST) is False ) def test_double_backslash_evil(self): """\\\\evil.com bypass.""" assert ( URLValidator.is_safe_redirect_url("\\\\evil.com", self.HOST) is False ) def test_backslash_forward_mix(self): """\\/evil.com bypass.""" assert ( URLValidator.is_safe_redirect_url("\\/evil.com", self.HOST) is False ) # -- path traversal -- def test_path_traversal_etc_passwd(self): assert ( URLValidator.is_safe_redirect_url("/../../../etc/passwd", self.HOST) is False ) def test_relative_path_traversal(self): assert URLValidator.is_safe_redirect_url("../admin", self.HOST) is False def test_mid_path_traversal(self): assert ( URLValidator.is_safe_redirect_url("/foo/../bar", self.HOST) is False ) # -- URL-encoded bypass attempts -- def test_encoded_double_slash(self): """%2f%2f should decode to // and be blocked.""" assert ( URLValidator.is_safe_redirect_url("%2f%2fevil.com", self.HOST) is False ) def test_encoded_backslash(self): """%5c is backslash -- blocked after decoding.""" assert ( URLValidator.is_safe_redirect_url("%5cevil.com", self.HOST) is False ) def test_encoded_path_traversal(self): """%2e%2e is '..' -- blocked after decoding.""" assert ( URLValidator.is_safe_redirect_url("%2e%2e/admin", self.HOST) is False ) def test_encoded_crlf(self): """%0d%0a is \\r\\n -- but literal \\r\\n check happens first.""" # The unquote will decode this, then backslash/protocol checks run. # Even if CRLF check on raw string misses it, the decoded form # should still fail at some check. result = URLValidator.is_safe_redirect_url("%0d%0a/path", self.HOST) # Implementation decodes first then checks -- might pass CRLF check # on raw but path traversal or other checks might catch it. Either way, # a safe implementation should block it. If it doesn't, this documents # the behavior. assert isinstance(result, bool) # -- same host redirect (SAFE) -- def test_relative_path_safe(self): assert ( URLValidator.is_safe_redirect_url("/dashboard", self.HOST) is True ) def test_relative_path_with_query(self): assert ( URLValidator.is_safe_redirect_url("/search?q=hello", self.HOST) is True ) def test_same_host_absolute_safe(self): assert ( URLValidator.is_safe_redirect_url( "http://example.com/page", self.HOST ) is True ) def test_same_host_https_absolute_safe(self): host = "https://example.com/" assert ( URLValidator.is_safe_redirect_url("https://example.com/page", host) is True ) def test_relative_nested_path(self): assert URLValidator.is_safe_redirect_url("/a/b/c/d", self.HOST) is True def test_relative_path_with_fragment(self): assert ( URLValidator.is_safe_redirect_url("/page#section", self.HOST) is True ) # -- different host redirect (BLOCKED) -- def test_different_host_blocked(self): assert ( URLValidator.is_safe_redirect_url( "http://evil.com/steal", self.HOST ) is False ) def test_different_host_similar_name_blocked(self): """example.com.evil.com is NOT the same as example.com.""" assert ( URLValidator.is_safe_redirect_url( "http://example.com.evil.com/path", self.HOST ) is False ) def test_subdomain_of_host_blocked(self): """sub.example.com is different netloc from example.com.""" assert ( URLValidator.is_safe_redirect_url( "http://sub.example.com/path", self.HOST ) is False ) # -- edge cases for dotdot in non-path components -- def test_dotdot_in_query_param_not_blocked(self): """Double dots in query string should NOT trigger path traversal.""" assert ( URLValidator.is_safe_redirect_url("/search?q=foo..bar", self.HOST) is True ) def test_dotdot_in_fragment_not_blocked(self): """Double dots in fragment should NOT trigger path traversal.""" assert ( URLValidator.is_safe_redirect_url("/page#section..2", self.HOST) is True ) # -- protocol scheme injection -- def test_javascript_redirect_blocked(self): """javascript: redirect is not http/https so blocked by scheme check.""" assert ( URLValidator.is_safe_redirect_url("javascript:alert(1)", self.HOST) is False ) def test_data_redirect_blocked(self): assert ( URLValidator.is_safe_redirect_url( "data:text/html,

pwned

", self.HOST ) is False ) # -- special characters in safe paths -- def test_path_with_spaces_encoded(self): assert ( URLValidator.is_safe_redirect_url( "/path%20with%20spaces", self.HOST ) is True ) def test_path_with_tilde(self): assert ( URLValidator.is_safe_redirect_url("/~user/page", self.HOST) is True ) def test_root_path(self): assert URLValidator.is_safe_redirect_url("/", self.HOST) is True # -- double-slash path bypass -- def test_double_slash_in_absolute_url_path_blocked(self): """http://host//evil.com/path has same netloc but // path -- blocked.""" assert ( URLValidator.is_safe_redirect_url( "http://example.com//evil.com/path", self.HOST ) is False ) def test_dot_slash_double_slash_blocked(self): """/.//evil.com resolves to //evil.com path -- blocked.""" assert ( URLValidator.is_safe_redirect_url("/.//evil.com", self.HOST) is False ) # -- HTTPS host URL variations -- def test_https_host_with_http_relative(self): """Relative path from HTTPS host stays on same host.""" host = "https://secure.example.com/" assert URLValidator.is_safe_redirect_url("/login", host) is True def test_host_with_port(self): """Host URL with port -- redirect must match same host:port.""" host = "http://example.com:8080/" assert URLValidator.is_safe_redirect_url("/dashboard", host) is True def test_host_with_port_different_port_blocked(self): """Redirect to different port on same hostname is blocked.""" host = "http://example.com:8080/" assert ( URLValidator.is_safe_redirect_url( "http://example.com:9090/path", host ) is False ) # --------------------------------------------------------------------------- # get_safe_redirect_path -- defense-in-depth path extraction # --------------------------------------------------------------------------- class TestGetSafeRedirectPath: """ Tests for get_safe_redirect_path which validates a redirect target and returns only its path+query+fragment, preventing external redirects even if the validator were bypassed. """ HOST = "http://example.com/" # -- unsafe targets return None -- def test_returns_none_for_external_url(self): assert ( URLValidator.get_safe_redirect_path( "http://evil.com/steal", self.HOST ) is None ) def test_returns_none_for_empty_string(self): assert URLValidator.get_safe_redirect_path("", self.HOST) is None def test_returns_none_for_none(self): assert URLValidator.get_safe_redirect_path(None, self.HOST) is None def test_returns_none_for_protocol_relative(self): assert ( URLValidator.get_safe_redirect_path("//evil.com/path", self.HOST) is None ) def test_returns_none_for_path_traversal(self): assert ( URLValidator.get_safe_redirect_path( "/../../../etc/passwd", self.HOST ) is None ) def test_returns_none_for_crlf_injection(self): assert ( URLValidator.get_safe_redirect_path( "/path\r\nSet-Cookie: evil=1", self.HOST ) is None ) def test_returns_none_for_javascript_scheme(self): assert ( URLValidator.get_safe_redirect_path( "javascript:alert(1)", self.HOST ) is None ) def test_double_slash_path_bypass_returns_none(self): """http://host//evil.com/path passes netloc check but has // path.""" assert ( URLValidator.get_safe_redirect_path( "http://example.com//evil.com/path", self.HOST ) is None ) # -- simple relative paths -- def test_relative_path(self): assert ( URLValidator.get_safe_redirect_path("/dashboard", self.HOST) == "/dashboard" ) def test_root_path(self): assert URLValidator.get_safe_redirect_path("/", self.HOST) == "/" def test_nested_path(self): assert ( URLValidator.get_safe_redirect_path("/a/b/c", self.HOST) == "/a/b/c" ) # -- absolute same-host URLs have scheme/host stripped -- def test_absolute_same_host_extracts_path(self): result = URLValidator.get_safe_redirect_path( "http://example.com/dashboard", self.HOST ) assert result == "/dashboard" def test_absolute_same_host_https(self): host = "https://example.com/" result = URLValidator.get_safe_redirect_path( "https://example.com/page", host ) assert result == "/page" def test_absolute_same_host_with_port(self): host = "http://localhost:5000/" result = URLValidator.get_safe_redirect_path( "http://localhost:5000/settings", host ) assert result == "/settings" # -- query string preservation -- def test_preserves_query_string(self): assert ( URLValidator.get_safe_redirect_path("/search?q=hello", self.HOST) == "/search?q=hello" ) def test_preserves_multiple_query_params(self): assert ( URLValidator.get_safe_redirect_path( "/search?q=hello&page=2&sort=date", self.HOST ) == "/search?q=hello&page=2&sort=date" ) def test_absolute_url_preserves_query(self): result = URLValidator.get_safe_redirect_path( "http://example.com/research?id=123&tab=sources", self.HOST ) assert result == "/research?id=123&tab=sources" def test_preserves_encoded_chars_in_query(self): """Percent-encoded characters in query should be preserved as-is.""" assert ( URLValidator.get_safe_redirect_path( "/search?q=hello%26world", self.HOST ) == "/search?q=hello%26world" ) # -- fragment preservation -- def test_preserves_fragment(self): assert ( URLValidator.get_safe_redirect_path("/page#section", self.HOST) == "/page#section" ) def test_preserves_query_and_fragment(self): assert ( URLValidator.get_safe_redirect_path( "/page?tab=info#details", self.HOST ) == "/page?tab=info#details" ) # -- empty path edge cases (the bugs this method fixes) -- def test_absolute_url_no_trailing_slash_returns_root(self): """http://example.com (no path) should return '/' not empty string.""" host = "http://example.com/" result = URLValidator.get_safe_redirect_path("http://example.com", host) assert result == "/" def test_absolute_url_with_trailing_slash(self): result = URLValidator.get_safe_redirect_path( "http://example.com/", self.HOST ) assert result == "/" # -- relative paths without leading slash (resolved via urljoin) -- def test_relative_no_leading_slash_resolved(self): """'dashboard' should resolve to '/dashboard' not stay as 'dashboard'.""" result = URLValidator.get_safe_redirect_path("dashboard", self.HOST) assert result == "/dashboard" def test_dot_relative_resolved(self): """'./dashboard' should resolve to '/dashboard'.""" result = URLValidator.get_safe_redirect_path("./dashboard", self.HOST) assert result == "/dashboard" # -- special characters in paths -- def test_path_with_encoded_spaces(self): assert ( URLValidator.get_safe_redirect_path("/my%20page", self.HOST) == "/my%20page" ) def test_path_with_tilde(self): assert ( URLValidator.get_safe_redirect_path("/~user/page", self.HOST) == "/~user/page" ) def test_path_with_plus_in_query(self): """Plus signs in query strings should be preserved.""" assert ( URLValidator.get_safe_redirect_path( "/search?q=hello+world", self.HOST ) == "/search?q=hello+world" )