Skip to content

Commit fbb91c1

Browse files
authored
fix(adagents): follow safe well-known redirects
1 parent 22ae047 commit fbb91c1

2 files changed

Lines changed: 472 additions & 15 deletions

File tree

src/adcp/adagents.py

Lines changed: 144 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,10 @@
1717
from dataclasses import dataclass, field
1818
from datetime import datetime
1919
from typing import Any, Literal
20-
from urllib.parse import quote, urlparse
20+
from urllib.parse import quote, urljoin, urlparse
2121

2222
import httpx
23+
import idna
2324
from pydantic import Field
2425

2526
from adcp.exceptions import (
@@ -28,6 +29,7 @@
2829
AdagentsTimeoutError,
2930
AdagentsValidationError,
3031
)
32+
from adcp.signing.etld import same_registrable_domain
3133
from adcp.types.base import AdCPBaseModel
3234
from adcp.validation import ValidationError, validate_adagents
3335

@@ -373,6 +375,40 @@ def _validate_redirect_url(url: str) -> None:
373375
_check_safe_host(parsed.hostname or "", "authoritative_location")
374376

375377

378+
def _idna_ascii_host(hostname: str, context: str) -> str:
379+
"""Normalize a hostname to IDNA ASCII for eTLD+1 comparisons."""
380+
try:
381+
return idna.encode(hostname.rstrip("."), uts46=True).decode("ascii").lower()
382+
except idna.IDNAError as e:
383+
raise AdagentsValidationError(f"{context} has invalid IDNA hostname: {hostname!r}") from e
384+
385+
386+
def _resolve_well_known_redirect_url(
387+
*,
388+
current_url: str,
389+
location: str | None,
390+
original_hostname: str,
391+
) -> str:
392+
if not location:
393+
raise AdagentsValidationError("adagents.json redirect missing Location header")
394+
395+
next_url = urljoin(current_url, location)
396+
parsed = urlparse(next_url)
397+
if parsed.scheme != "https":
398+
raise AdagentsValidationError(f"adagents.json redirect must be an HTTPS URL: {next_url!r}")
399+
next_hostname = parsed.hostname or ""
400+
_check_safe_host(next_hostname, "adagents.json redirect")
401+
402+
original = _idna_ascii_host(original_hostname, "publisher_domain")
403+
target = _idna_ascii_host(next_hostname, "adagents.json redirect")
404+
if not same_registrable_domain(original, target):
405+
raise AdagentsValidationError(
406+
"adagents.json redirect must stay within the original registrable domain"
407+
)
408+
409+
return next_url
410+
411+
376412
def normalize_url(url: str) -> str:
377413
"""Normalize URL by removing protocol and trailing slash.
378414
@@ -567,6 +603,11 @@ def verify_agent_authorization(
567603
# Maximum number of authoritative_location redirects to follow
568604
MAX_REDIRECT_DEPTH = 5
569605

606+
# Maximum number of HTTP redirects to follow for the initial
607+
# /.well-known/adagents.json fetch. These redirects are not delegation; they
608+
# only cover same-site hosting normalization such as apex -> www.
609+
MAX_WELL_KNOWN_REDIRECT_HOPS = 3
610+
570611
# Maximum size of a publisher's ads.txt file. IAB practice caps real
571612
# ads.txt files in the low MB range; this gives plenty of headroom while
572613
# preventing a hostile publisher from forcing the SDK to buffer an
@@ -661,14 +702,25 @@ async def _resolve_direct(
661702
hop_cache = cache_entry if not is_redirect else None
662703

663704
try:
664-
data, etag, last_modified, not_modified = await _fetch_adagents_url(
665-
url,
666-
timeout,
667-
user_agent,
668-
fetch_client,
669-
max_bytes=max_bytes,
670-
cache_entry=hop_cache,
671-
)
705+
if is_redirect:
706+
data, etag, last_modified, not_modified = await _fetch_adagents_url(
707+
url,
708+
timeout,
709+
user_agent,
710+
fetch_client,
711+
max_bytes=max_bytes,
712+
cache_entry=hop_cache,
713+
)
714+
else:
715+
data, etag, last_modified, not_modified = await _fetch_well_known_adagents_url(
716+
url,
717+
timeout,
718+
user_agent,
719+
fetch_client,
720+
original_hostname=publisher_domain,
721+
max_bytes=max_bytes,
722+
cache_entry=hop_cache,
723+
)
672724
except AdagentsNotFoundError:
673725
# A 404 on a followed authoritative_location target is a broken
674726
# redirect chain, not a missing publisher manifest. Surface it as
@@ -1087,13 +1139,84 @@ async def _fetch_adagents_url(
10871139
:data:`MAX_AUTHORITATIVE_BYTES` for dereferenced authoritative files
10881140
per adcp#4504).
10891141
"""
1142+
headers = _adagents_headers(user_agent, cache_entry)
1143+
body, status_code, response_headers = await _fetch_adagents_response(
1144+
url, timeout, headers, client, max_bytes
1145+
)
1146+
return _parse_adagents_response(url, body, status_code, response_headers, cache_entry)
1147+
1148+
1149+
async def _fetch_well_known_adagents_url(
1150+
url: str,
1151+
timeout: float,
1152+
user_agent: str,
1153+
client: httpx.AsyncClient | None,
1154+
*,
1155+
original_hostname: str,
1156+
max_bytes: int = MAX_POINTER_BYTES,
1157+
cache_entry: AdagentsCacheEntry | None = None,
1158+
) -> tuple[dict[str, Any], str | None, str | None, bool]:
1159+
"""Fetch the initial well-known URL, following safe same-site HTTP redirects."""
1160+
headers = _adagents_headers(user_agent, cache_entry)
1161+
current_url = url
1162+
current_client = client
1163+
current_cache = cache_entry
1164+
visited_urls: set[str] = set()
1165+
1166+
for hop in range(MAX_WELL_KNOWN_REDIRECT_HOPS + 1):
1167+
if current_url in visited_urls:
1168+
raise AdagentsValidationError("Circular redirect detected in adagents.json fetch")
1169+
visited_urls.add(current_url)
1170+
1171+
body, status_code, response_headers = await _fetch_adagents_response(
1172+
current_url,
1173+
timeout,
1174+
headers,
1175+
current_client,
1176+
max_bytes,
1177+
)
1178+
if status_code not in {301, 302, 303, 307, 308}:
1179+
return _parse_adagents_response(
1180+
current_url, body, status_code, response_headers, current_cache
1181+
)
1182+
1183+
if hop >= MAX_WELL_KNOWN_REDIRECT_HOPS:
1184+
raise AdagentsValidationError(
1185+
f"Maximum well-known redirect hops ({MAX_WELL_KNOWN_REDIRECT_HOPS}) exceeded"
1186+
)
1187+
1188+
current_url = _resolve_well_known_redirect_url(
1189+
current_url=current_url,
1190+
location=response_headers.get("location"),
1191+
original_hostname=original_hostname,
1192+
)
1193+
# Only the publisher's first URL may use the caller's client and
1194+
# conditional validators. Every HTTP redirect hop gets a fresh
1195+
# SDK-owned pinned client inside _fetch_adagents_response.
1196+
current_client = None
1197+
current_cache = None
1198+
headers = _adagents_headers(user_agent, None)
1199+
1200+
raise AssertionError("Unreachable") # pragma: no cover
1201+
1202+
1203+
def _adagents_headers(user_agent: str, cache_entry: AdagentsCacheEntry | None) -> dict[str, str]:
10901204
headers: dict[str, str] = {"User-Agent": user_agent}
10911205
if cache_entry is not None:
10921206
if cache_entry.etag:
10931207
headers["If-None-Match"] = cache_entry.etag
10941208
if cache_entry.last_modified:
10951209
headers["If-Modified-Since"] = cache_entry.last_modified
1210+
return headers
1211+
10961212

