[verified] fix: close 0.3.0 release security gaps
Docker / build (push) Has been cancelled
Build / build (push) Has been cancelled
Lint / ruff (push) Has been cancelled
Test / pytest (ubuntu-latest, 3.11) (push) Has been cancelled
Test / pytest (ubuntu-latest, 3.12) (push) Has been cancelled
Test / pytest (windows-latest, 3.11) (push) Has been cancelled
Test / pytest (windows-latest, 3.12) (push) Has been cancelled
Docker / build (push) Has been cancelled
Build / build (push) Has been cancelled
Lint / ruff (push) Has been cancelled
Test / pytest (ubuntu-latest, 3.11) (push) Has been cancelled
Test / pytest (ubuntu-latest, 3.12) (push) Has been cancelled
Test / pytest (windows-latest, 3.11) (push) Has been cancelled
Test / pytest (windows-latest, 3.12) (push) Has been cancelled
This commit is contained in:
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import math
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
@@ -19,23 +20,36 @@ class SubagentTimeoutMiddleware(AgentMiddleware):
|
||||
def name(self) -> str:
|
||||
return "subagent_timeout"
|
||||
|
||||
def __init__(self, timeout_seconds: float = 180.0) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
timeout_seconds: float = 180.0,
|
||||
cancellation_grace_seconds: float = 0.25,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
if timeout_seconds <= 0:
|
||||
raise ValueError("timeout_seconds must be positive")
|
||||
if not math.isfinite(timeout_seconds) or timeout_seconds <= 0:
|
||||
raise ValueError("timeout_seconds must be finite and positive")
|
||||
if (
|
||||
not math.isfinite(cancellation_grace_seconds)
|
||||
or cancellation_grace_seconds <= 0
|
||||
):
|
||||
raise ValueError("cancellation_grace_seconds must be finite and positive")
|
||||
self.timeout_seconds = float(timeout_seconds)
|
||||
self.cancellation_grace_seconds = float(cancellation_grace_seconds)
|
||||
|
||||
@staticmethod
|
||||
async def _cancel_task(task: asyncio.Future[Any]) -> None:
|
||||
async def _cancel_task(self, task: asyncio.Future[Any]) -> None:
|
||||
task.cancel()
|
||||
try:
|
||||
await asyncio.shield(task)
|
||||
done, _pending = await asyncio.wait(
|
||||
{task}, timeout=self.cancellation_grace_seconds
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
if not task.done():
|
||||
task.add_done_callback(SubagentTimeoutMiddleware._consume_task_result)
|
||||
raise
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
if task in done:
|
||||
self._consume_task_result(task)
|
||||
else:
|
||||
task.add_done_callback(SubagentTimeoutMiddleware._consume_task_result)
|
||||
|
||||
@staticmethod
|
||||
def _consume_task_result(task: asyncio.Future[Any]) -> None:
|
||||
|
||||
@@ -22,6 +22,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from deepagents.backends.protocol import ExecuteResponse, SandboxBackendProtocol
|
||||
from filelock import FileLock
|
||||
|
||||
from .workspace_files import ScopedFilesystemBackend
|
||||
|
||||
@@ -54,19 +55,125 @@ class NativeSandboxInstallation:
|
||||
socat: Path | None = None
|
||||
|
||||
|
||||
def _repo_root() -> Path:
|
||||
return Path(__file__).resolve().parents[1]
|
||||
def _runtime_assets_root() -> Path:
|
||||
return Path(__file__).with_name("native_sandbox_runtime")
|
||||
|
||||
|
||||
def _runtime_root() -> Path:
|
||||
configured = os.getenv("EVOSCIENTIST_NATIVE_SANDBOX_ROOT", "").strip()
|
||||
if configured:
|
||||
return Path(configured).expanduser().resolve()
|
||||
from .paths import DATA_DIR
|
||||
|
||||
return (DATA_DIR / "runtime" / "native-sandbox").expanduser().resolve()
|
||||
|
||||
|
||||
def _runtime_install_complete(root: Path) -> bool:
|
||||
package_root = root / "node_modules" / "@anthropic-ai" / "sandbox-runtime"
|
||||
try:
|
||||
metadata = json.loads((package_root / "package.json").read_text(encoding="utf-8"))
|
||||
patched = (
|
||||
package_root / "dist" / "sandbox" / "linux-sandbox-utils.js"
|
||||
).read_text(encoding="utf-8")
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
srt = root / "node_modules" / ".bin" / "srt"
|
||||
return (
|
||||
Path(configured).expanduser().resolve()
|
||||
if configured
|
||||
else (_repo_root() / "runtime" / "native-sandbox").resolve()
|
||||
metadata.get("version") == _PINNED_SRT_VERSION
|
||||
and srt.is_file()
|
||||
and os.access(srt, os.X_OK)
|
||||
and "rootChildIsAllowedSymlink" in patched
|
||||
and "--remount-ro" in patched
|
||||
)
|
||||
|
||||
|
||||
def _ensure_runtime_installed() -> Path:
|
||||
"""Install the pinned SRT into user data from packaged lockfile assets."""
|
||||
|
||||
root = _runtime_root()
|
||||
if _runtime_install_complete(root):
|
||||
return root
|
||||
|
||||
npm = shutil.which("npm", path=_tool_path())
|
||||
if not npm:
|
||||
raise NativeSandboxUnavailable(
|
||||
"native sandbox requires npm for its first deterministic runtime install"
|
||||
)
|
||||
assets = _runtime_assets_root()
|
||||
for name in ("package.json", "package-lock.json", "patch_merged_usr.py"):
|
||||
if not (assets / name).is_file():
|
||||
raise NativeSandboxUnavailable(f"packaged native sandbox asset is missing: {name}")
|
||||
|
||||
root.parent.mkdir(parents=True, exist_ok=True)
|
||||
lock = FileLock(str(root.parent / ".native-sandbox-install.lock"), timeout=180)
|
||||
with lock:
|
||||
if _runtime_install_complete(root):
|
||||
return root
|
||||
staging = root.parent / f".native-sandbox-install-{uuid.uuid4().hex}"
|
||||
backup = root.parent / f".native-sandbox-backup-{uuid.uuid4().hex}"
|
||||
try:
|
||||
staging.mkdir(mode=0o700)
|
||||
for name in ("package.json", "package-lock.json"):
|
||||
shutil.copyfile(assets / name, staging / name)
|
||||
result = subprocess.run(
|
||||
[
|
||||
npm,
|
||||
"ci",
|
||||
"--ignore-scripts",
|
||||
"--omit=dev",
|
||||
"--no-audit",
|
||||
"--no-fund",
|
||||
],
|
||||
cwd=staging,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=180,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
detail = (result.stderr or result.stdout).strip()[-1000:]
|
||||
raise NativeSandboxUnavailable(
|
||||
f"native sandbox npm ci failed: {detail or 'unknown npm error'}"
|
||||
)
|
||||
|
||||
from .native_sandbox_runtime.patch_merged_usr import patch_file
|
||||
|
||||
patch_file(
|
||||
staging
|
||||
/ "node_modules"
|
||||
/ "@anthropic-ai"
|
||||
/ "sandbox-runtime"
|
||||
/ "dist"
|
||||
/ "sandbox"
|
||||
/ "linux-sandbox-utils.js"
|
||||
)
|
||||
if not _runtime_install_complete(staging):
|
||||
raise NativeSandboxUnavailable(
|
||||
"native sandbox runtime failed post-install verification"
|
||||
)
|
||||
if root.exists():
|
||||
os.replace(root, backup)
|
||||
try:
|
||||
os.replace(staging, root)
|
||||
except BaseException:
|
||||
if backup.exists() and not root.exists():
|
||||
os.replace(backup, root)
|
||||
raise
|
||||
shutil.rmtree(backup, ignore_errors=True)
|
||||
return root
|
||||
except NativeSandboxUnavailable:
|
||||
raise
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise NativeSandboxUnavailable(
|
||||
"native sandbox runtime installation failed"
|
||||
) from exc
|
||||
finally:
|
||||
if staging.exists():
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
if backup.exists() and not root.exists():
|
||||
os.replace(backup, root)
|
||||
|
||||
|
||||
def _positive_int(name: str, default: int) -> int:
|
||||
raw = os.getenv(name, str(default)).strip()
|
||||
try:
|
||||
@@ -185,7 +292,7 @@ def _assert_install_contract() -> NativeSandboxInstallation:
|
||||
f"native sandbox does not support {platform.system() or sys.platform}"
|
||||
)
|
||||
|
||||
root = _runtime_root()
|
||||
root = _ensure_runtime_installed()
|
||||
package_root = root / "node_modules" / "@anthropic-ai" / "sandbox-runtime"
|
||||
package_json = package_root / "package.json"
|
||||
srt = root / "node_modules" / ".bin" / "srt"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Packaged manifests and installer patch for the native sandbox runtime."""
|
||||
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"name": "evoscientist-native-sandbox-runtime",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "evoscientist-native-sandbox-runtime",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sandbox-runtime": "0.0.73"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/sandbox-runtime": {
|
||||
"version": "0.0.73",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sandbox-runtime/-/sandbox-runtime-0.0.73.tgz",
|
||||
"integrity": "sha512-F608iUirrCqwvInZYGRRgJWDQj0tt6fNVE9aPagpotLJ5LhC4JbrMFIIZww5MFjb+HRCkpE0+xdI79c30tdVYg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@pondwader/socks5-server": "^1.0.10",
|
||||
"commander": "^12.1.0",
|
||||
"node-forge": "^1.4.0",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"bin": {
|
||||
"srt": "dist/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@pondwader/socks5-server": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@pondwader/socks5-server/-/socks5-server-1.0.10.tgz",
|
||||
"integrity": "sha512-bQY06wzzR8D2+vVCUoBsr5QS2U6UgPUQRmErNwtsuI6vLcyRKkafjkr3KxbtGFf9aBBIV2mcvlsKD1UYaIV+sg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "12.1.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
|
||||
"integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/node-forge": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz",
|
||||
"integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==",
|
||||
"license": "(BSD-3-Clause OR GPL-2.0)",
|
||||
"engines": {
|
||||
"node": ">= 6.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "evoscientist-native-sandbox-runtime",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sandbox-runtime": "0.0.73"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
_ORIGINAL = """ const rootSkip = new Set(['proc', 'dev', 'sys']);
|
||||
for (const p of readConfig?.denyOnly || []) {
|
||||
if (normalizePathForSandbox(p) === '/') {
|
||||
for (const child of fs.readdirSync('/')) {
|
||||
if (!rootSkip.has(child))
|
||||
readDenyPaths.push('/' + child);
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
_REPLACEMENT = """ const rootSkip = new Set(['proc', 'dev', 'sys']);
|
||||
const rootChildIsAllowedSymlink = (childPath) => {
|
||||
try {
|
||||
if (!fs.lstatSync(childPath).isSymbolicLink())
|
||||
return false;
|
||||
const resolved = fs.realpathSync(childPath);
|
||||
return readAllowPaths.some(allowPath => resolved === allowPath || resolved.startsWith(allowPath + '/'));
|
||||
}
|
||||
catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
for (const p of readConfig?.denyOnly || []) {
|
||||
if (normalizePathForSandbox(p) === '/') {
|
||||
for (const child of fs.readdirSync('/')) {
|
||||
const childPath = '/' + child;
|
||||
if (!rootSkip.has(child) && !rootChildIsAllowedSymlink(childPath))
|
||||
readDenyPaths.push(childPath);
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
_TMPFS_ORIGINAL = """ args.push('--ro-bind', allowPath, allowPath);
|
||||
logForDebugging(`[Sandbox Linux] Re-allowed read access within denied region: ${allowPath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
_TMPFS_REPLACEMENT = """ args.push('--ro-bind', allowPath, allowPath);
|
||||
logForDebugging(`[Sandbox Linux] Re-allowed read access within denied region: ${allowPath}`);
|
||||
}
|
||||
}
|
||||
// A denyRead tmpfs must not become an unlisted writable location. Remount
|
||||
// only the parent mount read-only; explicit writable child binds remain rw.
|
||||
if (!allowedWritePaths.includes(normalizedPath)) {
|
||||
args.push('--remount-ro', normalizedPath);
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def patch_file(path: Path) -> None:
|
||||
source = path.read_text(encoding="utf-8")
|
||||
if _REPLACEMENT in source or _TMPFS_REPLACEMENT in source:
|
||||
raise RuntimeError("sandbox runtime merged-usr patch is already patched")
|
||||
if source.count(_ORIGINAL) != 1:
|
||||
raise RuntimeError("sandbox runtime merged-usr patch target does not match pinned source")
|
||||
if source.count(_TMPFS_ORIGINAL) != 1:
|
||||
raise RuntimeError("sandbox runtime read-only tmpfs patch target does not match pinned source")
|
||||
patched = source.replace(_ORIGINAL, _REPLACEMENT)
|
||||
patched = patched.replace(_TMPFS_ORIGINAL, _TMPFS_REPLACEMENT)
|
||||
path.write_text(patched, encoding="utf-8")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("path", type=Path)
|
||||
args = parser.parse_args()
|
||||
patch_file(args.path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+26
-20
@@ -287,30 +287,36 @@ class PruningCheckpointer(AsyncSqliteSaver):
|
||||
``agent_name`` key — by design, those rows belong to third-party
|
||||
LangGraph users and must never be pruned by us.
|
||||
|
||||
Walk + DELETEs held under ``self.lock`` for atomicity with
|
||||
concurrent ``aput()`` on the same thread.
|
||||
Walk + DELETEs run in a ``BEGIN IMMEDIATE`` transaction. The database
|
||||
write lock serializes independent checkpointer connections before
|
||||
anchor selection, so a newly committed head cannot be deleted using
|
||||
a stale retained-id set.
|
||||
"""
|
||||
keep = self._keep_per_ns
|
||||
agent = AGENT_NAME
|
||||
async with self.lock:
|
||||
# ``writes`` table is checked inside ``_delete_outside`` so a
|
||||
# legacy DB that only has ``checkpoints`` still gets pruned
|
||||
# (writes DELETE silently skipped; checkpoints DELETE runs).
|
||||
# The migration sweep depends on this — it walks legacy DBs
|
||||
# that often pre-date the ``writes`` table entirely.
|
||||
anchor_ids = await self._fetch_recent_checkpoint_ids(
|
||||
thread_id, checkpoint_ns, agent, keep
|
||||
)
|
||||
if len(anchor_ids) < keep:
|
||||
return # nothing to prune yet
|
||||
|
||||
extra_preserve = await self._walk_to_snapshot_ancestor(
|
||||
thread_id, checkpoint_ns, anchor_ids[-1]
|
||||
)
|
||||
kept = set(anchor_ids) | extra_preserve
|
||||
|
||||
await self._delete_outside(thread_id, checkpoint_ns, agent, kept)
|
||||
await self.conn.commit()
|
||||
try:
|
||||
await self.conn.execute("BEGIN IMMEDIATE")
|
||||
# ``writes`` table is checked inside ``_delete_outside`` so a
|
||||
# legacy DB that only has ``checkpoints`` still gets pruned
|
||||
# (writes DELETE silently skipped; checkpoints DELETE runs).
|
||||
# The migration sweep depends on this — it walks legacy DBs
|
||||
# that often pre-date the ``writes`` table entirely.
|
||||
anchor_ids = await self._fetch_recent_checkpoint_ids(
|
||||
thread_id, checkpoint_ns, agent, keep
|
||||
)
|
||||
if len(anchor_ids) >= keep:
|
||||
extra_preserve = await self._walk_to_snapshot_ancestor(
|
||||
thread_id, checkpoint_ns, anchor_ids[-1]
|
||||
)
|
||||
kept = set(anchor_ids) | extra_preserve
|
||||
await self._delete_outside(
|
||||
thread_id, checkpoint_ns, agent, kept
|
||||
)
|
||||
await self.conn.commit()
|
||||
except BaseException:
|
||||
await self.conn.rollback()
|
||||
raise
|
||||
|
||||
async def _fetch_recent_checkpoint_ids(
|
||||
self,
|
||||
|
||||
+113
-13
@@ -5,7 +5,11 @@ using Tavily for URL discovery and fetching full webpage content.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import math
|
||||
import socket
|
||||
from typing import Annotated, Literal
|
||||
from urllib.parse import urljoin, urlsplit
|
||||
|
||||
import httpx
|
||||
from langchain_core.tools import InjectedToolArg, tool
|
||||
@@ -20,10 +24,13 @@ MAX_DISPLAY_TITLE_CHARS = 512
|
||||
MAX_DISPLAY_URL_CHARS = 2_048
|
||||
MAX_PAGE_CONTENT_CHARS = 4_000
|
||||
MAX_SEARCH_RESULT_CHARS = 16_000
|
||||
MAX_PAGE_RESPONSE_BYTES = 1_000_000
|
||||
MAX_REDIRECTS = 5
|
||||
_TRUNCATION_MARKER = "\n\n[page content truncated]"
|
||||
_SEARCH_TRUNCATION_MARKER = (
|
||||
"\n[search result content truncated to preserve all titles and URLs]"
|
||||
)
|
||||
_ALLOWED_WEB_PORTS = {"http": 80, "https": 443}
|
||||
|
||||
|
||||
def _get_tavily_client() -> TavilyClient:
|
||||
@@ -34,35 +41,128 @@ def _get_tavily_client() -> TavilyClient:
|
||||
return _tavily_client
|
||||
|
||||
|
||||
async def _resolve_public_web_target(url: str) -> tuple[str, str, int]:
|
||||
"""Validate a web URL and return its host plus one verified public address."""
|
||||
parsed = urlsplit(url)
|
||||
scheme = parsed.scheme.lower()
|
||||
if scheme not in _ALLOWED_WEB_PORTS:
|
||||
raise ValueError("only HTTP(S) URLs are allowed")
|
||||
if parsed.username is not None or parsed.password is not None:
|
||||
raise ValueError("URL userinfo is not allowed")
|
||||
host = parsed.hostname
|
||||
if not host:
|
||||
raise ValueError("URL host is required")
|
||||
try:
|
||||
port = parsed.port or _ALLOWED_WEB_PORTS[scheme]
|
||||
except ValueError as exc:
|
||||
raise ValueError("URL port is invalid") from exc
|
||||
if port != _ALLOWED_WEB_PORTS[scheme]:
|
||||
raise ValueError(f"URL port {port} is not allowed")
|
||||
|
||||
try:
|
||||
literal = ipaddress.ip_address(host)
|
||||
except ValueError:
|
||||
loop = asyncio.get_running_loop()
|
||||
answers = await loop.getaddrinfo(host, port, type=socket.SOCK_STREAM)
|
||||
addresses = list(dict.fromkeys(answer[4][0] for answer in answers))
|
||||
else:
|
||||
addresses = [str(literal)]
|
||||
if not addresses:
|
||||
raise ValueError("URL host did not resolve")
|
||||
parsed_addresses = [ipaddress.ip_address(address) for address in addresses]
|
||||
if any(not address.is_global for address in parsed_addresses):
|
||||
raise ValueError("URL host is not publicly routable")
|
||||
return host, str(parsed_addresses[0]), port
|
||||
|
||||
|
||||
def _pinned_request(url: str, host: str, address: str) -> tuple[httpx.URL, dict]:
|
||||
"""Build a request target that cannot re-resolve the validated hostname."""
|
||||
original = httpx.URL(url)
|
||||
pinned = original.copy_with(host=address)
|
||||
extensions = {"sni_hostname": host} if original.scheme == "https" else {}
|
||||
return pinned, extensions
|
||||
|
||||
|
||||
async def fetch_webpage_content(url: str, timeout: float = 10.0) -> str:
|
||||
"""Fetch and convert webpage content to markdown.
|
||||
"""Fetch and convert webpage content to markdown within a hard deadline.
|
||||
|
||||
Args:
|
||||
url: URL to fetch
|
||||
timeout: Request timeout in seconds
|
||||
timeout: Total wall-clock and per-I/O timeout in seconds
|
||||
|
||||
Returns:
|
||||
Webpage content as markdown
|
||||
"""
|
||||
headers = {
|
||||
"Accept-Encoding": "identity",
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/91.0.4472.124 Safari/537.36"
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url, headers=headers, timeout=timeout)
|
||||
response.raise_for_status()
|
||||
content_type = response.headers.get("content-type", "").lower()
|
||||
if not any(
|
||||
allowed in content_type
|
||||
for allowed in ("text/", "application/xhtml+xml")
|
||||
):
|
||||
return f"Error fetching content from {url}: unsupported content type {content_type or 'unknown'}"
|
||||
return markdownify(response.text)
|
||||
if not math.isfinite(timeout) or timeout <= 0:
|
||||
raise ValueError("timeout must be finite and positive")
|
||||
async with asyncio.timeout(timeout):
|
||||
current_url = url
|
||||
for redirect_count in range(MAX_REDIRECTS + 1):
|
||||
host, address, _port = await _resolve_public_web_target(current_url)
|
||||
pinned_url, extensions = _pinned_request(current_url, host, address)
|
||||
request_headers = {**headers, "Host": host}
|
||||
# The pinned IP is the HTTP pool origin. A fresh pool per hop
|
||||
# prevents cross-host redirects that share an IP from reusing
|
||||
# a TLS connection authenticated for the previous hostname.
|
||||
async with httpx.AsyncClient(trust_env=False) as client:
|
||||
async with client.stream(
|
||||
"GET",
|
||||
pinned_url,
|
||||
headers=request_headers,
|
||||
extensions=extensions,
|
||||
timeout=timeout,
|
||||
) as response:
|
||||
if response.is_redirect:
|
||||
location = response.headers.get("location")
|
||||
if not location:
|
||||
response.raise_for_status()
|
||||
if redirect_count >= MAX_REDIRECTS:
|
||||
raise ValueError("too many redirects")
|
||||
current_url = urljoin(current_url, location)
|
||||
continue
|
||||
response.raise_for_status()
|
||||
content_type = response.headers.get("content-type", "").lower()
|
||||
content_encoding = response.headers.get(
|
||||
"content-encoding", ""
|
||||
).lower()
|
||||
if content_encoding not in {"", "identity"}:
|
||||
return (
|
||||
f"Error fetching content from {url}: "
|
||||
"compressed responses are not allowed"
|
||||
)
|
||||
if not any(
|
||||
allowed in content_type
|
||||
for allowed in ("text/", "application/xhtml+xml")
|
||||
):
|
||||
return (
|
||||
f"Error fetching content from {url}: "
|
||||
f"unsupported content type {content_type or 'unknown'}"
|
||||
)
|
||||
body = bytearray()
|
||||
async for chunk in response.aiter_raw():
|
||||
remaining = MAX_PAGE_RESPONSE_BYTES - len(body)
|
||||
if remaining <= 0:
|
||||
break
|
||||
body.extend(chunk[:remaining])
|
||||
return markdownify(
|
||||
body.decode(response.encoding or "utf-8", errors="replace")
|
||||
)
|
||||
raise ValueError("too many redirects") # pragma: no cover
|
||||
except TimeoutError:
|
||||
return (
|
||||
f"Error fetching content from {url}: "
|
||||
f"wall-clock timeout after {timeout:g}s"
|
||||
)
|
||||
except Exception as e:
|
||||
return f"Error fetching content from {url}: {e!s}"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user