fix: only treat the base domain and its subdomains as internal - #2109
fix: only treat the base domain and its subdomains as internal#2109eeshsaxena wants to merge 1 commit into
Conversation
is_external_url decided membership with a bare url_domain.endswith(base), so any host that merely ends with the base string was classified as internal: notexample.com and malicious-example.com both counted as part of example.com. base_domain comes from get_base_domain(), so those are genuinely different sites an attacker can register, and the result feeds link classification and the deep-crawl scope check - a crawl scoped to one domain could follow links onto look-alike domains. Match what deep_crawling/filters.py and domain_mapper.py already do: internal means equal to the base domain or a dot-separated subdomain of it. Real subdomains (blog.example.com) and relative URLs are unchanged. The www. strip also moves from .replace("www.", "") to .removeprefix("www."), so a host with www. somewhere other than the front is not silently rewritten. Adds TestIsExternalUrl to tests/regression/test_reg_utils.py covering the base domain, a subdomain, www., the two look-alike cases, the base domain as a prefix, an unrelated domain, a relative URL, and a mailto: link.
eeshsaxena
left a comment
There was a problem hiding this comment.
This closes a real hole. The old url_domain.endswith(base) treated any look-alike suffix as internal, so malicious-example.com and example.com.evil.com both counted as internal to example.com. Switching to url_domain == base or url_domain.endswith(f".{base}") requires either an exact match or a dot-boundary subdomain, which is the correct rule, and moving from .replace("www.", "") (which strips www. anywhere) to .removeprefix("www.") avoids mangling hosts that merely contain www. The test matrix covers the important cases, including the example.com.evil.com prefix trick.
One robustness nit, not a blocker: parsed.netloc.lower().split(":")[0] keeps userinfo, so http://user@evil.com leaves user@evil.com as the compared host. parsed.hostname would strip userinfo and port and lowercase for you, sidestepping that whole class of edge case.
Summary
is_external_urldecided whether a link belongs to the site being crawled with a bare suffix check:Any host whose name merely ends with the base domain string passed that check and was classified as internal:
basecomes fromget_base_domain(), so it is a bare registrable domain and these are genuinely different sites an attacker can register. The result feeds link classification incontent_scraping_strategy.pyand the deep-crawl scope check indomain_mapper.py, so a crawl scoped to one domain could follow links onto look-alike domains.The fix matches what the rest of the codebase already does (
deep_crawling/filters.py,domain_mapper.py): internal means equal to the base domain, or a dot-separated subdomain of it (domain == base or domain.endswith(f".{base}")). Real subdomains (blog.example.com) and relative URLs are unchanged. Thewww.strip also moves from.replace("www.", "")to.removeprefix("www."), so a host containingwww.somewhere other than the front is not silently rewritten.No existing issue filed — happy to open one first if you'd prefer it tracked separately.
List of files changed and why
crawl4ai/utils.py— rewriteis_external_url's membership test fromendswith(base)to== base or endswith("." + base), and switch thewww.strip toremoveprefix.tests/regression/test_reg_utils.py— addTestIsExternalUrlcovering the base domain, a subdomain,www., the two look-alike cases (notexample.com,malicious-example.com), the base domain as a prefix, an unrelated domain, a relative URL, and amailto:link.How Has This Been Tested?
Ran
pytest tests/regression/test_reg_utils.py. Against the current code, the two look-alike cases in the newTestIsExternalUrlfail (demonstrating the bug); with the fix applied the full file passes.Checklist: