From ff77c11241178ca5c23e5d8570b196bc43164e1e Mon Sep 17 00:00:00 2001 From: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:23:48 -0400 Subject: [PATCH 1/2] fix(leads): analyze a clean hostname in the domain-intel endpoint POST /api/leads/domain-intel passed the caller's text to RDAP, Cloudflare DoH and crt.sh almost as typed: - a non-string domain raised AttributeError (HTTP 500); - replace("www.", "") removed a "www." anywhere in the name, so awww.example.com was analyzed as aexample.com; - the name was not lowercased, but crt.sh results are, so Example.com always reported zero subdomains; - a path, port, query, space or "&exclude=..." went straight into the RDAP and crt.sh request URLs. Add clean_domain(): take the hostname of a domain or URL, lowercase it, drop a trailing dot and a leading www. only, convert IDNs to punycode and accept only a valid hostname. The route returns 400 for anything else. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com> --- apps/api/routers/leads.py | 15 ++-- .../leadgen/enrichment/domain_intel.py | 32 ++++++++ tests/test_domain_intel_route.py | 81 +++++++++++++++++++ 3 files changed, 120 insertions(+), 8 deletions(-) create mode 100644 tests/test_domain_intel_route.py diff --git a/apps/api/routers/leads.py b/apps/api/routers/leads.py index 9e5ae266..e97d6d9e 100644 --- a/apps/api/routers/leads.py +++ b/apps/api/routers/leads.py @@ -1399,15 +1399,14 @@ async def tech_stack_endpoint( @router.post("/leads/domain-intel") async def domain_intelligence(body: dict, ctx: WorkspaceCtx = Depends(require_editor)): """Analyze a domain — RDAP registration, DNS, hosting, email provider, legitimacy score.""" - domain = body.get("domain", "").strip() - if not domain: - raise HTTPException(400, "domain is required") + from apps.api.services.leadgen.enrichment.domain_intel import clean_domain - # Strip protocol/path if provided as URL - if "://" in domain: - from urllib.parse import urlparse - domain = urlparse(domain).hostname or domain - domain = domain.replace("www.", "").strip("/") + raw_domain = body.get("domain") + if not isinstance(raw_domain, str) or not raw_domain.strip(): + raise HTTPException(400, "domain is required") + domain = clean_domain(raw_domain) + if not domain: + raise HTTPException(400, "domain must be a valid domain name or URL") from apps.api.services.leadgen.enrichment.domain_intel import analyze_domain result = await analyze_domain(domain) diff --git a/apps/api/services/leadgen/enrichment/domain_intel.py b/apps/api/services/leadgen/enrichment/domain_intel.py index 06ab4af7..54068f6e 100644 --- a/apps/api/services/leadgen/enrichment/domain_intel.py +++ b/apps/api/services/leadgen/enrichment/domain_intel.py @@ -14,8 +14,10 @@ import asyncio import logging +import re from typing import Dict, Optional from datetime import datetime +from urllib.parse import urlparse logger = logging.getLogger("leadgen.domain_intel") @@ -56,6 +58,36 @@ } +_HOSTNAME = re.compile( + r"^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?" + r"(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$" +) + + +def clean_domain(value: object) -> str: + """The bare, lowercase hostname of a domain or URL, or "" when it is not one. + + The result goes into RDAP and crt.sh request URLs and is compared with + lowercase certificate names, so it must not carry a scheme, path, query, + port, credentials, case, a trailing dot or a leading ``www.``. Only that + prefix is removed, never a ``www.`` elsewhere in the name. + """ + if not isinstance(value, str): + return "" + text = value.strip() + if not text: + return "" + host = urlparse(text if "://" in text else "//" + text).hostname or "" + host = host.strip(".").lower() + if host.startswith("www."): + host = host[4:] + try: + host = host.encode("idna").decode("ascii") # bücher.de -> xn--bcher-kva.de + except UnicodeError: + return "" + return host if _HOSTNAME.match(host) else "" + + async def get_rdap_info(domain: str) -> Dict: """Query RDAP for domain registration data (free, no auth).""" import aiohttp diff --git a/tests/test_domain_intel_route.py b/tests/test_domain_intel_route.py new file mode 100644 index 00000000..1d7b8614 --- /dev/null +++ b/tests/test_domain_intel_route.py @@ -0,0 +1,81 @@ +"""POST /api/leads/domain-intel analyzes a clean, lowercase hostname.""" + +from types import SimpleNamespace + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from apps.api.routers import leads +from apps.api.services.leadgen.enrichment import domain_intel +from apps.api.services.leadgen.enrichment.domain_intel import clean_domain + + +@pytest.fixture +def analyzed(monkeypatch): + seen = [] + + async def fake_analyze(domain): + seen.append(domain) + return {"domain": domain} + + monkeypatch.setattr(domain_intel, "analyze_domain", fake_analyze) + return seen + + +@pytest.fixture +def client(): + ctx = SimpleNamespace(workspace_id="ws-fixture") + app = FastAPI() + app.include_router(leads.router) + app.dependency_overrides[leads.require_editor] = lambda: ctx + with TestClient(app) as http: + yield http + + +@pytest.mark.parametrize("raw,expected", [ + ("example.com", "example.com"), + ("Example.COM", "example.com"), + (" www.Example.com. ", "example.com"), + ("https://WWW.Example.com:8443/pricing?x=1#top", "example.com"), + ("example.com/careers", "example.com"), + ("user:pw@example.com", "example.com"), + # Only a leading www. goes; a www. inside the name is part of it. + ("awww.example.com", "awww.example.com"), + ("blog.www.example.com", "blog.www.example.com"), + ("wwwexample.com", "wwwexample.com"), + ("bücher.de", "xn--bcher-kva.de"), +]) +def test_clean_domain_keeps_only_the_hostname(raw, expected): + assert clean_domain(raw) == expected + + +@pytest.mark.parametrize("raw", [ + "", " ", "localhost", "exa mple.com", "example.com&exclude=none", + "example..com", "-example.com", "https://", 123, None, ["example.com"], +]) +def test_clean_domain_rejects_what_is_not_a_hostname(raw): + assert clean_domain(raw) == "" + + +def test_route_analyzes_the_cleaned_domain(client, analyzed): + response = client.post("/api/leads/domain-intel", json={"domain": "https://WWW.Example.com/about"}) + + assert response.status_code == 200, response.text + assert analyzed == ["example.com"] + + +def test_route_keeps_a_www_inside_the_name(client, analyzed): + assert client.post("/api/leads/domain-intel", json={"domain": "awww.example.com"}).status_code == 200 + assert analyzed == ["awww.example.com"] + + +@pytest.mark.parametrize("body", [ + {}, {"domain": ""}, {"domain": 42}, {"domain": ["a.com"]}, + {"domain": "example.com&exclude=none"}, {"domain": "not a domain"}, +]) +def test_route_rejects_input_that_is_not_a_domain(client, analyzed, body): + response = client.post("/api/leads/domain-intel", json=body) + + assert response.status_code == 400, response.text + assert analyzed == [] From 4f8cbf16604816b6562c47ff5e58d22fad979be5 Mon Sep 17 00:00:00 2001 From: Palash Debnath <4178343+debpalash@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:09:36 +0530 Subject: [PATCH 2/2] fix(leads): reject malformed domain URLs without server errors Catch ValueError from URL parsing and hostname extraction before domain analysis. Preserve protocol-relative URLs instead of adding a second // prefix. Add direct and route regressions for malformed brackets, NFKC netloc separators, and protocol-relative input. Signed-off-by: Palash Debnath <4178343+debpalash@users.noreply.github.com> --- .../leadgen/enrichment/domain_intel.py | 6 +++- tests/test_domain_intel_route.py | 35 +++++++++++++++++-- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/apps/api/services/leadgen/enrichment/domain_intel.py b/apps/api/services/leadgen/enrichment/domain_intel.py index 54068f6e..ec0deabf 100644 --- a/apps/api/services/leadgen/enrichment/domain_intel.py +++ b/apps/api/services/leadgen/enrichment/domain_intel.py @@ -77,7 +77,11 @@ def clean_domain(value: object) -> str: text = value.strip() if not text: return "" - host = urlparse(text if "://" in text else "//" + text).hostname or "" + try: + url = text if "://" in text or text.startswith("//") else "//" + text + host = urlparse(url).hostname or "" + except ValueError: + return "" host = host.strip(".").lower() if host.startswith("www."): host = host[4:] diff --git a/tests/test_domain_intel_route.py b/tests/test_domain_intel_route.py index 1d7b8614..807a5d33 100644 --- a/tests/test_domain_intel_route.py +++ b/tests/test_domain_intel_route.py @@ -11,6 +11,17 @@ from apps.api.services.leadgen.enrichment.domain_intel import clean_domain +_MALFORMED_URLS = [ + "https://[", + "[example.com", + "https://example.com]", + "//[not-an-ip]", + "https://example.com\uff0fpath", + "example.com\uff1a443", + "//example.com\uff20evil.com", +] + + @pytest.fixture def analyzed(monkeypatch): seen = [] @@ -36,6 +47,8 @@ def client(): @pytest.mark.parametrize("raw,expected", [ ("example.com", "example.com"), ("Example.COM", "example.com"), + ("//example.com", "example.com"), + ("//WWW.Example.com:8443/pricing?x=1#top", "example.com"), (" www.Example.com. ", "example.com"), ("https://WWW.Example.com:8443/pricing?x=1#top", "example.com"), ("example.com/careers", "example.com"), @@ -58,8 +71,13 @@ def test_clean_domain_rejects_what_is_not_a_hostname(raw): assert clean_domain(raw) == "" -def test_route_analyzes_the_cleaned_domain(client, analyzed): - response = client.post("/api/leads/domain-intel", json={"domain": "https://WWW.Example.com/about"}) +@pytest.mark.parametrize("raw", [ + "https://WWW.Example.com/about", + "//example.com", + "//WWW.Example.com:8443/pricing?x=1#top", +]) +def test_route_analyzes_the_cleaned_domain(client, analyzed, raw): + response = client.post("/api/leads/domain-intel", json={"domain": raw}) assert response.status_code == 200, response.text assert analyzed == ["example.com"] @@ -79,3 +97,16 @@ def test_route_rejects_input_that_is_not_a_domain(client, analyzed, body): assert response.status_code == 400, response.text assert analyzed == [] + + +@pytest.mark.parametrize("raw", _MALFORMED_URLS) +def test_clean_domain_rejects_malformed_urls_without_raising(raw): + assert clean_domain(raw) == "" + + +@pytest.mark.parametrize("raw", _MALFORMED_URLS) +def test_route_rejects_malformed_urls_before_analysis(client, analyzed, raw): + response = client.post("/api/leads/domain-intel", json={"domain": raw}) + + assert response.status_code == 400, response.text + assert analyzed == []