|
17 | 17 | from dataclasses import dataclass, field |
18 | 18 | from datetime import datetime |
19 | 19 | from typing import Any, Literal |
20 | | -from urllib.parse import quote, urlparse |
| 20 | +from urllib.parse import quote, urljoin, urlparse |
21 | 21 |
|
22 | 22 | import httpx |
| 23 | +import idna |
23 | 24 | from pydantic import Field |
24 | 25 |
|
25 | 26 | from adcp.exceptions import ( |
|
28 | 29 | AdagentsTimeoutError, |
29 | 30 | AdagentsValidationError, |
30 | 31 | ) |
| 32 | +from adcp.signing.etld import same_registrable_domain |
31 | 33 | from adcp.types.base import AdCPBaseModel |
32 | 34 | from adcp.validation import ValidationError, validate_adagents |
33 | 35 |
|
@@ -373,6 +375,40 @@ def _validate_redirect_url(url: str) -> None: |
373 | 375 | _check_safe_host(parsed.hostname or "", "authoritative_location") |
374 | 376 |
|
375 | 377 |
|
| 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 | + |
376 | 412 | def normalize_url(url: str) -> str: |
377 | 413 | """Normalize URL by removing protocol and trailing slash. |
378 | 414 |
|
@@ -567,6 +603,11 @@ def verify_agent_authorization( |
567 | 603 | # Maximum number of authoritative_location redirects to follow |
568 | 604 | MAX_REDIRECT_DEPTH = 5 |
569 | 605 |
|
| 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 | + |
570 | 611 | # Maximum size of a publisher's ads.txt file. IAB practice caps real |
571 | 612 | # ads.txt files in the low MB range; this gives plenty of headroom while |
572 | 613 | # preventing a hostile publisher from forcing the SDK to buffer an |
@@ -661,14 +702,25 @@ async def _resolve_direct( |
661 | 702 | hop_cache = cache_entry if not is_redirect else None |
662 | 703 |
|
663 | 704 | 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 | + ) |
672 | 724 | except AdagentsNotFoundError: |
673 | 725 | # A 404 on a followed authoritative_location target is a broken |
674 | 726 | # redirect chain, not a missing publisher manifest. Surface it as |
@@ -1087,13 +1139,84 @@ async def _fetch_adagents_url( |
1087 | 1139 | :data:`MAX_AUTHORITATIVE_BYTES` for dereferenced authoritative files |
1088 | 1140 | per adcp#4504). |
1089 | 1141 | """ |
| 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]: |
1090 | 1204 | headers: dict[str, str] = {"User-Agent": user_agent} |
1091 | 1205 | if cache_entry is not None: |
1092 | 1206 | if cache_entry.etag: |
1093 | 1207 | headers["If-None-Match"] = cache_entry.etag |
1094 | 1208 | if cache_entry.last_modified: |
1095 | 1209 | headers["If-Modified-Since"] = cache_entry.last_modified |
| 1210 | + return headers |
| 1211 | + |
1096 | 1212 |
|
| 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]: |
1097 | 1220 | parsed = urlparse(url) |
1098 | 1221 | await _dns_validate_host( |
1099 | 1222 | parsed.hostname or "", parsed.port or (443 if parsed.scheme == "https" else 80) |
@@ -1121,7 +1244,16 @@ async def _fetch_adagents_url( |
1121 | 1244 | raise AdagentsTimeoutError(parsed.netloc, timeout) from e |
1122 | 1245 | except httpx.RequestError as e: |
1123 | 1246 | raise AdagentsValidationError(f"Failed to fetch adagents.json: {e}") from e |
| 1247 | + return body, status_code, response_headers |
1124 | 1248 |
|
| 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]: |
1125 | 1257 | if status_code == 304: |
1126 | 1258 | if cache_entry is None: |
1127 | 1259 | # The server should not return 304 without a conditional |
@@ -1208,10 +1340,9 @@ async def _stream_capped( |
1208 | 1340 | rejected up-front; servers that omit the header (or lie) are still |
1209 | 1341 | caught by the running total inside the loop. |
1210 | 1342 | """ |
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. |
1215 | 1346 | async with client.stream( |
1216 | 1347 | "GET", url, headers=headers, timeout=timeout, follow_redirects=False |
1217 | 1348 | ) as response: |
|
0 commit comments