unique-search-proxy-core 2026.26.0.dev5__tar.gz → 2026.26.0.dev7__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.
- {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev7}/PKG-INFO +3 -1
- {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev7}/pyproject.toml +3 -1
- unique_search_proxy_core-2026.26.0.dev7/unique_search_proxy_core/url_safety/__init__.py +25 -0
- unique_search_proxy_core-2026.26.0.dev7/unique_search_proxy_core/url_safety/dns.py +53 -0
- unique_search_proxy_core-2026.26.0.dev7/unique_search_proxy_core/url_safety/egress.py +21 -0
- unique_search_proxy_core-2026.26.0.dev7/unique_search_proxy_core/url_safety/models.py +80 -0
- unique_search_proxy_core-2026.26.0.dev7/unique_search_proxy_core/url_safety/netloc.py +62 -0
- unique_search_proxy_core-2026.26.0.dev7/unique_search_proxy_core/url_safety/policy.py +52 -0
- unique_search_proxy_core-2026.26.0.dev7/unique_search_proxy_core/url_safety/redirect.py +93 -0
- unique_search_proxy_core-2026.26.0.dev7/unique_search_proxy_core/url_safety/resolver.py +103 -0
- unique_search_proxy_core-2026.26.0.dev7/unique_search_proxy_core/url_safety/service.py +81 -0
- unique_search_proxy_core-2026.26.0.dev7/unique_search_proxy_core/url_safety/settings.py +39 -0
- {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev7}/README.md +0 -0
- {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev7}/unique_search_proxy_core/__init__.py +0 -0
- {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev7}/unique_search_proxy_core/agent_engines/__init__.py +0 -0
- {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev7}/unique_search_proxy_core/agent_engines/base.py +0 -0
- {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev7}/unique_search_proxy_core/agent_engines/bing/__init__.py +0 -0
- {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev7}/unique_search_proxy_core/agent_engines/bing/schema.py +0 -0
- {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev7}/unique_search_proxy_core/agent_engines/config_types.py +0 -0
- {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev7}/unique_search_proxy_core/agent_engines/output_schema.py +0 -0
- {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev7}/unique_search_proxy_core/agent_engines/projection.py +0 -0
- {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev7}/unique_search_proxy_core/agent_engines/resolve.py +0 -0
- {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev7}/unique_search_proxy_core/agent_engines/response_parsing.py +0 -0
- {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev7}/unique_search_proxy_core/agent_engines/vertexai/__init__.py +0 -0
- {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev7}/unique_search_proxy_core/agent_engines/vertexai/schema.py +0 -0
- {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev7}/unique_search_proxy_core/crawlers/__init__.py +0 -0
- {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev7}/unique_search_proxy_core/crawlers/base.py +0 -0
- {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev7}/unique_search_proxy_core/crawlers/basic/__init__.py +0 -0
- {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev7}/unique_search_proxy_core/crawlers/basic/content_types.py +0 -0
- {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev7}/unique_search_proxy_core/crawlers/basic/processing/policy.py +0 -0
- {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev7}/unique_search_proxy_core/crawlers/basic/schema.py +0 -0
- {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev7}/unique_search_proxy_core/crawlers/config_types.py +0 -0
- {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev7}/unique_search_proxy_core/crawlers/firecrawl/schema.py +0 -0
- {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev7}/unique_search_proxy_core/crawlers/jina/schema.py +0 -0
- {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev7}/unique_search_proxy_core/crawlers/params.py +0 -0
- {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev7}/unique_search_proxy_core/crawlers/projection.py +0 -0
- {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev7}/unique_search_proxy_core/crawlers/tavily/schema.py +0 -0
- {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev7}/unique_search_proxy_core/errors.py +0 -0
- {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev7}/unique_search_proxy_core/logging.py +0 -0
- {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev7}/unique_search_proxy_core/model_derivation/__init__.py +0 -0
- {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev7}/unique_search_proxy_core/model_derivation/derive.py +0 -0
- {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev7}/unique_search_proxy_core/model_derivation/fields.py +0 -0
- {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev7}/unique_search_proxy_core/param_policy/__init__.py +0 -0
- {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev7}/unique_search_proxy_core/param_policy/exposable_param.py +0 -0
- {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev7}/unique_search_proxy_core/param_policy/policy.py +0 -0
- {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev7}/unique_search_proxy_core/projection.py +0 -0
- {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev7}/unique_search_proxy_core/providers/schema.py +0 -0
- {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev7}/unique_search_proxy_core/schema.py +0 -0
- {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev7}/unique_search_proxy_core/search_engines/__init__.py +0 -0
- {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev7}/unique_search_proxy_core/search_engines/base.py +0 -0
- {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev7}/unique_search_proxy_core/search_engines/brave/__init__.py +0 -0
- {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev7}/unique_search_proxy_core/search_engines/brave/schema.py +0 -0
- {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev7}/unique_search_proxy_core/search_engines/call_schema.py +0 -0
- {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev7}/unique_search_proxy_core/search_engines/config_types.py +0 -0
- {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev7}/unique_search_proxy_core/search_engines/google/__init__.py +0 -0
- {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev7}/unique_search_proxy_core/search_engines/google/schema.py +0 -0
- {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev7}/unique_search_proxy_core/search_engines/params.py +0 -0
- {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev7}/unique_search_proxy_core/search_engines/perplexity/__init__.py +0 -0
- {unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev7}/unique_search_proxy_core/search_engines/perplexity/schema.py +0 -0
{unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev7}/PKG-INFO
RENAMED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: unique-search-proxy-core
|
|
3
|
-
Version: 2026.26.0.
|
|
3
|
+
Version: 2026.26.0.dev7
|
|
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
|
{unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev7}/pyproject.toml
RENAMED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "unique-search-proxy-core"
|
|
3
|
-
version = "2026.26.0.
|
|
3
|
+
version = "2026.26.0.dev7"
|
|
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,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from logging import getLogger
|
|
4
|
+
|
|
5
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
6
|
+
|
|
7
|
+
_LOGGER = getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class UrlSafetySettings(BaseSettings):
|
|
11
|
+
enabled: bool = True
|
|
12
|
+
resolve_redirects: bool = True
|
|
13
|
+
allowed_schemes: list[str] = ["http", "https"]
|
|
14
|
+
localhost_hosts: list[str] = [
|
|
15
|
+
"localhost",
|
|
16
|
+
"localhost.localdomain",
|
|
17
|
+
]
|
|
18
|
+
metadata_hosts: list[str] = [
|
|
19
|
+
"100.100.100.200", # Alibaba Cloud
|
|
20
|
+
"169.254.169.254", # AWS / GCP / Azure IMDS
|
|
21
|
+
"169.254.170.2", # AWS ECS task credentials
|
|
22
|
+
"metadata.azure.internal",
|
|
23
|
+
"metadata.google.internal",
|
|
24
|
+
]
|
|
25
|
+
cluster_local_suffix: str = ".cluster.local"
|
|
26
|
+
service_suffix: str = ".svc"
|
|
27
|
+
max_redirect_hops: int = 10
|
|
28
|
+
redirect_timeout_seconds: float = 10.0
|
|
29
|
+
|
|
30
|
+
model_config = SettingsConfigDict(
|
|
31
|
+
extra="ignore",
|
|
32
|
+
env_prefix="URL_SAFETY_",
|
|
33
|
+
case_sensitive=False,
|
|
34
|
+
frozen=True,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
url_safety_settings = UrlSafetySettings()
|
|
39
|
+
_LOGGER.info("URL Safety is enabled: %s", url_safety_settings.enabled)
|
{unique_search_proxy_core-2026.26.0.dev5 → unique_search_proxy_core-2026.26.0.dev7}/README.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|