From 0e6eb89d7d38b0118baf7dd52e82857e1a0966fe Mon Sep 17 00:00:00 2001 From: OKAN TIKTAS Date: Mon, 26 Oct 2026 01:03:00 -0400 Subject: [PATCH] fix(website): classify source badges by URL hostname Compare URL hostnames against known source domains instead of searching for domain text anywhere in an untrusted URL. Cover lookalike hosts, userinfo, query/path text, subdomains and trailing-dot hostnames with regression tests. --- website/build.py | 15 ++++++++++----- website/tests/test_build.py | 26 ++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 5 deletions(-) mode change 100755 => 100644 website/build.py diff --git a/website/build.py b/website/build.py old mode 100755 new mode 100644 index 2185a9c819..d79bcb0a9c --- a/website/build.py +++ b/website/build.py @@ -125,14 +125,19 @@ class SyntheticCategory(TypedDict): def detect_source_type(url: str) -> str | None: """Detect source type from URL domain. Returns None for GitHub URLs.""" - if GITHUB_REPO_URL_RE.match(url): + try: + hostname = urlsplit(url).hostname + except ValueError: + hostname = None + if not hostname: + return "External" + hostname = hostname.rstrip(".") + if hostname == "github.com" or hostname.endswith(".github.com"): return None for domain, source_type in SOURCE_TYPE_DOMAINS.items(): - if domain in url: + if hostname == domain or hostname.endswith(f".{domain}"): return source_type - if "github.com" not in url: - return "External" - return None + return "External" def extract_github_repo(url: str) -> str | None: diff --git a/website/tests/test_build.py b/website/tests/test_build.py index 39b91d3443..5cce67a560 100644 --- a/website/tests/test_build.py +++ b/website/tests/test_build.py @@ -1445,6 +1445,32 @@ def test_non_github_external(self): def test_github_non_repo_returns_none(self): assert detect_source_type("https://github.com/org/repo/wiki") is None + @pytest.mark.parametrize( + "url", + [ + "https://docs.python.org.lookalike.example/3/library/asyncio.html", + "https://gitlab.com.lookalike.example/org/repo", + "https://bitbucket.org@lookalike.example/org/repo", + "https://lookalike.example/?next=https://docs.python.org/3/library/", + "https://github.com.lookalike.example/owner/repo", + "https://lookalike.example/github.com/owner/repo", + ], + ) + def test_lookalike_domains_and_url_text_are_external(self, url): + assert detect_source_type(url) == "External" + + @pytest.mark.parametrize( + ("url", "source_type"), + [ + ("https://docs.python.org/3/library/asyncio.html", "Stdlib"), + ("https://subdomain.gitlab.com/org/repo", "GitLab"), + ("https://bitbucket.org./org/repo", "Bitbucket"), + ("https://gist.github.com/owner/gist", None), + ], + ) + def test_real_domain_and_subdomain_detection(self, url, source_type): + assert detect_source_type(url) == source_type + class TestExtractEntries: def test_basic_extraction(self):