unique-search-proxy-core 2026.26.0.dev5__tar.gz → 2026.26.0.dev6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev6}/PKG-INFO +3 -1
  2. {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev6}/pyproject.toml +3 -1
  3. unique_search_proxy_core-2026.26.0.dev6/unique_search_proxy_core/url_safety/__init__.py +25 -0
  4. unique_search_proxy_core-2026.26.0.dev6/unique_search_proxy_core/url_safety/dns.py +53 -0
  5. unique_search_proxy_core-2026.26.0.dev6/unique_search_proxy_core/url_safety/egress.py +21 -0
  6. unique_search_proxy_core-2026.26.0.dev6/unique_search_proxy_core/url_safety/models.py +80 -0
  7. unique_search_proxy_core-2026.26.0.dev6/unique_search_proxy_core/url_safety/netloc.py +62 -0
  8. unique_search_proxy_core-2026.26.0.dev6/unique_search_proxy_core/url_safety/policy.py +52 -0
  9. unique_search_proxy_core-2026.26.0.dev6/unique_search_proxy_core/url_safety/redirect.py +93 -0
  10. unique_search_proxy_core-2026.26.0.dev6/unique_search_proxy_core/url_safety/resolver.py +103 -0
  11. unique_search_proxy_core-2026.26.0.dev6/unique_search_proxy_core/url_safety/service.py +81 -0
  12. unique_search_proxy_core-2026.26.0.dev6/unique_search_proxy_core/url_safety/settings.py +34 -0
  13. {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev6}/README.md +0 -0
  14. {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev6}/unique_search_proxy_core/__init__.py +0 -0
  15. {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev6}/unique_search_proxy_core/agent_engines/__init__.py +0 -0
  16. {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev6}/unique_search_proxy_core/agent_engines/base.py +0 -0
  17. {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev6}/unique_search_proxy_core/agent_engines/bing/__init__.py +0 -0
  18. {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev6}/unique_search_proxy_core/agent_engines/bing/schema.py +0 -0
  19. {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev6}/unique_search_proxy_core/agent_engines/config_types.py +0 -0
  20. {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev6}/unique_search_proxy_core/agent_engines/output_schema.py +0 -0
  21. {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev6}/unique_search_proxy_core/agent_engines/projection.py +0 -0
  22. {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev6}/unique_search_proxy_core/agent_engines/resolve.py +0 -0
  23. {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev6}/unique_search_proxy_core/agent_engines/response_parsing.py +0 -0
  24. {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev6}/unique_search_proxy_core/agent_engines/vertexai/__init__.py +0 -0
  25. {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev6}/unique_search_proxy_core/agent_engines/vertexai/schema.py +0 -0
  26. {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev6}/unique_search_proxy_core/crawlers/__init__.py +0 -0
  27. {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev6}/unique_search_proxy_core/crawlers/base.py +0 -0
  28. {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev6}/unique_search_proxy_core/crawlers/basic/__init__.py +0 -0
  29. {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev6}/unique_search_proxy_core/crawlers/basic/content_types.py +0 -0
  30. {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev6}/unique_search_proxy_core/crawlers/basic/processing/policy.py +0 -0
  31. {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev6}/unique_search_proxy_core/crawlers/basic/schema.py +0 -0
  32. {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev6}/unique_search_proxy_core/crawlers/config_types.py +0 -0
  33. {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev6}/unique_search_proxy_core/crawlers/firecrawl/schema.py +0 -0
  34. {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev6}/unique_search_proxy_core/crawlers/jina/schema.py +0 -0
  35. {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev6}/unique_search_proxy_core/crawlers/params.py +0 -0
  36. {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev6}/unique_search_proxy_core/crawlers/projection.py +0 -0
  37. {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev6}/unique_search_proxy_core/crawlers/tavily/schema.py +0 -0
  38. {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev6}/unique_search_proxy_core/errors.py +0 -0
  39. {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev6}/unique_search_proxy_core/logging.py +0 -0
  40. {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev6}/unique_search_proxy_core/model_derivation/__init__.py +0 -0
  41. {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev6}/unique_search_proxy_core/model_derivation/derive.py +0 -0
  42. {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev6}/unique_search_proxy_core/model_derivation/fields.py +0 -0
  43. {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev6}/unique_search_proxy_core/param_policy/__init__.py +0 -0
  44. {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev6}/unique_search_proxy_core/param_policy/exposable_param.py +0 -0
  45. {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev6}/unique_search_proxy_core/param_policy/policy.py +0 -0
  46. {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev6}/unique_search_proxy_core/projection.py +0 -0
  47. {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev6}/unique_search_proxy_core/providers/schema.py +0 -0
  48. {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev6}/unique_search_proxy_core/schema.py +0 -0
  49. {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev6}/unique_search_proxy_core/search_engines/__init__.py +0 -0
  50. {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev6}/unique_search_proxy_core/search_engines/base.py +0 -0
  51. {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev6}/unique_search_proxy_core/search_engines/brave/__init__.py +0 -0
  52. {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev6}/unique_search_proxy_core/search_engines/brave/schema.py +0 -0
  53. {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev6}/unique_search_proxy_core/search_engines/call_schema.py +0 -0
  54. {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev6}/unique_search_proxy_core/search_engines/config_types.py +0 -0
  55. {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev6}/unique_search_proxy_core/search_engines/google/__init__.py +0 -0
  56. {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev6}/unique_search_proxy_core/search_engines/google/schema.py +0 -0
  57. {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev6}/unique_search_proxy_core/search_engines/params.py +0 -0
  58. {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev6}/unique_search_proxy_core/search_engines/perplexity/__init__.py +0 -0
  59. {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev6}/unique_search_proxy_core/search_engines/perplexity/schema.py +0 -0
