Files
wehub-resource-sync 7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

1076 lines
36 KiB
Python

"""
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,<h1>hi</h1>",
"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&amp;extra") is False
def test_html_entity_hex(self):
assert (
URLValidator.is_safe_url("https://x.com/path&#x41;extra") 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 &amp; detected."""
assert (
URLValidator._has_suspicious_patterns("https://x.com/&amp;") is True
)
def test_html_entity_decimal(self):
"""Decimal HTML entities like &#65; detected."""
assert (
URLValidator._has_suspicious_patterns("https://x.com/&#65;") is True
)
def test_html_entity_hex_entity(self):
"""Hex HTML entities like &#x41; detected."""
assert (
URLValidator._has_suspicious_patterns("https://x.com/&#x41;")
is True
)
def test_html_entity_lt_gt(self):
assert (
URLValidator._has_suspicious_patterns(
"https://x.com/&lt;script&gt;"
)
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,<h1>pwned</h1>", 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"
)