Skip to content

fix(governance): define adagents.json discovery redirect policy (#5450)#5472

Merged
bokelley merged 1 commit into
mainfrom
fix/5450-adagents-redirect-policy
Jun 11, 2026
Merged

fix(governance): define adagents.json discovery redirect policy (#5450)#5472
bokelley merged 1 commit into
mainfrom
fix/5450-adagents-redirect-policy

Conversation

@bokelley

Copy link
Copy Markdown
Contributor

Summary

Resolves #5450. Defines the redirect policy for adagents.json discovery and reconciles the reference validator with it.

The current state was a three-way disagreement that silently broke correctly-configured publishers:

  • Spec (security.mdx SSRF step 4) had a blanket "refuse all redirects on counterparty-controlled fetches," while managed-networks.mdx said redirects on the .well-known fetch were "not prohibited" — a self-contradiction.
  • Python SDK refused all redirects → a publisher whose apex 301s to www (the managed-hosting/CDN default) is reported unauthorized.
  • TS reference followed redirects to any public host on both the .well-known fetch and the authoritative_location dereference — the latter directly violating the spec's "refuse redirects" rule for that hop.

Policy (two hops, deliberately different)

Fetch Redirect policy
Initial /.well-known/adagents.json Follow same-registrable-domain redirects (apex↔www, HTTPS-preserving, ≤3 hops, SSRF re-validated per hop, anchored on the originally-requested domain). Refuse cross-registrable-domain — that's an unscoped delegation signal; use authoritative_location instead.
authoritative_location dereference (2nd hop) Refuse all redirects — the named URL is the declared authoritative location; a redirect away from it changes that declaration.

This mirrors the existing bounded redirect carve-out for brand.json resolution ("one redirect, no chains"), so it's a precedented exception, not a new posture.

Registrable-domain matching uses the PSL including its private section (tldts.getDomain(..., { allowPrivateDomains: true })) so shared-hosting tenants like victim.github.io and attacker.github.io are distinct registrants — without that, a cross-tenant redirect on *.github.io / *.pages.dev / etc. would be wrongly trusted.

Changes

  • Spec: managed-networks.mdx "Why not HTTP redirects?" rewritten into the precise two-part rule; security.mdx SSRF step 4 + outbound-TLS bullet get the bounded same-domain exception. Schema adagents.json description untouched — its "refuse redirects" caveat is correct for the authoritative_location hop.
  • Reference impl: url-security.ts adds sameSiteRedirectsOnly to safeFetch/safeFetchAxiosLike; adagents-manager.ts wires the well-known fetch to { sameSiteRedirectsOnly: true, maxRedirects: 3 } and the authoritative_location fetch to { maxRedirects: 0 }.
  • Conformance: static/test-vectors/adagents-discovery-redirects.json — cross-SDK vectors covering apex↔www, subdomain, co.uk (ICANN multi-label), *.github.io (PSL private section), same→cross two-hop (origin-anchored), HTTPS downgrade, hop cap, and the authoritative-location refuse-all rule.
  • tldts promoted to a direct dependency (resolution unchanged at 7.0.30); minor changeset.

Wire impact

None — the adagents.json document shape is unchanged. This only widens/narrows which redirects a validator follows, so it's wire-compatible (minor). Targets the 3.1 line.

Follow-up (sibling SDK PRs)

The reference validator here is aligned; coordinated PRs to bring adcp-client, adcp-client-python (flip follow_redirects=False), and adcp-go to the same same-domain-follow / authoritative-refuse behavior against the shared test-vector are tracked separately.

Review

Passed an adversarial security + correctness review (found and fixed: the allowPrivateDomains shared-hosting bypass, missing PSL/hop-cap test coverage, and a stale branch base). 11 redirect unit tests + 154 url-security/adagents tests pass; typecheck clean.

🤖 Generated with Claude Code

The initial /.well-known/adagents.json fetch now follows same-registrable-domain
redirects (apex↔www, HTTPS-preserving, ≤3 hops, SSRF re-validated per hop,
anchored on the originally-requested domain and using the PSL private section so
shared-hosting tenants like *.github.io are distinct registrants) so standard
apex→www managed hosting resolves instead of being silently reported
unauthorized. Cross-registrable-domain redirects are refused — declare delegation
with authoritative_location instead — and the authoritative_location dereference
refuses all redirects.

Reconciles the reference validator with the spec: the /.well-known fetch was
following redirects to any public host and the authoritative_location dereference
likewise; both are now policy-correct. Adds cross-SDK conformance vectors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@aao-release-bot aao-release-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. The right fix for a documented three-way contradiction — and it lands the policy as a bounded same-registrable-domain carve-out, not a blanket loosening, so the SSRF posture tightens rather than regresses.

Things I checked

  • Origin anchoring is correct. originRegistrableDomain is computed once from parsedUrl.hostname before the redirect loop (url-security.ts:190) and every per-hop check compares against that frozen value, never currentUrl. The same → cross two-hop chain (pub.example → www.pub.example → attacker.example) is refused at hop 2 — covered by both the anchored-on-origin unit test and vector well-known-same-then-cross-refused-anchored-on-origin.
  • Fails closed on every null/edge case. if (!originRegistrableDomain || targetRegistrableDomain !== originRegistrableDomain) throw — IP-literal origin, bare-suffix target, both-null all refuse the redirect. No fail-open path. security-reviewer confirmed no High/Medium/Low.
  • Per-hop SSRF re-validation intact. validateRedirectTargetvalidateFetchUrl → reserved-range + DNS-rebind pin still fires on every hop before any dial; the same-site/HTTPS gates layer on top, not in place of it. Widening to same-eTLD+1 does not relax reserved-range defense.
  • HTTPS preservation. Asserted on hop 0 (url-security.ts:179) and per-hop before the domain check, so an https→http same-domain hop is still refused (vector well-known-https-downgrade-refused).
  • allowPrivateDomains:true is load-bearing. Without it getDomain collapses victim.github.io and attacker.github.io to github.io and a cross-tenant redirect is wrongly trusted. With it they're distinct registrants — refused. Both vectors + unit tests cover the private-suffix boundary.
  • Authoritative-location hop refuses all redirects. maxRedirects: 0 (adagents-manager.ts:117) returns the raw 3xx, then the new branch maps [301,302,303,307,308] to an authoritative_location refuse error (adagents-manager.ts:136-138). Hop-cap exceeded on the well-known fetch returns the terminal 301, which response.status !== 200 classifies as not-valid — matches vector well-known-hop-cap-exceeded-refused.
  • Downstream trust stays pinned right. security-reviewer confirmed authorization (hasExplicitPublisherScope, authorized_agents/properties) evaluates against normalizedDomain — the originally-requested publisher domain — never resolved_url. Letting www.publisher.com answer for publisher.com cannot let any other registrant author the publisher's authorization, because the body can only come from the same eTLD+1.
  • No schema drift. static/schemas/source/adagents.json:19 authoritative_location description ("refuse redirects on the fetch") scopes the second-hop dereference, which the new policy still enforces. Leaving it untouched is correct — ad-tech-protocol-expert: sound.
  • Changeset present, minor is right. Document shape unchanged; this only widens/narrows which redirects a validator follows. tldts added to package.json + package-lock.json at the existing resolved 7.0.30.
  • Test-plan honesty. PR claims 11 redirect unit tests + 154 url-security/adagents tests pass, typecheck clean. No unchecked manual-verification boxes describing the changed path.

Follow-ups (non-blocking — file as issues)

  • Sibling SDKs are unaligned until their PRs land. Until adcp-client-python flips follow_redirects=False and adcp-go/adcp-client adopt the same-domain-follow / authoritative-refuse behavior against the shared vector, a correctly-configured apex→www publisher is still reported unauthorized by those validators. The vector file is the contract; the cross-SDK PRs are the deliverable.
  • Hop-cap vector wording. well-known-hop-cap-exceeded-refused expects reason: too_many_redirects, but the TS reference returns the terminal 3xx and lets the caller reject it rather than throwing. Not a contradiction (result: refused means "MUST NOT resolve to a usable 200"), but a sibling author reading only the vector could implement a throw. Worth a one-line note that refused is a validator-layer outcome, not a transport-primitive throw.

Minor nits (non-blocking)

  1. DNS lookup precedes the refusal. url-security.ts:409validateRedirectTarget (which resolves DNS) runs before the cheap synchronous same-site/scheme checks. A cross-domain hop is still refused with no connection, but reordering the same-site/scheme guards ahead of validateRedirectTarget would avoid a needless lookup of the attacker-named host and tighten the "MUST NOT dial the off-domain hop" intent.
  2. Refusal message bucketing. classifySafeFetchError files the cross-registrable-domain throw under the network bucket and surfaces it verbatim. Hosts are publisher-controlled so it's not the internal-topology leak the function guards against — but a dedicated pattern would read more consistently with the other AAO buckets.

code-reviewer: clean. security-reviewer: sound, not an SSRF regression. ad-tech-protocol-expert: sound-with-caveats (the hop-cap wording note above).

Safe to merge.

@mintlify

mintlify Bot commented Jun 11, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
adcp 🔴 Failed Jun 11, 2026, 10:41 AM

💡 Tip: Enable Workflows to automatically generate PRs for you.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

adagents.json discovery: "refuse redirects on the fetch" breaks apex→www publishers, and the reference impl contradicts the spec

1 participant