@@ -1,10 +1,12 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: unique-search-proxy-core
3
- Version: 2026.26.0.dev5
3
+ Version: 2026.26.0.dev6
4
4
  Summary: Shared Pydantic types for the Unique Search Proxy API
5
5
  Author: ThePhilAz
6
6
  Author-email: ThePhilAz <rami.azouz@philico.com>
7
+ Requires-Dist: httpx>=0.28.0,<0.29
7
8
  Requires-Dist: pydantic>=2.12.5,<3.0.0
9
+ Requires-Dist: pydantic-settings>=2.10.1,<3
8
10
  Requires-Dist: pyhumps>=3.8.0,<4
9
11
  Requires-Python: >=3.12
10
12
  Description-Content-Type: text/markdown
@@ -1,12 +1,14 @@
1
1
  [project]
2
2
  name = "unique-search-proxy-core"
3
- version = "2026.26.0.dev5"
3
+ version = "2026.26.0.dev6"
4
4
  description = "Shared Pydantic types for the Unique Search Proxy API"
5
5
  authors = [{ name = "ThePhilAz", email = "rami.azouz@philico.com" }]
6
6
  readme = "README.md"
7
7
  requires-python = ">=3.12"
8
8
  dependencies = [
9
+ "httpx>=0.28.0,<0.29",
9
10
  "pydantic>=2.12.5,<3.0.0",
11
+ "pydantic-settings>=2.10.1,<3",
10
12
  "pyhumps>=3.8.0,<4",
11
13
  ]
12
14
 
