add ssrf-safe website analysis

This commit is contained in:
Marco0300
2026-09-03 11:07:34 +02:00
parent f1efe39de4
commit fb89a28f2c
13 changed files with 454 additions and 15 deletions
+49
View File
@@ -10,10 +10,12 @@ if __package__ in (None, ""):
from app.domain import deduplication_key, deduplicate_businesses, is_suppressed, normalize_business, score_business, normalize_domain, normalize_phone, match_businesses
from app.sources import adapter_for, contains_secret
from app.domain_intelligence import normalize_registrable_domain, resolve_domain, generate_candidate_domains
from app.website_scanner import scan_website, validate_url
else:
from .domain import deduplication_key, deduplicate_businesses, is_suppressed, normalize_business, score_business, normalize_domain, normalize_phone, match_businesses
from .sources import adapter_for, contains_secret
from .domain_intelligence import normalize_registrable_domain, resolve_domain, generate_candidate_domains
from .website_scanner import scan_website, validate_url
ORGANIZATION_ID = "demo-tenant"
SCHEMA = Path(__file__).resolve().parents[1] / "schema.sql"
SESSION_DAYS = 7
@@ -21,6 +23,8 @@ PBKDF2_ITERATIONS = 300_000
MUTATING_ROLES = {"owner", "admin", "researcher"}
JOB_TYPES = {"noop", "prospect_recalculate", "source_discovery", "domain_check"}
JOB_PAGE_SIZE = 100
WEBSITE_SCAN_PAGE_SIZE = 100
WEBSITE_SCAN_CACHE_SECONDS = 3600
SECRET_KEYS = {"password", "passwd", "secret", "token", "api_key", "apikey", "authorization", "credential", "private_key"}
CHILD_TABLES = {"contacts": ("name", "email", "phone", "title", "do_not_contact"), "domains": ("domain", "kind"), "websites": ("url", "website_class"), "evidence": ("kind", "url", "claim"), "notes": ("body",)}
@@ -120,12 +124,14 @@ class ApiHandler(BaseHTTPRequestHandler):
if path=="/api/v1/source-records": return self.list_source_records(db,org,parse_qs(parsed.query))
if path=="/api/v1/jobs": return self.list_jobs(db,org,parse_qs(parsed.query))
if path=="/api/v1/domain-checks": return self.list_domain_checks(db,org,parse_qs(parsed.query))
if path=="/api/v1/website-scans": return self.list_website_scans(db,org,parse_qs(parsed.query))
if path.startswith("/api/v1/jobs/"): return self.get_job_route(db,org,path,parse_qs(parsed.query))
if path.startswith("/api/v1/businesses/"):
bits=path.split("/"); ident=bits[4] if len(bits)>4 else ""
if not ident.isdigit(): return self.send_json(404,{"error":"not_found"})
row=self.business(db,int(ident),org)
if not row:return self.send_json(404,{"error":"not_found"})
if len(bits)==7 and bits[5:]==["websites","scan"]: return self.get_latest_website_scan(int(ident),db,user)
if len(bits)==7 and bits[5:]==["domains","check"]: return self.get_domain_check(int(ident),db,user,parse_qs(parsed.query))
if len(bits)==6 and bits[5]=="domain-candidates": return self.list_domain_candidates(int(ident),db,org)
if len(bits)==6 and bits[5]=="matches": return self.matches(int(ident),db,org)
@@ -173,6 +179,48 @@ class ApiHandler(BaseHTTPRequestHandler):
rows=db.execute("SELECT * FROM domain_checks WHERE organization_id=? ORDER BY id DESC LIMIT ? OFFSET ?",(org,limit+1,offset)).fetchall()
return self.send_json(200,{"organization_id":org,"items":[self._domain_result(r) for r in rows[:limit]],"limit":limit,"offset":offset,"has_more":len(rows)>limit})
def _website_scan_result(self, row, cache_hit=False):
result = json.loads(row["result_json"] or "{}")
result.update({"id": row["id"], "business_id": row["business_id"], "website_id": row["website_id"], "input_url": row["input_url"], "classification": row["classification"], "scanned_at": row["scanned_at"], "cache_expires_at": row["cache_expires_at"], "cache_hit": cache_hit})
return result
def list_website_scans(self, db, org, query):
try:
limit = int((query.get("page_size") or [50])[0]); offset = max(0, int((query.get("offset") or [0])[0]))
if limit < 1 or limit > WEBSITE_SCAN_PAGE_SIZE: raise ValueError
except (ValueError, TypeError): return self.send_json(400, {"error": "invalid_pagination"})
params = [org]; where = ["organization_id=?"]
if (query.get("business_id") or [""])[0].isdigit(): where.append("business_id=?"); params.append(int(query["business_id"][0]))
rows = db.execute("SELECT * FROM website_scans WHERE " + " AND ".join(where) + " ORDER BY id DESC LIMIT ? OFFSET ?", params + [limit + 1, offset]).fetchall()
return self.send_json(200, {"organization_id": org, "items": [self._website_scan_result(r) for r in rows[:limit]], "limit": limit, "offset": offset, "has_more": len(rows) > limit})
def get_latest_website_scan(self, bid, db, user):
row = db.execute("SELECT * FROM website_scans WHERE business_id=? AND organization_id=? ORDER BY id DESC LIMIT 1", (bid, user["organization_id"])).fetchone()
if not row: return self.send_json(404, {"error": "scan_not_found"})
return self.send_json(200, self._website_scan_result(row, False))
def scan_business_website(self, bid, payload, db, user):
org = user["organization_id"]; business = self.business(db, bid, org)
if not business: return self.send_json(404, {"error": "not_found"})
requested = str(payload.get("url", "")).strip() if isinstance(payload, dict) else ""
if not requested:
child = db.execute("SELECT * FROM websites WHERE business_id=? AND organization_id=? ORDER BY id LIMIT 1", (bid, org)).fetchone()
requested = (child["url"] if child else business["website"]) or ""
try: safe_url = validate_url(requested)
except ValueError as exc:
self.audit(db, user, "website.scan.rejected", f"{bid}:{str(exc)}"); db.commit()
return self.send_json(400, {"error": "unsafe_url", "reason": str(exc)})
website = db.execute("SELECT id FROM websites WHERE business_id=? AND organization_id=? AND url=? ORDER BY id LIMIT 1", (bid, org, safe_url)).fetchone()
cache_key = hashlib.sha256(safe_url.encode()).hexdigest(); now = datetime.now(timezone.utc).replace(microsecond=0); expires = now + timedelta(seconds=WEBSITE_SCAN_CACHE_SECONDS)
cached = db.execute("SELECT * FROM website_scans WHERE organization_id=? AND business_id=? AND cache_key=? AND cache_expires_at>? ORDER BY id DESC LIMIT 1", (org, bid, cache_key, now.isoformat())).fetchone()
if cached:
self.audit(db, user, "website.scan.cache_hit", f"{bid}:{safe_url}"); db.commit()
return self.send_json(200, self._website_scan_result(cached, True))
result = scan_website(safe_url); result["business_id"] = bid
cur = db.execute("INSERT INTO website_scans(organization_id,business_id,website_id,input_url,classification,result_json,cache_key,scanned_at,cache_expires_at) VALUES(?,?,?,?,?,?,?,?,?)", (org, bid, website["id"] if website else None, safe_url, result["classification"], json.dumps(result, sort_keys=True), cache_key, now.isoformat(), expires.isoformat()))
self.audit(db, user, "website.scanned", f"{bid}:{result['classification']}"); db.commit()
return self.send_json(201, self._website_scan_result(db.execute("SELECT * FROM website_scans WHERE id=?", (cur.lastrowid,)).fetchone()))
def list_domain_candidates(self,bid,db,org):
business=self.business(db,bid,org)
if not business:return self.send_json(404,{"error":"not_found"})
@@ -286,6 +334,7 @@ class ApiHandler(BaseHTTPRequestHandler):
if path=="/api/v1/discovery-queries":return self.create_query(payload,db,user)
if path=="/api/v1/suppressions":return self.create_suppression(payload,db,user)
if path=="/api/v1/imports/preview":return self.preview_import(payload,db,org)
if len(path.split("/"))==7 and path.split("/")[3:6]==["businesses",path.split("/")[4],"websites"] and path.split("/")[6]=="scan": return self.scan_business_website(int(path.split("/")[4]) if path.split("/")[4].isdigit() else -1,payload,db,user)
if len(path.split("/"))==7 and path.split("/")[3:6]==["businesses",path.split("/")[4],"domains"] and path.split("/")[6]=="check": return self.post_domain_check(int(path.split("/")[4]) if path.split("/")[4].isdigit() else -1,payload,db,user)
if len(path.split("/"))==7 and path.split("/")[3:6]==["businesses",path.split("/")[4],"domain-candidates"] and path.split("/")[6]=="check-availability": return self.check_availability(int(path.split("/")[4]) if path.split("/")[4].isdigit() else -1,payload,db,user)
if path.startswith("/api/v1/merge-history/") and path.endswith("/reverse"):
+180
View File
@@ -0,0 +1,180 @@
"""Bounded, passive and SSRF-safe website analysis using only the stdlib."""
from __future__ import annotations
import html
import ipaddress
import re
import socket
import ssl
import time
from html.parser import HTMLParser
from urllib.parse import urljoin, urlparse
from urllib.error import HTTPError
from urllib.request import HTTPRedirectHandler, Request, build_opener
MAX_BYTES = 512 * 1024
MAX_REDIRECTS = 5
MAX_PAGES = 1
DEFAULT_TIMEOUT = 5.0
_METADATA_IPS = {ipaddress.ip_address("169.254.169.254"), ipaddress.ip_address("100.100.100.200")}
def _resolved_addresses(host: str, timeout: float) -> list[str]:
try:
socket.setdefaulttimeout(timeout)
records = socket.getaddrinfo(host, None, type=socket.SOCK_STREAM)
except (OSError, socket.gaierror) as exc:
raise ValueError("dns_failure") from exc
addresses = sorted({str(r[4][0]) for r in records if len(r) > 4})
if not addresses:
raise ValueError("dns_failure")
for value in addresses:
try:
ip = ipaddress.ip_address(value)
except ValueError as exc:
raise ValueError("unsafe_address") from exc
if ip in _METADATA_IPS or not ip.is_global or ip.is_loopback or ip.is_private or ip.is_link_local or ip.is_multicast or ip.is_reserved or ip.is_unspecified:
raise ValueError("unsafe_address")
return addresses
def validate_url(value: str, *, timeout: float = DEFAULT_TIMEOUT) -> str:
raw = str(value or "").strip()
parsed = urlparse(raw)
if parsed.scheme.lower() not in {"http", "https"} or not parsed.hostname or parsed.username or parsed.password:
raise ValueError("invalid_url")
if parsed.fragment:
raw = raw.split("#", 1)[0]
parsed = urlparse(raw)
host = (parsed.hostname or "").rstrip(".").lower()
if len(raw) > 2048 or len(host) > 253:
raise ValueError("invalid_url")
try:
ipaddress.ip_address(host)
_resolved_addresses(host, timeout)
except ValueError:
_resolved_addresses(host, timeout)
return parsed.geturl()
class _Redirects(HTTPRedirectHandler):
def __init__(self, timeout: float, max_redirects: int):
self.timeout = timeout
self.max_redirects = max_redirects
self.chain: list[str] = []
def redirect_request(self, req, fp, code, msg, headers, newurl):
if len(self.chain) >= self.max_redirects:
raise ValueError("redirect_limit")
target = validate_url(urljoin(req.full_url, newurl), timeout=self.timeout)
self.chain.append(target)
return Request(target, headers={"User-Agent": "ProspectPlatformWebsiteScanner/1.0", "Accept": "text/html,application/xhtml+xml"}, method="GET")
def _fetch(url: str, *, timeout: float = DEFAULT_TIMEOUT, max_bytes: int = MAX_BYTES, max_redirects: int = MAX_REDIRECTS) -> dict:
safe_url = validate_url(url, timeout=timeout)
redirects = _Redirects(timeout, max_redirects)
opener = build_opener(redirects)
request = Request(safe_url, headers={"User-Agent": "ProspectPlatformWebsiteScanner/1.0", "Accept": "text/html,application/xhtml+xml"}, method="GET")
started = time.monotonic()
try:
with opener.open(request, timeout=timeout) as response:
chunks, total = [], 0
while True:
chunk = response.read(min(65536, max_bytes - total + 1))
if not chunk:
break
total += len(chunk)
if total > max_bytes:
raise ValueError("response_too_large")
chunks.append(chunk)
final_url = validate_url(response.geturl(), timeout=timeout)
return {"status": int(response.status), "final_url": final_url, "redirect_chain": redirects.chain, "body": b"".join(chunks), "content_type": response.headers.get_content_type(), "elapsed_ms": round((time.monotonic() - started) * 1000, 2), "tls": final_url.startswith("https://"), "certificate_status": "valid" if final_url.startswith("https://") else "not_applicable"}
except HTTPError as exc:
# HTTP errors are still useful website observations; read only the bounded body.
body = exc.read(max_bytes + 1)
if len(body) > max_bytes: raise ValueError("response_too_large") from exc
return {"status": int(exc.code), "final_url": validate_url(exc.geturl(), timeout=timeout), "redirect_chain": redirects.chain, "body": body, "content_type": exc.headers.get_content_type() if exc.headers else "text/html", "elapsed_ms": round((time.monotonic() - started) * 1000, 2), "tls": str(exc.geturl()).startswith("https://"), "certificate_status": "valid" if str(exc.geturl()).startswith("https://") else "not_applicable"}
except ssl.SSLCertVerificationError as exc:
raise ValueError("certificate_invalid") from exc
except ValueError:
raise
except TimeoutError as exc:
raise ValueError("timeout") from exc
except OSError as exc:
raise ValueError("connection_failed") from exc
class _PageParser(HTMLParser):
def __init__(self):
super().__init__(convert_charrefs=True)
self.title = ""; self.meta_description = ""; self.language = ""; self.headings: list[str] = []
self.viewport = False; self.cms_hints: set[str] = set(); self.contact_page = False; self.form = False
self.mail = False; self.phone = False; self.whatsapp = False; self.social = False; self._tag = ""; self._buf: list[str] = []
def handle_starttag(self, tag, attrs):
attrs = {str(k).lower(): str(v or "") for k, v in attrs}; tag = tag.lower()
self._tag = tag
if tag == "html": self.language = attrs.get("lang", "")[:20]
if tag in {"h1", "h2", "h3", "h4", "h5", "h6"}: self._buf = []
if tag == "title": self._buf = []
if tag == "meta":
name = attrs.get("name", "").lower()
if name == "description": self.meta_description = attrs.get("content", "")[:1000]
if name == "viewport": self.viewport = True
generator = attrs.get("content", "").lower()
if name == "generator": self._cms(generator)
if tag == "form": self.form = True
if tag == "a":
href = attrs.get("href", "").lower()
self.contact_page |= any(x in href for x in ("contact", "get-in-touch", "reach-us"))
self.mail |= href.startswith("mailto:"); self.whatsapp |= "wa.me" in href or "whatsapp" in href
self.social |= any(x in href for x in ("facebook.com", "instagram.com", "linkedin.com", "twitter.com", "x.com", "youtube.com", "tiktok.com"))
if tag in {"script", "link"}:
text = " ".join(attrs.values()).lower(); self._cms(text)
def _cms(self, text):
for key, terms in {"wordpress": ("wordpress", "wp-content"), "drupal": ("drupal",), "joomla": ("joomla",), "shopify": ("shopify",), "wix": ("wix.com",)}.items():
if any(term in text for term in terms): self.cms_hints.add(key)
def handle_data(self, data):
if self._tag in {"title", "h1", "h2", "h3", "h4", "h5", "h6"}: self._buf.append(data)
if re.search(r"(?:tel:|\+?\d[\d ()-]{6,})", data): self.phone = True
def handle_endtag(self, tag):
tag = tag.lower()
if tag == "title" and self._buf: self.title = " ".join("".join(self._buf).split())[:500]
if tag in {"h1", "h2", "h3", "h4", "h5", "h6"} and self._buf:
self.headings.append(" ".join("".join(self._buf).split())[:300])
self._tag = ""
def classify_website(status, final_url, body, *, error=None) -> str:
if error:
return "blocked" if error in {"timeout", "connection_failed", "dns_failure", "unsafe_address", "certificate_invalid", "redirect_limit", "response_too_large"} else "unknown"
if status is None: return "unknown"
if 400 <= status or status < 200: return "broken"
text = re.sub(r"<[^>]+>", " ", body if isinstance(body, str) else body.decode("utf-8", "replace")).lower()
if status in {301, 302, 303, 307, 308} and not text.strip(): return "redirect_only"
if re.search(r"domain (is )?for sale|buy this domain|parking page|parked free", text): return "parked"
if re.search(r"under construction|coming soon|website coming", text): return "under_construction"
if re.search(r"placeholder|lorem ipsum|sample page|default web page", text): return "placeholder"
if status < 300 and len(re.sub(r"\s+", "", text)) >= 8: return "healthy"
return "unknown"
def scan_website(url: str, *, timeout: float = DEFAULT_TIMEOUT, max_bytes: int = MAX_BYTES, max_redirects: int = MAX_REDIRECTS, max_pages: int = MAX_PAGES) -> dict:
result = {"input_url": str(url), "status": None, "final_url": None, "redirect_chain": [], "title": "", "meta_description": "", "language": "", "headings": [], "responsive_signal": None, "cms_hints": [], "contact_page_signal": None, "form_signal": None, "mail_signal": None, "phone_signal": None, "whatsapp_signal": None, "social_signal": None, "elapsed_ms": None, "size_bytes": 0, "tls": None, "certificate_status": "unknown", "error_code": None}
try:
if not 0 < int(max_redirects) <= MAX_REDIRECTS or not 0 < int(max_pages) <= MAX_PAGES: raise ValueError("invalid_limits")
fetched = _fetch(url, timeout=max(0.1, min(float(timeout), 10.0)), max_bytes=max(1, min(int(max_bytes), MAX_BYTES)), max_redirects=int(max_redirects))
result.update({k: fetched[k] for k in ("status", "final_url", "redirect_chain", "elapsed_ms", "tls", "certificate_status")}); result["size_bytes"] = len(fetched["body"])
if fetched["content_type"] not in {"text/html", "application/xhtml+xml"}:
result["classification"] = classify_website(fetched["status"], fetched["final_url"], ""); return result
parser = _PageParser(); parser.feed(fetched["body"].decode("utf-8", "replace"))
for key in ("title", "meta_description", "language", "headings", "viewport", "cms_hints", "contact_page", "form", "mail", "phone", "whatsapp", "social"):
result[{"viewport":"responsive_signal","cms_hints":"cms_hints","contact_page":"contact_page_signal","form":"form_signal","mail":"mail_signal","phone":"phone_signal","whatsapp":"whatsapp_signal","social":"social_signal"}.get(key,key)] = sorted(parser.cms_hints) if key == "cms_hints" else getattr(parser, key)
result["classification"] = classify_website(result["status"], result["final_url"], fetched["body"])
except ValueError as exc:
result["error_code"] = str(exc); result["classification"] = classify_website(None, url, "", error=str(exc))
return result