1213+
async def _fetch_adagents_response(
1214+
url: str,
1215+
timeout: float,
1216+
headers: dict[str, str],
1217+
client: httpx.AsyncClient | None,
1218+
max_bytes: int,
1219+
) -> tuple[bytes, int, httpx.Headers]:
10971220
parsed = urlparse(url)
10981221
await _dns_validate_host(
10991222
parsed.hostname or "", parsed.port or (443 if parsed.scheme == "https" else 80)
@@ -1121,7 +1244,16 @@ async def _fetch_adagents_url(
11211244
raise AdagentsTimeoutError(parsed.netloc, timeout) from e
11221245
except httpx.RequestError as e:
11231246
raise AdagentsValidationError(f"Failed to fetch adagents.json: {e}") from e
1247+
return body, status_code, response_headers
11241248

1249+
1250+
def _parse_adagents_response(
1251+
url: str,
1252+
body: bytes,
1253+
status_code: int,
1254+
response_headers: httpx.Headers,
1255+
cache_entry: AdagentsCacheEntry | None,
1256+
) -> tuple[dict[str, Any], str | None, str | None, bool]:
11251257
if status_code == 304:
11261258
if cache_entry is None:
11271259
# The server should not return 304 without a conditional
@@ -1208,10 +1340,9 @@ async def _stream_capped(
12081340
rejected up-front; servers that omit the header (or lie) are still
12091341
caught by the running total inside the loop.
12101342
"""
1211-
# follow_redirects=False: HTTP 30x is not how adagents.json delegates.
1212-
# Cross-host delegation goes through the explicit `authoritative_location`
1213-
# field, which passes through _validate_redirect_url. Allowing httpx to
1214-
# transparently follow 30x would bypass that SSRF gate.
1343+
# Always disable httpx's transparent redirect handling. Callers that opt
1344+
# into a redirect policy must inspect Location and re-enter this primitive
1345+
# after applying scheme, eTLD+1, DNS, and SSRF gates for the next hop.
12151346
async with client.stream(
12161347
"GET", url, headers=headers, timeout=timeout, follow_redirects=False
12171348
) as response:

0 commit comments

Comments
 (0)