@@ -0,0 +1,25 @@
1
+ from unique_search_proxy_core.url_safety.egress import pinned_httpx_get_args
2
+ from unique_search_proxy_core.url_safety.models import (
3
+ BlockedCrawlTarget,
4
+ CrawlTargetValidationError,
5
+ ResolvedCrawlTarget,
6
+ UrlSafetyOutcome,
7
+ bypass_crawl_target,
8
+ )
9
+ from unique_search_proxy_core.url_safety.service import UrlSafetyService
10
+ from unique_search_proxy_core.url_safety.settings import (
11
+ UrlSafetySettings,
12
+ url_safety_settings,
13
+ )
14
+
15
+ __all__ = [
16
+ "BlockedCrawlTarget",
17
+ "CrawlTargetValidationError",
18
+ "ResolvedCrawlTarget",
19
+ "UrlSafetyOutcome",
20
+ "UrlSafetyService",
21
+ "UrlSafetySettings",
22
+ "bypass_crawl_target",
23
+ "pinned_httpx_get_args",
24
+ "url_safety_settings",
25
+ ]
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import socket
5
+ from ipaddress import ip_address
6
+
7
+
8
+ async def resolve_host_addresses(host: str) -> tuple[str, ...]:
9
+ loop = asyncio.get_running_loop()
10
+ resolved = await loop.run_in_executor(
11
+ None, lambda: socket.getaddrinfo(host, None, type=socket.SOCK_STREAM)
12
+ )
13
+ return tuple(
14
+ dict.fromkeys(str(ip_address(sockaddr[0])) for _, _, _, _, sockaddr in resolved)
15
+ )
16
+
17
+
18
+ def block_reason_for_resolved_addresses(
19
+ resolved_addresses: tuple[str, ...],
20
+ ) -> tuple[str, str] | None:
21
+ if not resolved_addresses:
22
+ return (
23
+ "dns",
24
+ "Target host could not be resolved during safety validation",
25
+ )
26
+
27
+ for resolved_address in resolved_addresses:
28
+ if not ip_address(resolved_address).is_global:
29
+ return (
30
+ "private",
31
+ "Target host resolves to a private or special-use IP address",
32
+ )
33
+
34
+ return None
35
+
36
+
37
+ async def resolve_and_validate_host(
38
+ host: str,
39
+ ) -> tuple[tuple[str, ...], tuple[str, str] | None]:
40
+ try:
41
+ resolved_addresses = await resolve_host_addresses(host)
42
+ except socket.gaierror:
43
+ return (), (
44
+ "dns",
45
+ "Target host could not be resolved during safety validation",
46
+ )
47
+
48
+ return resolved_addresses, block_reason_for_resolved_addresses(resolved_addresses)
49
+
50
+
51
+ async def validate_resolved_host(host: str) -> tuple[str, str] | None:
52
+ _, validation_error = await resolve_and_validate_host(host)
53
+ return validation_error
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ from unique_search_proxy_core.url_safety.models import ResolvedCrawlTarget
4
+
5
+
6
+ def pinned_httpx_get_args(
7
+ target: ResolvedCrawlTarget,
8
+ ) -> tuple[str, dict[str, str], dict[str, str]]:
9
+ """Build httpx GET url, headers, and extensions for DNS-pinned egress."""
10
+ headers: dict[str, str] = {}
11
+ if target.host_header is not None:
12
+ headers["Host"] = target.host_header
13
+
14
+ extensions: dict[str, str] = {}
15
+ if target.sni_hostname is not None:
16
+ extensions["sni_hostname"] = target.sni_hostname
17
+
18
+ return target.request_url, headers, extensions
19
+
20
+
21
+ __all__ = ["pinned_httpx_get_args"]
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from urllib.parse import urlsplit
5
+
6
+ from unique_search_proxy_core.url_safety.netloc import (
7
+ build_netloc,
8
+ replace_url_host,
9
+ )
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class BlockedCrawlTarget:
14
+ hostname: str | None
15
+ category: str
16
+ reason: str
17
+
18
+ @property
19
+ def display_target(self) -> str:
20
+ return self.hostname or "<unknown-host>"
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class ResolvedCrawlTarget:
25
+ """Carries the validated host/IP values reused when issuing crawl requests."""
26
+
27
+ normalized_url: str
28
+ hostname: str
29
+ resolved_ip: str
30
+ used_dns_resolution: bool
31
+
32
+ @property
33
+ def request_url(self) -> str:
34
+ if not self.used_dns_resolution and not self.resolved_ip:
35
+ return self.normalized_url
36
+ return replace_url_host(urlsplit(self.normalized_url), host=self.resolved_ip)
37
+
38
+ @property
39
+ def host_header(self) -> str | None:
40
+ if not self.used_dns_resolution:
41
+ return None
42
+
43
+ return build_netloc(host=self.hostname, port=urlsplit(self.normalized_url).port)
44
+
45
+ @property
46
+ def sni_hostname(self) -> str | None:
47
+ if not self.used_dns_resolution:
48
+ return None
49
+
50
+ if urlsplit(self.normalized_url).scheme.lower() != "https":
51
+ return None
52
+
53
+ return self.hostname
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class UrlSafetyOutcome:
58
+ url: str
59
+ resolved: ResolvedCrawlTarget | None = None
60
+ blocked: BlockedCrawlTarget | None = None
61
+
62
+
63
+ class CrawlTargetValidationError(ValueError):
64
+ def __init__(self, blocked_targets: list[BlockedCrawlTarget]) -> None:
65
+ self.blocked_targets: list[BlockedCrawlTarget] = blocked_targets
66
+
67
+ details = "; ".join(
68
+ f"{target.display_target} ({target.reason})"
69
+ for target in self.blocked_targets
70
+ )
71
+ super().__init__(f"Blocked crawl target(s) due to URL safety policy: {details}")
72
+
73
+
74
+ def bypass_crawl_target(url: str) -> ResolvedCrawlTarget:
75
+ return ResolvedCrawlTarget(
76
+ normalized_url=url.strip(),
77
+ hostname="",
78
+ resolved_ip="",
79
+ used_dns_resolution=False,
80
+ )
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+
3
+ from ipaddress import ip_address
4
+ from urllib.parse import SplitResult, urlsplit, urlunsplit
5
+
6
+
7
+ def extract_hostname(url: str) -> str | None:
8
+ normalized_url = url.strip()
9
+ if not normalized_url:
10
+ return None
11
+
12
+ hostname = urlsplit(normalized_url).hostname
13
+ if hostname is None:
14
+ return None
15
+
16
+ return hostname.rstrip(".").lower()
17
+
18
+
19
+ def replace_url_host(parsed_url: SplitResult, *, host: str) -> str:
20
+ netloc = build_netloc(
21
+ host=host,
22
+ port=parsed_url.port,
23
+ username=parsed_url.username,
24
+ password=parsed_url.password,
25
+ )
26
+ return urlunsplit(
27
+ (
28
+ parsed_url.scheme,
29
+ netloc,
30
+ parsed_url.path,
31
+ parsed_url.query,
32
+ "",
33
+ )
34
+ )
35
+
36
+
37
+ def build_netloc(
38
+ host: str,
39
+ port: int | None,
40
+ username: str | None = None,
41
+ password: str | None = None,
42
+ ) -> str:
43
+ credential_prefix = ""
44
+ if username is not None:
45
+ credential_prefix = username
46
+ if password is not None:
47
+ credential_prefix = f"{credential_prefix}:{password}"
48
+ credential_prefix = f"{credential_prefix}@"
49
+
50
+ try:
51
+ host_ip = ip_address(host)
52
+ except ValueError:
53
+ normalized_host = host
54
+ else:
55
+ normalized_host = (
56
+ f"[{host_ip.compressed}]" if host_ip.version == 6 else host_ip.compressed
57
+ )
58
+
59
+ if port is None:
60
+ return f"{credential_prefix}{normalized_host}"
61
+
62
+ return f"{credential_prefix}{normalized_host}:{port}"
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ from ipaddress import ip_address
4
+ from urllib.parse import urlsplit
5
+
6
+ from unique_search_proxy_core.url_safety.settings import url_safety_settings
7
+
8
+
9
+ def validate_target_cheap(url: str) -> tuple[str, str] | None:
10
+ """Static validation only — no DNS. Returns (category, reason) if blocked, None otherwise."""
11
+ if not url:
12
+ return "empty", "URL is empty"
13
+
14
+ parsed_url = urlsplit(url)
15
+ if parsed_url.scheme.lower() not in url_safety_settings.allowed_schemes:
16
+ return "scheme", "URL scheme must be http or https"
17
+
18
+ hostname = parsed_url.hostname
19
+ if not hostname:
20
+ return "host", "URL host is missing or malformed"
21
+
22
+ normalized_host = hostname.rstrip(".").lower()
23
+
24
+ if normalized_host in url_safety_settings.metadata_hosts:
25
+ return "metadata", "Target points to a metadata endpoint"
26
+
27
+ if (
28
+ normalized_host in url_safety_settings.localhost_hosts
29
+ or normalized_host.endswith(".localhost")
30
+ ):
31
+ return "localhost", "Target points to a localhost host"
32
+
33
+ if normalized_host.endswith(url_safety_settings.service_suffix):
34
+ return "cluster", "Target points to an internal service host"
35
+
36
+ if normalized_host.endswith(url_safety_settings.cluster_local_suffix):
37
+ return "cluster", "Target points to an internal cluster-local host"
38
+
39
+ try:
40
+ target_ip = ip_address(normalized_host)
41
+ except ValueError:
42
+ pass
43
+ else:
44
+ if not target_ip.is_global:
45
+ return "private", "Target points to a private or special-use IP address"
46
+
47
+ return None
48
+
49
+ if "." not in normalized_host:
50
+ return "cluster", "Target points to a single-label internal host"
51
+
52
+ return None
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from collections.abc import Awaitable, Callable
5
+ from urllib.parse import urljoin
6
+
7
+ import httpx
8
+
9
+ from unique_search_proxy_core.url_safety.models import (
10
+ BlockedCrawlTarget,
11
+ CrawlTargetValidationError,
12
+ )
13
+ from unique_search_proxy_core.url_safety.netloc import extract_hostname
14
+ from unique_search_proxy_core.url_safety.settings import url_safety_settings
15
+
16
+ _LOGGER = logging.getLogger(__name__)
17
+
18
+ _REDIRECT_STATUS_CODES = frozenset({301, 302, 303, 307, 308})
19
+
20
+ ValidateUrlFn = Callable[[str], Awaitable[tuple[str, str] | None]]
21
+
22
+
23
+ async def resolve_redirect_chain(
24
+ url: str,
25
+ validate_url: ValidateUrlFn,
26
+ ) -> str:
27
+ """Follow HTTP 3xx redirects hop-by-hop, validating each destination.
28
+
29
+ Returns the final validated URL.
30
+ Raises CrawlTargetValidationError if any hop is blocked or redirect probing
31
+ cannot be completed (fail-closed to prevent GET-time redirect SSRF bypass).
32
+ """
33
+ current = url
34
+ timeout = url_safety_settings.redirect_timeout_seconds
35
+ async with httpx.AsyncClient(follow_redirects=False, timeout=timeout) as client:
36
+ for _ in range(url_safety_settings.max_redirect_hops):
37
+ error = await validate_url(current)
38
+ if error is not None:
39
+ category, reason = error
40
+ raise CrawlTargetValidationError(
41
+ [
42
+ BlockedCrawlTarget(
43
+ hostname=extract_hostname(current),
44
+ category=category,
45
+ reason=reason,
46
+ )
47
+ ]
48
+ )
49
+
50
+ try:
51
+ resp = await client.head(current)
52
+ except Exception as exc:
53
+ _LOGGER.debug(
54
+ "Redirect resolution blocked at %s due to network error: %s",
55
+ current,
56
+ exc,
57
+ )
58
+ raise CrawlTargetValidationError(
59
+ [
60
+ BlockedCrawlTarget(
61
+ hostname=extract_hostname(current),
62
+ category="redirect",
63
+ reason=(
64
+ "Unable to verify redirect chain before crawl; "
65
+ "blocking to prevent redirect-based SSRF"
66
+ ),
67
+ )
68
+ ]
69
+ ) from exc
70
+
71
+ if resp.status_code not in _REDIRECT_STATUS_CODES:
72
+ break
73
+
74
+ location = resp.headers.get("location")
75
+ if not location:
76
+ break
77
+
78
+ current = urljoin(current, location)
79
+
80
+ error = await validate_url(current)
81
+ if error is not None:
82
+ category, reason = error
83
+ raise CrawlTargetValidationError(
84
+ [
85
+ BlockedCrawlTarget(
86
+ hostname=extract_hostname(current),
87
+ category=category,
88
+ reason=reason,
89
+ )
90
+ ]
91
+ )
92
+
93
+ return current
@@ -0,0 +1,103 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from ipaddress import ip_address
5
+ from urllib.parse import urlsplit
6
+
7
+ from unique_search_proxy_core.url_safety import dns
8
+ from unique_search_proxy_core.url_safety.models import (
9
+ BlockedCrawlTarget,
10
+ CrawlTargetValidationError,
11
+ ResolvedCrawlTarget,
12
+ )
13
+ from unique_search_proxy_core.url_safety.netloc import extract_hostname
14
+ from unique_search_proxy_core.url_safety.policy import validate_target_cheap
15
+
16
+
17
+ async def resolve_crawl_target(url: str) -> ResolvedCrawlTarget:
18
+ """Validate and resolve a single URL, performing DNS exactly once."""
19
+ normalized_url = url.strip()
20
+
21
+ validation_error = validate_target_cheap(normalized_url)
22
+ if validation_error is not None:
23
+ category, reason = validation_error
24
+ raise CrawlTargetValidationError(
25
+ [
26
+ BlockedCrawlTarget(
27
+ hostname=extract_hostname(normalized_url),
28
+ category=category,
29
+ reason=reason,
30
+ )
31
+ ]
32
+ )
33
+
34
+ parsed_url = urlsplit(normalized_url)
35
+ hostname = parsed_url.hostname
36
+ if hostname is None:
37
+ raise CrawlTargetValidationError(
38
+ [
39
+ BlockedCrawlTarget(
40
+ hostname=None,
41
+ category="host",
42
+ reason="URL host is missing or malformed",
43
+ )
44
+ ]
45
+ )
46
+
47
+ normalized_host = hostname.rstrip(".").lower()
48
+
49
+ try:
50
+ target_ip = ip_address(normalized_host)
51
+ except ValueError:
52
+ resolved_addresses, validation_error = await dns.resolve_and_validate_host(
53
+ normalized_host
54
+ )
55
+ if validation_error is not None:
56
+ category, reason = validation_error
57
+ raise CrawlTargetValidationError(
58
+ [
59
+ BlockedCrawlTarget(
60
+ hostname=normalized_host,
61
+ category=category,
62
+ reason=reason,
63
+ )
64
+ ]
65
+ )
66
+ return ResolvedCrawlTarget(
67
+ normalized_url=normalized_url,
68
+ hostname=normalized_host,
69
+ resolved_ip=resolved_addresses[0],
70
+ used_dns_resolution=True,
71
+ )
72
+
73
+ return ResolvedCrawlTarget(
74
+ normalized_url=normalized_url,
75
+ hostname=normalized_host,
76
+ resolved_ip=str(target_ip),
77
+ used_dns_resolution=False,
78
+ )
79
+
80
+
81
+ async def resolve_crawl_targets_batch(urls: list[str]) -> list[ResolvedCrawlTarget]:
82
+ """Resolve and validate each URL, collecting all policy violations before raising."""
83
+ results: list[ResolvedCrawlTarget | BaseException] = list(
84
+ await asyncio.gather(
85
+ *[resolve_crawl_target(url) for url in urls],
86
+ return_exceptions=True,
87
+ )
88
+ )
89
+
90
+ blocked: list[BlockedCrawlTarget] = []
91
+ resolved: list[ResolvedCrawlTarget] = []
92
+ for result in results:
93
+ if isinstance(result, CrawlTargetValidationError):
94
+ blocked.extend(result.blocked_targets)
95
+ elif isinstance(result, BaseException):
96
+ raise result
97
+ else:
98
+ resolved.append(result)
99
+
100
+ if blocked:
101
+ raise CrawlTargetValidationError(blocked)
102
+
103
+ return resolved
@@ -0,0 +1,81 @@
1
+ from __future__ import annotations
2
+
3
+ from ipaddress import ip_address
4
+ from urllib.parse import urlsplit
5
+
6
+ from unique_search_proxy_core.url_safety import dns, redirect, resolver
7
+ from unique_search_proxy_core.url_safety.models import (
8
+ CrawlTargetValidationError,
9
+ ResolvedCrawlTarget,
10
+ UrlSafetyOutcome,
11
+ bypass_crawl_target,
12
+ )
13
+ from unique_search_proxy_core.url_safety.policy import validate_target_cheap
14
+ from unique_search_proxy_core.url_safety.settings import url_safety_settings
15
+
16
+
17
+ class UrlSafetyService:
18
+ @staticmethod
19
+ async def validate_urls_individually(urls: list[str]) -> list[UrlSafetyOutcome]:
20
+ if not url_safety_settings.enabled:
21
+ return [
22
+ UrlSafetyOutcome(url=url, resolved=bypass_crawl_target(url))
23
+ for url in urls
24
+ ]
25
+
26
+ outcomes: list[UrlSafetyOutcome] = []
27
+ for url in urls:
28
+ working_url = url.strip()
29
+ try:
30
+ if url_safety_settings.resolve_redirects:
31
+ working_url = await redirect.resolve_redirect_chain(
32
+ working_url,
33
+ validate_url=UrlSafetyService.validate_url,
34
+ )
35
+ resolved = await resolver.resolve_crawl_target(working_url)
36
+ outcomes.append(UrlSafetyOutcome(url=url, resolved=resolved))
37
+ except CrawlTargetValidationError as exc:
38
+ blocked = exc.blocked_targets[0]
39
+ outcomes.append(UrlSafetyOutcome(url=url, blocked=blocked))
40
+ return outcomes
41
+
42
+ @staticmethod
43
+ async def validate_batch_urls(urls: list[str]) -> list[ResolvedCrawlTarget]:
44
+ outcomes = await UrlSafetyService.validate_urls_individually(urls)
45
+ blocked_targets = [
46
+ outcome.blocked for outcome in outcomes if outcome.blocked is not None
47
+ ]
48
+ if blocked_targets:
49
+ raise CrawlTargetValidationError(blocked_targets)
50
+
51
+ return [
52
+ outcome.resolved for outcome in outcomes if outcome.resolved is not None
53
+ ]
54
+
55
+ @staticmethod
56
+ async def validate_url(url: str) -> tuple[str, str] | None:
57
+ """Full validation including async DNS check.
58
+
59
+ Returns a ``(category, reason)`` tuple if the URL should be blocked, or
60
+ ``None`` if it is allowed.
61
+ """
62
+ error = validate_target_cheap(url)
63
+ if error is not None:
64
+ return error
65
+
66
+ hostname = urlsplit(url).hostname
67
+ if hostname is None:
68
+ return None
69
+
70
+ normalized_host = hostname.rstrip(".").lower()
71
+ try:
72
+ ip_address(normalized_host)
73
+ return None
74
+ except ValueError:
75
+ pass
76
+
77
+ return await dns.validate_resolved_host(normalized_host)
78
+
79
+ @staticmethod
80
+ async def resolve_crawl_target(url: str) -> ResolvedCrawlTarget:
81
+ return await resolver.resolve_crawl_target(url)
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic_settings import BaseSettings, SettingsConfigDict
4
+
5
+
6
+ class UrlSafetySettings(BaseSettings):
7
+ enabled: bool = True
8
+ resolve_redirects: bool = True
9
+ allowed_schemes: list[str] = ["http", "https"]
10
+ localhost_hosts: list[str] = [
11
+ "localhost",
12
+ "localhost.localdomain",
13
+ ]
14
+ metadata_hosts: list[str] = [
15
+ "100.100.100.200", # Alibaba Cloud
16
+ "169.254.169.254", # AWS / GCP / Azure IMDS
17
+ "169.254.170.2", # AWS ECS task credentials
18
+ "metadata.azure.internal",
19
+ "metadata.google.internal",
20
+ ]
21
+ cluster_local_suffix: str = ".cluster.local"
22
+ service_suffix: str = ".svc"
23
+ max_redirect_hops: int = 10
24
+ redirect_timeout_seconds: float = 10.0
25
+
26
+ model_config = SettingsConfigDict(
27
+ extra="ignore",
28
+ env_prefix="URL_SAFETY_",
29
+ case_sensitive=False,
30
+ frozen=True,
31
+ )
32
+
33
+
34
+ url_safety_settings = UrlSafetySettings()