Pin LaunchCheck requests to validated DNS
This commit is contained in:
4
a2a.yaml
4
a2a.yaml
@@ -1,5 +1,5 @@
|
|||||||
name: launch-check-studio-v1
|
name: launch-check-studio-v1
|
||||||
version: 0.1.0
|
version: 0.1.1
|
||||||
entrypoint: agent:LaunchCheckStudioV1
|
entrypoint: agent:LaunchCheckStudioV1
|
||||||
expose:
|
expose:
|
||||||
public: false
|
public: false
|
||||||
@@ -29,4 +29,4 @@ runtime:
|
|||||||
max_runtime_seconds: 120
|
max_runtime_seconds: 120
|
||||||
egress:
|
egress:
|
||||||
deny_internet_by_default: false
|
deny_internet_by_default: false
|
||||||
tools_used: [httpx, psycopg, postgres]
|
tools_used: [aiohttp, psycopg, postgres]
|
||||||
|
|||||||
216
agent.py
216
agent.py
@@ -11,7 +11,7 @@ import socket
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from html.parser import HTMLParser
|
from html.parser import HTMLParser
|
||||||
from typing import Any
|
from typing import Annotated, Any
|
||||||
from urllib.parse import urljoin, urlparse, urlunparse
|
from urllib.parse import urljoin, urlparse, urlunparse
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
@@ -61,6 +61,41 @@ class FetchResult:
|
|||||||
error: dict[str, Any] | None = None
|
error: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class _PinnedResolver:
|
||||||
|
"""Resolve one hostname only to addresses validated before the request."""
|
||||||
|
|
||||||
|
def __init__(self, hostname: str, addresses: tuple[str, ...]) -> None:
|
||||||
|
self._hostname = hostname.lower().rstrip(".")
|
||||||
|
self._addresses = addresses
|
||||||
|
|
||||||
|
async def resolve(
|
||||||
|
self,
|
||||||
|
host: str,
|
||||||
|
port: int = 0,
|
||||||
|
family: int = socket.AF_UNSPEC,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
if host.lower().rstrip(".") != self._hostname:
|
||||||
|
raise OSError("resolver hostname mismatch")
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"hostname": host,
|
||||||
|
"host": address,
|
||||||
|
"port": port,
|
||||||
|
"family": (
|
||||||
|
socket.AF_INET6
|
||||||
|
if ipaddress.ip_address(address).version == 6
|
||||||
|
else socket.AF_INET
|
||||||
|
),
|
||||||
|
"proto": 0,
|
||||||
|
"flags": 0,
|
||||||
|
}
|
||||||
|
for address in self._addresses
|
||||||
|
]
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class _HeadParser(HTMLParser):
|
class _HeadParser(HTMLParser):
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@@ -110,7 +145,7 @@ class LaunchCheckStudioV1(A2AAgent[LaunchCheckStudioV1Config, PlatformUserAuth])
|
|||||||
"LaunchCheck audits public HTTP(S) launch pages with bounded SSRF-safe "
|
"LaunchCheck audits public HTTP(S) launch pages with bounded SSRF-safe "
|
||||||
"network checks and user-scoped Postgres persistence."
|
"network checks and user-scoped Postgres persistence."
|
||||||
)
|
)
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
|
|
||||||
config_model = LaunchCheckStudioV1Config
|
config_model = LaunchCheckStudioV1Config
|
||||||
auth_model = PlatformUserAuth
|
auth_model = PlatformUserAuth
|
||||||
@@ -119,7 +154,7 @@ class LaunchCheckStudioV1(A2AAgent[LaunchCheckStudioV1Config, PlatformUserAuth])
|
|||||||
state_model = LaunchCheckStudioV1State
|
state_model = LaunchCheckStudioV1State
|
||||||
resources = Resources(cpu="500m", memory="512Mi", max_runtime_seconds=120)
|
resources = Resources(cpu="500m", memory="512Mi", max_runtime_seconds=120)
|
||||||
egress = EgressPolicy(deny_internet_by_default=False)
|
egress = EgressPolicy(deny_internet_by_default=False)
|
||||||
tools_used = ("httpx", "psycopg", "postgres")
|
tools_used = ("aiohttp", "psycopg", "postgres")
|
||||||
pricing = Pricing(
|
pricing = Pricing(
|
||||||
price_per_call_usd=0.0,
|
price_per_call_usd=0.0,
|
||||||
caller_pays_llm=False,
|
caller_pays_llm=False,
|
||||||
@@ -140,14 +175,17 @@ class LaunchCheckStudioV1(A2AAgent[LaunchCheckStudioV1Config, PlatformUserAuth])
|
|||||||
@a2a.tool(
|
@a2a.tool(
|
||||||
description="Run a bounded SSRF-safe launch readiness audit for a public HTTP(S) URL and persist the report.",
|
description="Run a bounded SSRF-safe launch readiness audit for a public HTTP(S) URL and persist the report.",
|
||||||
timeout_seconds=60,
|
timeout_seconds=60,
|
||||||
idempotent=True,
|
idempotent=False,
|
||||||
cost_class="network-read",
|
cost_class="network-read",
|
||||||
)
|
)
|
||||||
async def audit_url(
|
async def audit_url(
|
||||||
self,
|
self,
|
||||||
ctx: RunContext[PlatformUserAuth],
|
ctx: RunContext[PlatformUserAuth],
|
||||||
audit_id: str,
|
audit_id: Annotated[
|
||||||
url: str,
|
str,
|
||||||
|
Field(min_length=1, max_length=128, pattern=r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$"),
|
||||||
|
],
|
||||||
|
url: Annotated[str, Field(min_length=8, max_length=2048)],
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
tenant = _tenant_key(ctx)
|
tenant = _tenant_key(ctx)
|
||||||
audit_id = _clean_audit_id(audit_id)
|
audit_id = _clean_audit_id(audit_id)
|
||||||
@@ -202,7 +240,10 @@ class LaunchCheckStudioV1(A2AAgent[LaunchCheckStudioV1Config, PlatformUserAuth])
|
|||||||
async def get_audit(
|
async def get_audit(
|
||||||
self,
|
self,
|
||||||
ctx: RunContext[PlatformUserAuth],
|
ctx: RunContext[PlatformUserAuth],
|
||||||
audit_id: str,
|
audit_id: Annotated[
|
||||||
|
str,
|
||||||
|
Field(min_length=1, max_length=128, pattern=r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$"),
|
||||||
|
],
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
tenant = _tenant_key(ctx)
|
tenant = _tenant_key(ctx)
|
||||||
audit_id = _clean_audit_id(audit_id)
|
audit_id = _clean_audit_id(audit_id)
|
||||||
@@ -273,42 +314,66 @@ async def _validate_public_url(raw_url: str) -> dict[str, Any]:
|
|||||||
host = parsed.hostname.strip().rstrip(".")
|
host = parsed.hostname.strip().rstrip(".")
|
||||||
if not host:
|
if not host:
|
||||||
return {"ok": False, "code": "invalid_url", "message": "URL must include a hostname."}
|
return {"ok": False, "code": "invalid_url", "message": "URL must include a hostname."}
|
||||||
if parsed.port is not None and not (1 <= parsed.port <= 65535):
|
try:
|
||||||
|
port = parsed.port
|
||||||
|
except ValueError:
|
||||||
return {"ok": False, "code": "invalid_url", "message": "URL port is invalid."}
|
return {"ok": False, "code": "invalid_url", "message": "URL port is invalid."}
|
||||||
blocked = await _host_is_disallowed(host, parsed.port or (443 if parsed.scheme == "https" else 80))
|
if port is not None and not (1 <= port <= 65535):
|
||||||
if blocked:
|
return {"ok": False, "code": "invalid_url", "message": "URL port is invalid."}
|
||||||
|
try:
|
||||||
|
host = str(ipaddress.ip_address(host.strip("[]")))
|
||||||
|
except ValueError:
|
||||||
|
try:
|
||||||
|
host = host.encode("idna").decode("ascii").lower()
|
||||||
|
except UnicodeError:
|
||||||
|
return {"ok": False, "code": "invalid_url", "message": "URL hostname is invalid."}
|
||||||
|
addresses = await _resolve_public_addresses(
|
||||||
|
host,
|
||||||
|
port or (443 if parsed.scheme == "https" else 80),
|
||||||
|
)
|
||||||
|
if addresses is None:
|
||||||
return {"ok": False, "code": "disallowed_private_target", "message": "Target resolves to a non-public network address."}
|
return {"ok": False, "code": "disallowed_private_target", "message": "Target resolves to a non-public network address."}
|
||||||
netloc = host
|
if not addresses:
|
||||||
if parsed.port:
|
return {"ok": False, "code": "dns_resolution_failed", "message": "Target hostname could not be resolved safely."}
|
||||||
netloc = f"{host}:{parsed.port}"
|
netloc = f"[{host}]" if ":" in host else host
|
||||||
|
if port:
|
||||||
|
netloc = f"{netloc}:{port}"
|
||||||
normalized = urlunparse((parsed.scheme.lower(), netloc, parsed.path or "", "", parsed.query, ""))
|
normalized = urlunparse((parsed.scheme.lower(), netloc, parsed.path or "", "", parsed.query, ""))
|
||||||
return {"ok": True, "url": normalized}
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"url": normalized,
|
||||||
|
"hostname": host,
|
||||||
|
"port": port or (443 if parsed.scheme == "https" else 80),
|
||||||
|
"addresses": addresses,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def _host_is_disallowed(host: str, port: int) -> bool:
|
async def _resolve_public_addresses(host: str, port: int) -> tuple[str, ...] | None:
|
||||||
try:
|
try:
|
||||||
ip = ipaddress.ip_address(host.strip("[]"))
|
ip = ipaddress.ip_address(host.strip("[]"))
|
||||||
return _ip_disallowed(ip)
|
return None if _ip_disallowed(ip) else (str(ip),)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
lowered = host.lower()
|
lowered = host.lower()
|
||||||
if lowered in {"localhost", "metadata.google.internal"} or lowered.endswith(".localhost"):
|
if lowered in {"localhost", "metadata.google.internal"} or lowered.endswith(".localhost"):
|
||||||
return True
|
return None
|
||||||
try:
|
try:
|
||||||
infos = await asyncio.wait_for(
|
infos = await asyncio.wait_for(
|
||||||
asyncio.get_running_loop().getaddrinfo(host, port, type=socket.SOCK_STREAM),
|
asyncio.get_running_loop().getaddrinfo(host, port, type=socket.SOCK_STREAM),
|
||||||
timeout=DNS_TIMEOUT_SECONDS,
|
timeout=DNS_TIMEOUT_SECONDS,
|
||||||
)
|
)
|
||||||
except Exception: # DNS failure is not private, fetch will return a structured network error
|
except Exception:
|
||||||
return False
|
return ()
|
||||||
addresses: set[str] = set()
|
addresses: set[str] = set()
|
||||||
for info in infos:
|
for info in infos:
|
||||||
sockaddr = info[4]
|
sockaddr = info[4]
|
||||||
if sockaddr:
|
if sockaddr:
|
||||||
addresses.add(str(sockaddr[0]))
|
addresses.add(str(sockaddr[0]))
|
||||||
if not addresses:
|
if not addresses:
|
||||||
return True
|
return ()
|
||||||
return any(_ip_disallowed(ipaddress.ip_address(address)) for address in addresses)
|
if any(_ip_disallowed(ipaddress.ip_address(address)) for address in addresses):
|
||||||
|
return None
|
||||||
|
return tuple(sorted(addresses))
|
||||||
|
|
||||||
|
|
||||||
def _ip_disallowed(ip: ipaddress._BaseAddress) -> bool:
|
def _ip_disallowed(ip: ipaddress._BaseAddress) -> bool:
|
||||||
@@ -325,44 +390,76 @@ def _ip_disallowed(ip: ipaddress._BaseAddress) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
async def _fetch_bounded(url: str, max_redirects: int, max_bytes: int) -> FetchResult:
|
async def _fetch_bounded(url: str, max_redirects: int, max_bytes: int) -> FetchResult:
|
||||||
import httpx
|
|
||||||
|
|
||||||
current = url
|
current = url
|
||||||
chain: list[dict[str, Any]] = []
|
chain: list[dict[str, Any]] = []
|
||||||
timeout = httpx.Timeout(TOTAL_TIMEOUT_SECONDS, connect=CONNECT_TIMEOUT_SECONDS, read=READ_TIMEOUT_SECONDS)
|
for hop in range(max_redirects + 1):
|
||||||
async with httpx.AsyncClient(timeout=timeout, follow_redirects=False, trust_env=False, headers={"user-agent": USER_AGENT}) as client:
|
validation = await _validate_public_url(current)
|
||||||
for hop in range(max_redirects + 1):
|
if not validation["ok"]:
|
||||||
validation = await _validate_public_url(current)
|
return FetchResult(current, None, {}, b"", chain, {"code": validation["code"], "message": validation["message"]})
|
||||||
if not validation["ok"]:
|
try:
|
||||||
return FetchResult(current, None, {}, b"", chain, {"code": validation["code"], "message": validation["message"]})
|
status, headers, body, response_url = await _request_pinned(
|
||||||
try:
|
validation,
|
||||||
async with client.stream("GET", validation["url"]) as response:
|
max_bytes=max_bytes,
|
||||||
body = await _read_limited(response, max_bytes)
|
total_timeout=TOTAL_TIMEOUT_SECONDS,
|
||||||
except httpx.TimeoutException:
|
connect_timeout=CONNECT_TIMEOUT_SECONDS,
|
||||||
return FetchResult(current, None, {}, b"", chain, {"code": "network_timeout", "message": "The target did not respond within the bounded timeout."})
|
read_timeout=READ_TIMEOUT_SECONDS,
|
||||||
except httpx.HTTPError:
|
)
|
||||||
return FetchResult(current, None, {}, b"", chain, {"code": "network_error", "message": "The target could not be reached safely."})
|
except TimeoutError:
|
||||||
headers = {k.lower(): v[:1000] for k, v in response.headers.items()}
|
return FetchResult(current, None, {}, b"", chain, {"code": "network_timeout", "message": "The target did not respond within the bounded timeout."})
|
||||||
status = response.status_code
|
except Exception: # noqa: BLE001 - return a stable public network error
|
||||||
if status in {301, 302, 303, 307, 308} and response.headers.get("location"):
|
return FetchResult(current, None, {}, b"", chain, {"code": "network_error", "message": "The target could not be reached safely."})
|
||||||
location = response.headers["location"]
|
if status in {301, 302, 303, 307, 308} and headers.get("location"):
|
||||||
next_url = urljoin(str(response.url), location)
|
next_url = urljoin(response_url, headers["location"])
|
||||||
chain.append({"from": validation["url"], "to": _safe_url_echo(next_url), "status_code": status})
|
chain.append({"from": validation["url"], "to": _safe_url_echo(next_url), "status_code": status})
|
||||||
if hop >= max_redirects:
|
if hop >= max_redirects:
|
||||||
return FetchResult(validation["url"], status, headers, body, chain, {"code": "too_many_redirects", "message": "Redirect count exceeded the audit limit."})
|
return FetchResult(validation["url"], status, headers, body, chain, {"code": "too_many_redirects", "message": "Redirect count exceeded the audit limit."})
|
||||||
next_validation = await _validate_public_url(next_url)
|
current = next_url
|
||||||
if not next_validation["ok"]:
|
continue
|
||||||
return FetchResult(validation["url"], status, headers, body, chain, {"code": next_validation["code"], "message": next_validation["message"]})
|
return FetchResult(response_url, status, headers, body, chain)
|
||||||
current = next_validation["url"]
|
|
||||||
continue
|
|
||||||
return FetchResult(str(response.url), status, headers, body, chain)
|
|
||||||
return FetchResult(current, None, {}, b"", chain, {"code": "too_many_redirects", "message": "Redirect count exceeded the audit limit."})
|
return FetchResult(current, None, {}, b"", chain, {"code": "too_many_redirects", "message": "Redirect count exceeded the audit limit."})
|
||||||
|
|
||||||
|
|
||||||
|
async def _request_pinned(
|
||||||
|
validation: dict[str, Any],
|
||||||
|
*,
|
||||||
|
max_bytes: int,
|
||||||
|
total_timeout: float,
|
||||||
|
connect_timeout: float,
|
||||||
|
read_timeout: float,
|
||||||
|
) -> tuple[int, dict[str, str], bytes, str]:
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
resolver = _PinnedResolver(
|
||||||
|
str(validation["hostname"]),
|
||||||
|
tuple(str(item) for item in validation["addresses"]),
|
||||||
|
)
|
||||||
|
connector = aiohttp.TCPConnector(resolver=resolver, use_dns_cache=False)
|
||||||
|
timeout = aiohttp.ClientTimeout(
|
||||||
|
total=total_timeout,
|
||||||
|
connect=connect_timeout,
|
||||||
|
sock_connect=connect_timeout,
|
||||||
|
sock_read=read_timeout,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession(
|
||||||
|
connector=connector,
|
||||||
|
connector_owner=True,
|
||||||
|
timeout=timeout,
|
||||||
|
trust_env=False,
|
||||||
|
headers={"user-agent": USER_AGENT, "accept-encoding": "identity"},
|
||||||
|
) as client:
|
||||||
|
async with client.get(validation["url"], allow_redirects=False) as response:
|
||||||
|
body = await _read_limited(response, max_bytes)
|
||||||
|
headers = {key.lower(): value[:1000] for key, value in response.headers.items()}
|
||||||
|
return response.status, headers, body, str(response.url)
|
||||||
|
except asyncio.TimeoutError as exc:
|
||||||
|
raise TimeoutError("bounded request timed out") from exc
|
||||||
|
|
||||||
|
|
||||||
async def _read_limited(response: Any, max_bytes: int) -> bytes:
|
async def _read_limited(response: Any, max_bytes: int) -> bytes:
|
||||||
chunks: list[bytes] = []
|
chunks: list[bytes] = []
|
||||||
total = 0
|
total = 0
|
||||||
async for chunk in response.aiter_bytes():
|
async for chunk in response.content.iter_chunked(64 * 1024):
|
||||||
total += len(chunk)
|
total += len(chunk)
|
||||||
if total > max_bytes:
|
if total > max_bytes:
|
||||||
remaining = max_bytes - (total - len(chunk))
|
remaining = max_bytes - (total - len(chunk))
|
||||||
@@ -379,16 +476,17 @@ async def _check_robots(final_url: str) -> dict[str, Any]:
|
|||||||
validation = await _validate_public_url(robots_url)
|
validation = await _validate_public_url(robots_url)
|
||||||
if not validation["ok"]:
|
if not validation["ok"]:
|
||||||
return {"present": False, "status_code": None, "checked_url": _safe_url_echo(robots_url)}
|
return {"present": False, "status_code": None, "checked_url": _safe_url_echo(robots_url)}
|
||||||
import httpx
|
|
||||||
|
|
||||||
timeout = httpx.Timeout(6.0, connect=2.0, read=3.0)
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=timeout, follow_redirects=False, trust_env=False, headers={"user-agent": USER_AGENT}) as client:
|
status, _headers, body, _response_url = await _request_pinned(
|
||||||
async with client.stream("GET", validation["url"]) as response:
|
validation,
|
||||||
body = await _read_limited(response, MAX_ROBOTS_BYTES)
|
max_bytes=MAX_ROBOTS_BYTES,
|
||||||
|
total_timeout=6.0,
|
||||||
|
connect_timeout=2.0,
|
||||||
|
read_timeout=3.0,
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"present": response.status_code == 200,
|
"present": status == 200,
|
||||||
"status_code": response.status_code,
|
"status_code": status,
|
||||||
"checked_url": validation["url"],
|
"checked_url": validation["url"],
|
||||||
"bytes_read": len(body),
|
"bytes_read": len(body),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "launch-check-studio-v1-frontend",
|
"name": "launch-check-studio-v1-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0 --port 5173",
|
"dev": "vite --host 0.0.0.0 --port 5173",
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export function App() {
|
|||||||
HTTPS, page metadata, robots.txt, and key security headers, then saves the evidence for your account.
|
HTTPS, page metadata, robots.txt, and key security headers, then saves the evidence for your account.
|
||||||
</p>
|
</p>
|
||||||
<div className="trust-bar" aria-label="Safety guarantees">
|
<div className="trust-bar" aria-label="Safety guarantees">
|
||||||
<span>SSRF-safe DNS + redirects</span>
|
<span>Pinned public DNS + safe redirects</span>
|
||||||
<span>User-scoped Postgres</span>
|
<span>User-scoped Postgres</span>
|
||||||
<span>No LLM or secrets in browser</span>
|
<span>No LLM or secrets in browser</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# a2a-pack is auto-installed by the deploy build.
|
# a2a-pack is auto-installed by the deploy build.
|
||||||
httpx>=0.27
|
aiohttp>=3.10,<4
|
||||||
psycopg[binary]>=3.2
|
psycopg[binary]>=3.2
|
||||||
pytest>=8.0
|
pytest>=8.0
|
||||||
|
|||||||
@@ -57,8 +57,8 @@ async def test_acceptance_success_reload_and_receipt(monkeypatch):
|
|||||||
os.environ.pop("DATABASE_URL", None)
|
os.environ.pop("DATABASE_URL", None)
|
||||||
stored = {}
|
stored = {}
|
||||||
|
|
||||||
async def fake_host_allowed(host, port):
|
async def fake_public_addresses(host, port):
|
||||||
return False
|
return ("93.184.216.34",)
|
||||||
|
|
||||||
async def fake_fetch(url, max_redirects, max_bytes):
|
async def fake_fetch(url, max_redirects, max_bytes):
|
||||||
return FetchResult(
|
return FetchResult(
|
||||||
@@ -92,7 +92,7 @@ async def test_acceptance_success_reload_and_receipt(monkeypatch):
|
|||||||
async def fake_load(tenant, audit_id):
|
async def fake_load(tenant, audit_id):
|
||||||
return stored.get((tenant, audit_id))
|
return stored.get((tenant, audit_id))
|
||||||
|
|
||||||
monkeypatch.setattr(launch_agent, "_host_is_disallowed", fake_host_allowed)
|
monkeypatch.setattr(launch_agent, "_resolve_public_addresses", fake_public_addresses)
|
||||||
monkeypatch.setattr(launch_agent, "_fetch_bounded", fake_fetch)
|
monkeypatch.setattr(launch_agent, "_fetch_bounded", fake_fetch)
|
||||||
monkeypatch.setattr(launch_agent, "_check_robots", fake_robots)
|
monkeypatch.setattr(launch_agent, "_check_robots", fake_robots)
|
||||||
monkeypatch.setattr(launch_agent, "_save_audit_and_receipt", fake_save)
|
monkeypatch.setattr(launch_agent, "_save_audit_and_receipt", fake_save)
|
||||||
@@ -146,3 +146,28 @@ async def test_url_policy_rejects_dangerous_inputs(monkeypatch):
|
|||||||
|
|
||||||
monkeypatch.setattr(launch_agent.asyncio, "get_running_loop", lambda: FakeLoop())
|
monkeypatch.setattr(launch_agent.asyncio, "get_running_loop", lambda: FakeLoop())
|
||||||
assert (await launch_agent._validate_public_url("https://rebinding.test"))["code"] == "disallowed_private_target"
|
assert (await launch_agent._validate_public_url("https://rebinding.test"))["code"] == "disallowed_private_target"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_validated_dns_addresses_are_pinned_for_connection(monkeypatch):
|
||||||
|
calls = {"count": 0}
|
||||||
|
|
||||||
|
async def fake_getaddrinfo(host, port, type):
|
||||||
|
calls["count"] += 1
|
||||||
|
return [(None, None, None, None, ("93.184.216.34", port))]
|
||||||
|
|
||||||
|
class FakeLoop:
|
||||||
|
def getaddrinfo(self, host, port, type):
|
||||||
|
return fake_getaddrinfo(host, port, type)
|
||||||
|
|
||||||
|
monkeypatch.setattr(launch_agent.asyncio, "get_running_loop", lambda: FakeLoop())
|
||||||
|
validation = await launch_agent._validate_public_url("https://rebinding.test/path")
|
||||||
|
|
||||||
|
assert validation["ok"] is True
|
||||||
|
assert validation["addresses"] == ("93.184.216.34",)
|
||||||
|
resolver = launch_agent._PinnedResolver(
|
||||||
|
validation["hostname"], validation["addresses"]
|
||||||
|
)
|
||||||
|
resolved = await resolver.resolve(validation["hostname"], validation["port"])
|
||||||
|
assert [item["host"] for item in resolved] == ["93.184.216.34"]
|
||||||
|
assert calls["count"] == 1
|
||||||
|
|||||||
Reference in New Issue
Block a user