diff --git a/.changeset/5450-adagents-redirect-policy.md b/.changeset/5450-adagents-redirect-policy.md new file mode 100644 index 0000000000..820ae4d830 --- /dev/null +++ b/.changeset/5450-adagents-redirect-policy.md @@ -0,0 +1,7 @@ +--- +"adcontextprotocol": minor +--- + +Define adagents.json discovery redirect policy and reconcile the reference implementation with it. + +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) so that 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 continues to refuse all redirects. Docs: managed-networks "Why not HTTP redirects?" and L1 security SSRF/TLS-hardening sections; new conformance vectors in `static/test-vectors/adagents-discovery-redirects.json`. diff --git a/docs/building/by-layer/L1/security.mdx b/docs/building/by-layer/L1/security.mdx index f8c12c8681..2614d71c60 100644 --- a/docs/building/by-layer/L1/security.mdx +++ b/docs/building/by-layer/L1/security.mdx @@ -108,7 +108,7 @@ Before any outbound fetch to a counterparty-controlled URL, fetchers MUST: - IPv4: RFC 1918 (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), RFC 6598 CGNAT (`100.64.0.0/10`), loopback (`127.0.0.0/8`), link-local (`169.254.0.0/16` — explicitly includes `169.254.169.254` used by AWS/GCP/Azure/Alibaba instance metadata), broadcast (`255.255.255.255`), `0.0.0.0/8`, multicast (`224.0.0.0/4`). - IPv6: loopback (`::1`), unique-local (`fc00::/7`), link-local (`fe80::/10`), IPv4-mapped (`::ffff:0:0/96` — the most common bypass, mapping reserved IPv4 into IPv6), multicast (`ff00::/8`), and the AWS IMDSv2 fd00:ec2::254 address. 3. **Pin the connection to the validated IP.** DNS-based filtering alone is vulnerable to DNS rebinding: an attacker serves a public IP at validation time and a private IP at connect time. Fetchers MUST pin the connection. **Preferred**: (a) pass the validated IP directly to the TCP connect call and set the `Host:` header from the URL. **Fallback** (only when the HTTP client cannot accept a pre-resolved IP): (b) validate the socket's post-handshake peer address against the reserved-range list before sending any request body. Note: (b) depends on the client library exposing a peer-address hook that fires before the first body byte ships; many common libraries do not, so implementations choosing (b) MUST verify the hook in testing. Re-resolving DNS without pinning is not sufficient. -4. **Refuse to follow redirects** when fetching counterparty-controlled URLs (a 30x response lets the origin redirect to a reserved address that bypassed the initial check). +4. **Refuse to follow redirects** when fetching counterparty-controlled URLs (a 30x response lets the origin redirect to a reserved address that bypassed the initial check). Two bounded exceptions, each re-validating steps 1–3 on every hop and capping the chain: the [brand.json resolution](#buyer-identity-resolution) (one redirect, no chains), and the initial `/.well-known/adagents.json` fetch, which follows **same-registrable-domain** redirects only (`apex ↔ www`, HTTPS-preserving, ≤ 3 hops, anchored on the originally-requested domain) so standard apex→www hosting resolves — see [managed networks](/docs/governance/property/managed-networks#why-not-http-redirects). The `adagents.json` `authoritative_location` dereference takes no exception: redirects on that second hop MUST be refused. 5. **Cap response size and timeouts.** Recommended: 5 MB body cap, 10 s connect, 10 s read. The only exception is the dereferenced authoritative file in the managed-network indirection pattern — second-hop only, after a pointer file's `authoritative_location` redirects to the network origin — which uses a recommended 20 MB cap because it fans out across a publisher network. Pointer files themselves stay at 5 MB. See [managed networks security](/docs/governance/property/managed-networks#security-considerations). 6. **Do not echo fetch errors to the agent that supplied the URL.** Detailed error messages (connection refused vs. timed out vs. TLS failure) are a side-channel for probing internal network topology. @@ -1796,7 +1796,7 @@ Outbound-fetch code paths (governance JWKS, brand.json, revocation list, webhook - Use a connection pool with a fixed per-host cap and a fixed overall cap. Unbounded pools are a resource-exhaustion surface. - Cap TLS handshake time at 10 s and total request time at 30 s by default — counterparty-supplied URLs are a tarpit DoS vector otherwise. - Pin the connection to the IP address that passed the [SSRF controls](#webhook-url-validation-ssrf) — DNS re-resolution between the SSRF check and the actual connect is how TOCTOU bypasses land. -- Refuse redirects on security-sensitive fetches. JWKS, brand.json, revocation list, and webhook-callback fetches MUST NOT follow redirects; the [brand.json resolution rule](#buyer-identity-resolution) already says "one redirect (`authoritative_location` or `house` variant), no chains" — everywhere else, zero. +- Refuse redirects on security-sensitive fetches. JWKS, brand.json, revocation list, and webhook-callback fetches MUST NOT follow redirects; the [brand.json resolution rule](#buyer-identity-resolution) already says "one redirect (`authoritative_location` or `house` variant), no chains," and the initial `/.well-known/adagents.json` fetch follows same-registrable-domain redirects only (apex↔www, HTTPS-preserving, ≤ 3 hops, anchored on the originally-requested domain) — everywhere else, zero. The `adagents.json` `authoritative_location` dereference is "everywhere else": zero redirects. - Disable session resumption across trust boundaries. Resuming a TLS session with an attacker-controlled counterparty onto a later verified counterparty (same IP via DNS rebind) is a well-known class of confusion; library defaults are usually fine, but the operator MUST audit. ### TLS renegotiation and downgrade diff --git a/docs/governance/property/managed-networks.mdx b/docs/governance/property/managed-networks.mdx index 9bb9a65e20..a524be3039 100644 --- a/docs/governance/property/managed-networks.mdx +++ b/docs/governance/property/managed-networks.mdx @@ -141,7 +141,10 @@ For `adagents.json`, HTTP redirects cause problems: - **No scoping.** An HTTP redirect is all-or-nothing. A pointer file sits alongside a structured authorization model where the network can declare exactly what it is authorized to sell. - **Caching penalty.** With HTTP redirects, a validator has no way to know that 10,000 domains all redirect to the same file. It must treat each response as independent — 10,000 cache entries for identical content. With `authoritative_location`, the validator sees the same URL across all pointer files and caches the authoritative file once. For a network with thousands of domains, this is the difference between one cache entry and thousands. -HTTP redirects to `/.well-known/adagents.json` are not prohibited, but they are not the recommended pattern. Use `authoritative_location` instead. +Those problems are about using a redirect to express **delegation** — a cross-domain hop that says "my authorization lives somewhere else." That is what `authoritative_location` replaces. They are not about **hosting normalization**, which is the ordinary case where a publisher's apex domain `301`-redirects to `www` (the default on most managed hosting and CDNs). The two are distinguished by where the redirect points, so the fetch rule is split accordingly: + +- **Same-registrable-domain redirects MUST be followed** on the `/.well-known/adagents.json` fetch — `apex ↔ www`, and any redirect that stays on the same registrable domain (eTLD+1) — re-validating the [SSRF controls](/docs/building/by-layer/L1/security#webhook-url-validation-ssrf) on every hop, refusing any redirect to a non-HTTPS scheme, and capping the chain at 3 hops. Refusing these silently reports a correctly-configured publisher as unauthorized. The same-registrable-domain comparison MUST be anchored on the **originally-requested** domain at every hop, not the previous hop, so a `same → cross` two-hop chain cannot escape it. +- **Cross-registrable-domain redirects MUST be refused** on this fetch. A cross-domain redirect is the ambiguous, unscoped delegation signal this pattern exists to replace — declare delegation explicitly with `authoritative_location` instead. The validator dereferences `authoritative_location` as a second hop with redirects **refused** (see [Security considerations](#security-considerations)): the named URL is the declared authoritative location, and a redirect away from it changes that declaration. ## Choosing the right delegation type diff --git a/package-lock.json b/package-lock.json index 18e7dc048a..0543eb0f6b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,6 +54,7 @@ "sharp": "^0.34.5", "stripe": "^22.2.0", "svix": "^1.95.2", + "tldts": "^7.0.29", "undici": "^8.4.1", "whoiser": "^2.0.0-beta.10", "ws": "^8.21.0", diff --git a/package.json b/package.json index c296371238..884a0c1fc2 100644 --- a/package.json +++ b/package.json @@ -172,6 +172,7 @@ "sharp": "^0.34.5", "stripe": "^22.2.0", "svix": "^1.95.2", + "tldts": "^7.0.29", "undici": "^8.4.1", "whoiser": "^2.0.0-beta.10", "ws": "^8.21.0", diff --git a/server/src/adagents-manager.ts b/server/src/adagents-manager.ts index 5ecd2c3608..26f8f7efb8 100644 --- a/server/src/adagents-manager.ts +++ b/server/src/adagents-manager.ts @@ -5,6 +5,11 @@ import { canonicalizePublisherDomain } from './services/publisher-domain.js'; const MCP_ACCEPT_HEADER = 'application/json, text/event-stream'; const ADS_TXT_MAX_REDIRECTS = 5; +// The initial /.well-known/adagents.json fetch follows same-registrable-domain +// redirects only (apex↔www, HTTPS-preserving), capped at 3 hops, so standard +// apex→www hosting resolves. Cross-domain hops are refused — see +// docs/governance/property/managed-networks#why-not-http-redirects. +const ADAGENTS_WELL_KNOWN_MAX_REDIRECTS = 3; const MCP_PREFLIGHT_INITIALIZE_BODY = { jsonrpc: '2.0', method: 'initialize', @@ -243,6 +248,8 @@ export class AdAgentsManager { // unauthenticated and reachable on view (see PR #4128 / issue #4129). const response = await safeFetchAxiosLike(url, { timeoutMs: 10000, + maxRedirects: ADAGENTS_WELL_KNOWN_MAX_REDIRECTS, + sameSiteRedirectsOnly: true, headers: { 'Accept': 'application/json', 'User-Agent': AAO_UA_VALIDATOR, @@ -540,20 +547,23 @@ export class AdAgentsManager { return null; } - // Fetch the authoritative file. safeFetch follows redirects with - // per-hop SSRF validation, which matters more here than on the - // /.well-known fetch — the authoritative_location URL was named - // by the publisher and could redirect anywhere. + // Fetch the authoritative file with redirects REFUSED. The named URL is + // the publisher's declared authoritative location; a redirect away from + // it changes that declaration, so it is treated as an error rather than + // followed (unlike the initial /.well-known fetch, which follows + // same-registrable-domain hosting redirects). See + // docs/governance/property/managed-networks#security-considerations. const response = await safeFetchAxiosLike(url, { timeoutMs: 10000, + maxRedirects: 0, headers: { 'Accept': 'application/json', 'User-Agent': AAO_UA_VALIDATOR, }, }); - // Overwrite the resolved URL — when authoritative_location is - // followed, the canonical document body came from THIS fetch, + // Overwrite the resolved URL — when the authoritative_location pointer + // is dereferenced, the canonical document body came from THIS fetch, // not the original /.well-known. Verifiers should pin trust to // the authoritative location's TLS chain, not the publisher's. // Bytes likewise reflect the canonical body, not the stub. @@ -563,7 +573,9 @@ export class AdAgentsManager { if (response.status !== 200) { const statusMessage = response.status === 404 ? `File not found at ${url}` - : `HTTP ${response.status} error fetching ${url}`; + : [301, 302, 303, 307, 308].includes(response.status) + ? `Redirect (HTTP ${response.status}) from authoritative_location ${url}; redirects on the authoritative file are refused because they change the declared location` + : `HTTP ${response.status} error fetching ${url}`; result.errors.push({ field: 'authoritative_location', message: statusMessage, diff --git a/server/src/utils/url-security.ts b/server/src/utils/url-security.ts index 0348b5d124..bd04e2f84c 100644 --- a/server/src/utils/url-security.ts +++ b/server/src/utils/url-security.ts @@ -2,6 +2,7 @@ import dns from 'dns/promises'; import { lookup as dnsLookup, type LookupAddress, type LookupOptions } from 'dns'; import { isIP, type LookupFunction } from 'net'; import { Agent, fetch as undiciFetch, type Dispatcher } from 'undici'; +import { getDomain } from 'tldts'; import { createLogger } from '../logger.js'; const logger = createLogger('url-security'); @@ -353,6 +354,17 @@ export async function safeFetch( body?: string | Uint8Array; maxRequestBytes?: number; signal?: AbortSignal; + /** + * Restrict redirect-following to the originally-requested registrable + * domain (eTLD+1), HTTPS only. Cross-registrable-domain or scheme-downgrade + * hops throw instead of being followed. Used for the `/.well-known/adagents.json` + * discovery fetch, where standard apex→www hosting redirects MUST resolve but a + * cross-domain hop is an unscoped delegation signal that MUST be refused — see + * docs/governance/property/managed-networks#why-not-http-redirects. The + * comparison is anchored on the original request URL at every hop, not the + * previous hop, so a same→cross two-hop chain cannot escape it. + */ + sameSiteRedirectsOnly?: boolean; }, ): Promise { const parsedUrl = new URL(url); @@ -364,6 +376,23 @@ export async function safeFetch( const body = options?.body; const maxRequestBytes = options?.maxRequestBytes ?? DEFAULT_MAX_REQUEST_BYTES; const signal = options?.signal; + const sameSiteRedirectsOnly = options?.sameSiteRedirectsOnly ?? false; + // Same-site mode preserves HTTPS across the whole chain, including hop 0 — + // assert it up front so the invariant holds regardless of caller discipline. + if (sameSiteRedirectsOnly && parsedUrl.protocol !== 'https:') { + throw new Error( + `sameSiteRedirectsOnly requires an HTTPS origin, got ${parsedUrl.protocol}//${parsedUrl.hostname}`, + ); + } + // Anchor the same-site check on the ORIGINAL request domain, never the + // previous hop — otherwise a same→cross two-hop chain bypasses the rule. + // allowPrivateDomains:true so the PSL PRIVATE section (github.io, pages.dev, + // herokuapp.com, …) is the registrant boundary; without it two unrelated + // tenants on shared hosting collapse to one registrable domain and a + // cross-tenant redirect would be wrongly trusted. + const originRegistrableDomain = sameSiteRedirectsOnly + ? getDomain(parsedUrl.hostname, { allowPrivateDomains: true }) + : null; const dispatcher = buildSsrfSafeDispatcher(); // POST without a body would surprise downstream agent endpoints; reject @@ -407,6 +436,21 @@ export async function safeFetch( if (!location) throw new Error('Redirect with no Location header'); // Pre-flight check on the redirect hop, then dial through the same SSRF-safe dispatcher. const redirectUrl = await validateRedirectTarget(location, currentUrl); + if (sameSiteRedirectsOnly) { + // HTTPS-preserving: refuse any downgrade away from HTTPS. + if (redirectUrl.protocol !== 'https:') { + throw new Error( + `Refused non-HTTPS redirect on same-site fetch: ${currentUrl.hostname} -> ${redirectUrl.protocol}//${redirectUrl.hostname}`, + ); + } + // Same registrable domain (eTLD+1), anchored on the ORIGINAL request. + const targetRegistrableDomain = getDomain(redirectUrl.hostname, { allowPrivateDomains: true }); + if (!originRegistrableDomain || targetRegistrableDomain !== originRegistrableDomain) { + throw new Error( + `Refused cross-registrable-domain redirect: ${currentUrl.hostname} -> ${redirectUrl.hostname}`, + ); + } + } // Per RFC 7231 §6.4.4 a 303 ALWAYS rewrites to GET; for 301/302 most // clients also rewrite for non-idempotent verbs even though the spec // only mandates user confirmation. We rewrite POST→GET on 301/302/303 @@ -483,6 +527,7 @@ export async function safeFetchAxiosLike( timeoutMs?: number; maxResponseBytes?: number; maxRedirects?: number; + sameSiteRedirectsOnly?: boolean; }, ): Promise { const controller = new AbortController(); @@ -495,6 +540,7 @@ export async function safeFetchAxiosLike( headers: options?.headers, body: options?.body, maxRedirects: options?.maxRedirects, + sameSiteRedirectsOnly: options?.sameSiteRedirectsOnly, signal: controller.signal, }); diff --git a/server/tests/unit/url-security-redirects.test.ts b/server/tests/unit/url-security-redirects.test.ts index c7d40a3736..a8ff121a59 100644 --- a/server/tests/unit/url-security-redirects.test.ts +++ b/server/tests/unit/url-security-redirects.test.ts @@ -65,3 +65,200 @@ describe('safeFetch redirects', () => { expect(fetchMock.mock.calls[2][0]).toBe('https://www.publisher.example/sites/pub/ads.txt'); }); }); + +describe('safeFetch sameSiteRedirectsOnly (adagents /.well-known discovery)', () => { + beforeEach(() => { + fetchMock.mockReset(); + agentMock.mockClear(); + resolve4Mock.mockClear(); + resolve6Mock.mockClear(); + resolve4Mock.mockResolvedValue(['93.184.216.34']); + resolve6Mock.mockResolvedValue([]); + }); + + const wellKnown = (host: string) => `https://${host}/.well-known/adagents.json`; + + it('follows a same-registrable-domain redirect (apex -> www)', async () => { + fetchMock + .mockResolvedValueOnce(new Response('', { + status: 301, + headers: { Location: wellKnown('www.ladepeche.fr') }, + })) + .mockResolvedValueOnce(new Response('{"authorized_agents":[]}', { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })); + + const response = await safeFetch(wellKnown('ladepeche.fr'), { + sameSiteRedirectsOnly: true, + maxRedirects: 3, + signal: AbortSignal.timeout(2_000), + }); + + expect(response.status).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[1][0]).toBe(wellKnown('www.ladepeche.fr')); + }); + + it('refuses a cross-registrable-domain redirect without dialing the off-domain hop', async () => { + fetchMock.mockResolvedValueOnce(new Response('', { + status: 302, + headers: { Location: wellKnown('claire.pub') }, + })); + + await expect( + safeFetch(wellKnown('ladepeche.fr'), { + sameSiteRedirectsOnly: true, + maxRedirects: 3, + signal: AbortSignal.timeout(2_000), + }), + ).rejects.toThrow(/cross-registrable-domain/); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('refuses cross-domain even after a legitimate same-domain hop (anchored on origin)', async () => { + fetchMock + .mockResolvedValueOnce(new Response('', { + status: 301, + headers: { Location: wellKnown('www.pub.example') }, + })) + .mockResolvedValueOnce(new Response('', { + status: 302, + headers: { Location: wellKnown('attacker.example') }, + })); + + await expect( + safeFetch(wellKnown('pub.example'), { + sameSiteRedirectsOnly: true, + maxRedirects: 3, + signal: AbortSignal.timeout(2_000), + }), + ).rejects.toThrow(/cross-registrable-domain/); + }); + + it('refuses a scheme downgrade away from HTTPS', async () => { + fetchMock.mockResolvedValueOnce(new Response('', { + status: 302, + headers: { Location: 'http://pub.example/.well-known/adagents.json' }, + })); + + await expect( + safeFetch(wellKnown('pub.example'), { + sameSiteRedirectsOnly: true, + maxRedirects: 3, + signal: AbortSignal.timeout(2_000), + }), + ).rejects.toThrow(); + }); + + it('refuses a cross-tenant redirect on a shared private suffix (github.io)', async () => { + fetchMock.mockResolvedValueOnce(new Response('', { + status: 302, + headers: { Location: wellKnown('attacker.github.io') }, + })); + + await expect( + safeFetch(wellKnown('victim.github.io'), { + sameSiteRedirectsOnly: true, + maxRedirects: 3, + signal: AbortSignal.timeout(2_000), + }), + ).rejects.toThrow(/cross-registrable-domain/); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('follows a same-tenant redirect within a private suffix (victim.github.io -> www.victim.github.io)', async () => { + fetchMock + .mockResolvedValueOnce(new Response('', { + status: 301, + headers: { Location: wellKnown('www.victim.github.io') }, + })) + .mockResolvedValueOnce(new Response('{"authorized_agents":[]}', { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })); + + const response = await safeFetch(wellKnown('victim.github.io'), { + sameSiteRedirectsOnly: true, + maxRedirects: 3, + signal: AbortSignal.timeout(2_000), + }); + + expect(response.status).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('follows a same-registrable-domain redirect across a multi-label public TLD (co.uk)', async () => { + fetchMock + .mockResolvedValueOnce(new Response('', { + status: 301, + headers: { Location: wellKnown('www.example.co.uk') }, + })) + .mockResolvedValueOnce(new Response('{"authorized_agents":[]}', { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })); + + const response = await safeFetch(wellKnown('example.co.uk'), { + sameSiteRedirectsOnly: true, + maxRedirects: 3, + signal: AbortSignal.timeout(2_000), + }); + + expect(response.status).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('refuses a cross-domain redirect across a multi-label public TLD (co.uk -> com)', async () => { + fetchMock.mockResolvedValueOnce(new Response('', { + status: 302, + headers: { Location: wellKnown('example.com') }, + })); + + await expect( + safeFetch(wellKnown('example.co.uk'), { + sameSiteRedirectsOnly: true, + maxRedirects: 3, + signal: AbortSignal.timeout(2_000), + }), + ).rejects.toThrow(/cross-registrable-domain/); + }); + + it('does not resolve when the redirect hop cap is exceeded (4 same-domain hops, cap 3)', async () => { + fetchMock + .mockResolvedValueOnce(new Response('', { status: 301, headers: { Location: wellKnown('b.pub.example') } })) + .mockResolvedValueOnce(new Response('', { status: 301, headers: { Location: wellKnown('c.pub.example') } })) + .mockResolvedValueOnce(new Response('', { status: 301, headers: { Location: wellKnown('d.pub.example') } })) + .mockResolvedValueOnce(new Response('', { status: 301, headers: { Location: wellKnown('e.pub.example') } })); + + const response = await safeFetch(wellKnown('a.pub.example'), { + sameSiteRedirectsOnly: true, + maxRedirects: 3, + signal: AbortSignal.timeout(2_000), + }); + + // Cap reached: the last hop is still a 3xx and the chain did not resolve to 200. + expect(response.status).toBe(301); + expect(fetchMock).toHaveBeenCalledTimes(4); + }); + + it('still follows cross-domain redirects when the flag is not set (default behavior preserved)', async () => { + fetchMock + .mockResolvedValueOnce(new Response('', { + status: 302, + headers: { Location: wellKnown('claire.pub') }, + })) + .mockResolvedValueOnce(new Response('{"authorized_agents":[]}', { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })); + + const response = await safeFetch(wellKnown('ladepeche.fr'), { + maxRedirects: 3, + signal: AbortSignal.timeout(2_000), + }); + + expect(response.status).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/static/test-vectors/adagents-discovery-redirects.json b/static/test-vectors/adagents-discovery-redirects.json new file mode 100644 index 0000000000..d42526e0b4 --- /dev/null +++ b/static/test-vectors/adagents-discovery-redirects.json @@ -0,0 +1,132 @@ +{ + "version": 1, + "name": "adagents.json discovery redirect handling", + "specification": "https://adcontextprotocol.org/docs/governance/property/managed-networks#why-not-http-redirects", + "description": "Cross-SDK conformance vectors for HTTP redirect handling when a validator resolves adagents.json. Two distinct fetches with deliberately different policies. (1) The INITIAL fetch of `/.well-known/adagents.json` MUST follow same-registrable-domain (eTLD+1) redirects — apex↔www and any host on the same registrable domain — HTTPS-preserving, re-validating SSRF controls on every hop, capped at 3 hops, and MUST refuse cross-registrable-domain redirects; the same-domain comparison is anchored on the ORIGINALLY-requested domain at every hop, not the previous hop. (2) The `authoritative_location` dereference (second hop) MUST refuse all redirects, because the named URL is the declared authoritative location and a redirect away from it changes that declaration. Registrable-domain comparison MUST use the Public Suffix List INCLUDING its PRIVATE section (e.g. tldts.getDomain with allowPrivateDomains:true): the ICANN section handles multi-label public TLDs like co.uk, and the PRIVATE section is the registrant boundary on shared hosting (github.io, pages.dev, herokuapp.com, …) so two unrelated tenants are not collapsed to one registrable domain and a cross-tenant redirect is correctly refused. The normative assertion of each vector is `result` (resolved vs refused); `reason` is advisory explanation that implementations need not surface verbatim. Each vector has a stable `id` (the cross-SDK reference; kebab-case), a `target` (which fetch), the `origin_url`, an ordered `redirect_chain` of `{ status, location }` hops the origin would return, and the `expected` outcome. Downstream tests SHOULD look up vectors by `id`; `description` prose may be revised without notice.", + "vectors": [ + { + "id": "well-known-apex-to-www-followed", + "target": "well_known", + "description": "Apex 301-redirects to www on the same registrable domain — the default managed-hosting/CDN pattern. MUST be followed.", + "origin_url": "https://ladepeche.fr/.well-known/adagents.json", + "redirect_chain": [ + { "status": 301, "location": "https://www.ladepeche.fr/.well-known/adagents.json" } + ], + "expected": { "result": "resolved", "final_url": "https://www.ladepeche.fr/.well-known/adagents.json" } + }, + { + "id": "well-known-www-to-apex-followed", + "target": "well_known", + "description": "www 301-redirects to apex on the same registrable domain. MUST be followed (the rule is symmetric).", + "origin_url": "https://www.example.com/.well-known/adagents.json", + "redirect_chain": [ + { "status": 301, "location": "https://example.com/.well-known/adagents.json" } + ], + "expected": { "result": "resolved", "final_url": "https://example.com/.well-known/adagents.json" } + }, + { + "id": "well-known-subdomain-same-registrable-domain-followed", + "target": "well_known", + "description": "Redirect to a different subdomain on the same eTLD+1 (cdn.pub.example). Same registrable domain — MUST be followed.", + "origin_url": "https://pub.example/.well-known/adagents.json", + "redirect_chain": [ + { "status": 302, "location": "https://cdn.pub.example/.well-known/adagents.json" } + ], + "expected": { "result": "resolved", "final_url": "https://cdn.pub.example/.well-known/adagents.json" } + }, + { + "id": "well-known-multi-label-tld-same-domain-followed", + "target": "well_known", + "description": "PSL correctness: example.co.uk -> www.example.co.uk share registrable domain example.co.uk (co.uk is a public suffix). MUST be followed. A naive last-two-labels comparison would wrongly treat co.uk as the registrable domain.", + "origin_url": "https://example.co.uk/.well-known/adagents.json", + "redirect_chain": [ + { "status": 301, "location": "https://www.example.co.uk/.well-known/adagents.json" } + ], + "expected": { "result": "resolved", "final_url": "https://www.example.co.uk/.well-known/adagents.json" } + }, + { + "id": "well-known-cross-registrable-domain-refused", + "target": "well_known", + "description": "Redirect to a different registrable domain (ladepeche.fr -> claire.pub). This is an unscoped delegation signal — MUST be refused. The off-domain hop MUST NOT be dialed.", + "origin_url": "https://ladepeche.fr/.well-known/adagents.json", + "redirect_chain": [ + { "status": 302, "location": "https://claire.pub/.well-known/adagents.json" } + ], + "expected": { "result": "refused", "reason": "cross_registrable_domain" } + }, + { + "id": "well-known-multi-label-tld-cross-domain-refused", + "target": "well_known", + "description": "PSL correctness: example.co.uk -> example.com is a cross-registrable-domain hop. MUST be refused.", + "origin_url": "https://example.co.uk/.well-known/adagents.json", + "redirect_chain": [ + { "status": 302, "location": "https://example.com/.well-known/adagents.json" } + ], + "expected": { "result": "refused", "reason": "cross_registrable_domain" } + }, + { + "id": "well-known-private-suffix-cross-tenant-refused", + "target": "well_known", + "description": "PSL PRIVATE-section boundary: victim.github.io -> attacker.github.io are different registrants on a shared-hosting public suffix (github.io). They MUST be treated as cross-registrable-domain and refused. A PSL lookup that ignores the private section collapses both to 'github.io' and would wrongly follow the attacker's file as authoritative for the victim — the bypass this vector exists to catch.", + "origin_url": "https://victim.github.io/.well-known/adagents.json", + "redirect_chain": [ + { "status": 302, "location": "https://attacker.github.io/.well-known/adagents.json" } + ], + "expected": { "result": "refused", "reason": "cross_registrable_domain" } + }, + { + "id": "well-known-private-suffix-same-tenant-followed", + "target": "well_known", + "description": "PSL PRIVATE-section boundary, same tenant: victim.github.io -> www.victim.github.io stay within the same registrant (victim.github.io). MUST be followed, so apex↔www normalization still works for publishers on shared hosting.", + "origin_url": "https://victim.github.io/.well-known/adagents.json", + "redirect_chain": [ + { "status": 301, "location": "https://www.victim.github.io/.well-known/adagents.json" } + ], + "expected": { "result": "resolved", "final_url": "https://www.victim.github.io/.well-known/adagents.json" } + }, + { + "id": "well-known-same-then-cross-refused-anchored-on-origin", + "target": "well_known", + "description": "Two-hop chain: pub.example -> www.pub.example (same) -> attacker.example (cross). The second hop MUST be refused. Anchoring the comparison on the immediately-preceding hop instead of the originally-requested domain MUST NOT allow this chain to escape.", + "origin_url": "https://pub.example/.well-known/adagents.json", + "redirect_chain": [ + { "status": 301, "location": "https://www.pub.example/.well-known/adagents.json" }, + { "status": 302, "location": "https://attacker.example/.well-known/adagents.json" } + ], + "expected": { "result": "refused", "reason": "cross_registrable_domain" } + }, + { + "id": "well-known-https-downgrade-refused", + "target": "well_known", + "description": "Redirect downgrades the scheme to http on the same registrable domain. MUST be refused — redirects on this fetch are HTTPS-preserving.", + "origin_url": "https://pub.example/.well-known/adagents.json", + "redirect_chain": [ + { "status": 302, "location": "http://pub.example/.well-known/adagents.json" } + ], + "expected": { "result": "refused", "reason": "scheme_downgrade" } + }, + { + "id": "well-known-hop-cap-exceeded-refused", + "target": "well_known", + "description": "Four consecutive same-registrable-domain redirects with a 3-hop cap. The chain MUST NOT resolve — the validator stops at the cap and treats the unresolved chain as a failure rather than following indefinitely.", + "origin_url": "https://a.pub.example/.well-known/adagents.json", + "redirect_chain": [ + { "status": 301, "location": "https://b.pub.example/.well-known/adagents.json" }, + { "status": 301, "location": "https://c.pub.example/.well-known/adagents.json" }, + { "status": 301, "location": "https://d.pub.example/.well-known/adagents.json" }, + { "status": 301, "location": "https://e.pub.example/.well-known/adagents.json" } + ], + "expected": { "result": "refused", "reason": "too_many_redirects" } + }, + { + "id": "authoritative-location-any-redirect-refused", + "target": "authoritative_location", + "description": "The authoritative_location dereference (second hop) MUST refuse redirects entirely — even a same-registrable-domain one. The named URL is the declared authoritative location; a redirect away from it changes that declaration and is treated as an error.", + "origin_url": "https://network.example/authoritative/adagents.json", + "redirect_chain": [ + { "status": 301, "location": "https://www.network.example/authoritative/adagents.json" } + ], + "expected": { "result": "refused", "reason": "redirect_on_authoritative_location" } + } + ] +}