diff --git a/crates/trusted-server-cli/src/commands/dev/lint/domains.rs b/crates/trusted-server-cli/src/commands/dev/lint/domains.rs index 8978af61c0..28c07c5b36 100644 --- a/crates/trusted-server-cli/src/commands/dev/lint/domains.rs +++ b/crates/trusted-server-cli/src/commands/dev/lint/domains.rs @@ -46,19 +46,25 @@ pub const EXACT_HOSTS: &[&str] = &[ "sdk.privacy-center.org", // sourcepoint "cdn.privacy-mgmt.com", + "cdn-eu.privacy-mgmt.com", // lockr "aim.loc.kr", "identity.loc.kr", // datadome "js.datadome.co", "api-js.datadome.co", + "api-fastly.datadome.co", // aps / Amazon "aax.amazon-adsystem.com", "aax-events.amazon-adsystem.com", + "client.aps.amazon-adsystem.com", + "web.ads.aps.amazon-adsystem.com", // permutive "api.permutive.com", "secure-signals.permutive.app", "cdn.permutive.com", + "events.permutive.app", + "sync.permutive.com", // Google Tag Manager / Analytics "www.googletagmanager.com", "www.google-analytics.com", @@ -70,6 +76,13 @@ pub const EXACT_HOSTS: &[&str] = &[ "cdn.prebid.org", // Fastly platform "api.fastly.com", + // inmobi: the Choice consent prompt script and its CDN + "cmp.inmobi.com", + "cdn.inmobi.com", + // ad-tag.google: the AdSense loader + "pagead2.googlesyndication.com", + // permission-signal.mtm: the Model Terms document + "m4ow.uk", ]; /// Hosts where exact match AND any subdomain (`*.host`) is allowed. @@ -116,6 +129,8 @@ pub const REFERENCE_HOSTS: &[&str] = &[ "rslstandard.org", // Specs (other) "webassembly.org", + // IETF RFCs + "www.rfc-editor.org", // Fastly docs "www.fastly.com", "developer.fastly.com", @@ -125,6 +140,8 @@ pub const REFERENCE_HOSTS: &[&str] = &[ // Vendor docs "docs.datadome.co", "docs.prebid.org", + "support.google.com", + "developers.google.com", // Tooling docs "vitepress.dev", "playwright.dev", @@ -1555,6 +1572,15 @@ fn path_is_scanned(rel_path: &str) -> bool { { return false; } + // A module crate's own fixtures, the page its middleware is tested on + // and the page it records: exactly crates///src/fixtures/. + if components.len() > 5 + && components[0] == "crates" + && components[3] == "src" + && components[4] == "fixtures" + { + return false; + } let basename = components.last().copied().unwrap_or(""); // Excluded lockfiles (exact basename). Name comparisons below are @@ -2588,6 +2614,9 @@ mod path_is_scanned_tests { "crates/integration-tests/fixtures/frameworks/nextjs/app/page.tsx", "crates/integration-tests/fixtures/frameworks/nextjs/Dockerfile", "crates/integration-tests/fixtures/frameworks/wordpress/Dockerfile", + "crates/cmp/inmobi/src/lib.rs", + "crates/cmp/inmobi/fixtures/page.html", + "crates/trusted-server-cli/src/fixtures/page.html", "README.md", "CHANGELOG.md", "CONTRIBUTING.md", @@ -2607,6 +2636,8 @@ mod path_is_scanned_tests { for p in [ "crates/trusted-server-core/src/integrations/nextjs/fixtures/inlined-data-escaped.html", "crates/trusted-server-core/src/integrations/google_tag_manager/fixtures/captured.html", + "crates/cmp/inmobi/src/fixtures/page-change.input.html", + "crates/testing/testlight/src/fixtures/page-change.settings.toml", "node_modules/foo.js", ".worktrees/x/y.rs", ".claude/worktrees/x/y.rs", diff --git a/docs/superpowers/specs/2026-05-18-check-domains-design.md b/docs/superpowers/specs/2026-05-18-check-domains-design.md index 4ab3c49903..72020eb56d 100644 --- a/docs/superpowers/specs/2026-05-18-check-domains-design.md +++ b/docs/superpowers/specs/2026-05-18-check-domains-design.md @@ -284,19 +284,22 @@ a legitimate documentation source we link to repeatedly?" Integration proxies and loopback. Subdomains are **not** allowed (e.g., `anything.api.privacy-center.org` is disallowed). -| Category | Hosts | -| ---------------------------------------------------- | ------------------------------------------------------------------------------ | -| Loopback | `127.0.0.1`, `::1`, `localhost` | -| Integration proxies (didomi) | `api.privacy-center.org`, `sdk.privacy-center.org` | -| Integration proxies (sourcepoint) | `cdn.privacy-mgmt.com` | -| Integration proxies (lockr) | `aim.loc.kr`, `identity.loc.kr` | -| Integration proxies (datadome) | `js.datadome.co`, `api-js.datadome.co` | -| Integration proxies (aps / Amazon) | `aax.amazon-adsystem.com`, `aax-events.amazon-adsystem.com` | -| Integration proxies (permutive) | `api.permutive.com`, `secure-signals.permutive.app`, `cdn.permutive.com` | -| Integration proxies (Google Tag Manager / Analytics) | `www.googletagmanager.com`, `www.google-analytics.com`, `analytics.google.com` | -| Integration proxies (adserver mock) | `securepubads.g.doubleclick.net`, `origin-mocktioneer.cdintel.com` | -| Integration proxies (Prebid CDN) | `cdn.prebid.org` | -| Integration proxies (Fastly platform) | `api.fastly.com` | +| Category | Hosts | +| ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| Loopback | `127.0.0.1`, `::1`, `localhost` | +| Integration proxies (didomi) | `api.privacy-center.org`, `sdk.privacy-center.org` | +| Integration proxies (sourcepoint) | `cdn.privacy-mgmt.com`, `cdn-eu.privacy-mgmt.com` | +| Integration proxies (lockr) | `aim.loc.kr`, `identity.loc.kr` | +| Integration proxies (datadome) | `js.datadome.co`, `api-js.datadome.co`, `api-fastly.datadome.co` | +| Integration proxies (aps / Amazon) | `aax.amazon-adsystem.com`, `aax-events.amazon-adsystem.com`, `client.aps.amazon-adsystem.com`, `web.ads.aps.amazon-adsystem.com` | +| Integration proxies (permutive) | `api.permutive.com`, `secure-signals.permutive.app`, `cdn.permutive.com`, `events.permutive.app`, `sync.permutive.com` | +| Integration proxies (Google Tag Manager / Analytics) | `www.googletagmanager.com`, `www.google-analytics.com`, `analytics.google.com` | +| Integration proxies (adserver mock) | `securepubads.g.doubleclick.net`, `origin-mocktioneer.cdintel.com` | +| Integration proxies (Prebid CDN) | `cdn.prebid.org` | +| Integration proxies (Fastly platform) | `api.fastly.com` | +| Integration proxies (inmobi) | `cmp.inmobi.com`, `cdn.inmobi.com` | +| Integration proxies (Google ad tag) | `pagead2.googlesyndication.com` | +| Integration proxies (Model Terms signal) | `m4ow.uk` | ### Subdomain-permitting hosts (`SUBDOMAIN_HOSTS`) @@ -335,9 +338,10 @@ suppress per-line. | IAB Tech Lab | `iab.com`, `iabtechlab.com`, `iabtechlab.github.io`, `iabeurope.github.io` | | Specs (supply chain) | `in-toto.io`, `rslstandard.org` | | Specs (other) | `webassembly.org` | +| IETF RFCs | `www.rfc-editor.org` | | Fastly docs | `www.fastly.com`, `developer.fastly.com`, `manage.fastly.com` | | Cloudflare docs | `developers.cloudflare.com` | -| Vendor docs | `docs.datadome.co`, `docs.prebid.org` | +| Vendor docs | `docs.datadome.co`, `docs.prebid.org`, `support.google.com`, `developers.google.com` | | Tooling docs | `vitepress.dev`, `playwright.dev`, `testcontainers.com`, `grafana.com`, `docsearch.algolia.com` | One-off references not on this list (e.g., a single arxiv.org link in @@ -562,6 +566,15 @@ fenced code blocks. `crates/integration-tests/fixtures/frameworks/nextjs/app/*.tsx`, which we deliberately scan). +- **A module crate's own fixtures**, `crates///src/fixtures/**`. + A module in a crate of its own under `crates///` (the layout + the seam design gives) keeps the page its middleware is tested on, and the + page it records, under `src/fixtures/`. Those pages carry the vendor's own + script hosts and other vendors' tags the test proves untouched, as the + publisher captures above do. The rule is the two-level crate layout + exactly, so `crates//src/fixtures/` and + `crates/integration-tests/fixtures/...` stay scanned. + **Source files under `crates/integration-tests/fixtures/frameworks/*` — including `.tsx`, `.ts`, `.json`, `next.config.mjs`, `Dockerfile` — ARE scanned.** Only the publisher-capture path above is excluded. diff --git a/docs/superpowers/specs/2026-07-30-client-cycle-ec-resolve-design.md b/docs/superpowers/specs/2026-07-30-client-cycle-ec-resolve-design.md new file mode 100644 index 0000000000..502b65cd38 --- /dev/null +++ b/docs/superpowers/specs/2026-07-30-client-cycle-ec-resolve-design.md @@ -0,0 +1,325 @@ +# Design Spec: Client-Cycle Edge Cookie Modules and the Resolve Endpoint + +**Status:** Proposed. PR #1046 carries the implementation (hardened v1, on +the threat model below) and is not yet merged to main. +The full anti-replay reservation machinery (§3.9) and the vendor envelope +verification it serves are deliberately not in v1: they land with the first +real vendor scheme, which brings the concrete envelope format the +reservation design must fit. §8 records exactly what v1 implements, what it +defers, and why the feature is normative rather than deferred. Updated on +2026-10-06 to say module where it said provider, as the code does. +**Author:** Engineering (revised against the implementation, 2026-08-25) +**Issue references:** #778 (series), successor spec of the 2026-07-31 draft +**Related specs:** `2026-07-30-pluggable-providers-design.md` +**Last updated:** 2026-10-06 + +> **Context.** PR #838 shipped, undeclared and unspec'd, a second module +> _type_: a "client-cycle" EC module whose identifier is established by a +> browser POST to a new public endpoint (`POST /_ts/api/v1/ec/resolve`), +> plus a demo module (`client_fixed`) and a JS bundle. Review found the +> endpoint accepted cross-origin identity-setting posts with no origin +> check, created cookies with no identity-graph row (violating an invariant +> the organic path enforces explicitly), was registered on only one of four +> adapters, and could never round-trip because the core did not recognize +> non-HMAC identifiers. None of that is an argument the feature is a bad +> idea. Vendor identity systems with a browser leg (for example +> signed-envelope schemes) are a real integration target, and for this +> project the client-side path is the preferred route for the first vendor +> integration. It is an argument that the feature needs a threat model +> before an implementation. This spec is that threat model; PR #1046 is the +> implementation measured against it. + +--- + +## 1. Overview + +A **client-cycle** EC module establishes the identifier via a browser +round trip, where server-injected first-party JS obtains or derives a value in the +page (typically a signed envelope from a vendor identity system), posts it to +a Trusted Server endpoint, and the endpoint, after module-specific +verification, sets the first-party `ts-ec` cookie. + +This differs from server-side modules in one security-critical way: **the +identifier is attacker-influenceable input**, not server-derived evidence. +Everything in this spec follows from that. + +This feature is normative in the series rather than deferred because the first +vendor integration this project targets works client-side by design, since the +page script talks to the vendor's identity system and hands the result to +the edge, so the server-side path alone cannot carry it. The 2026-07-31 +draft deferred the feature for lack of a consumer. The consumer now exists +as a planned vendor module, and v1 builds the endpoint that module will +verify against. + +## 2. Threat model + +| Threat | Vector | Consequence if unmitigated | +| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Cross-site identity fixation** | `text/plain` POST is a CORS-simple request: any page on the web can `fetch(resolveUrl, {method: "POST", credentials: "include", body: payload})` with no preflight | An attacker pins a chosen identity onto a victim's first-party cookie jar, a login-CSRF for the ad-identity layer, and the victim's activity accretes to an attacker-controlled ID | +| **Replay** | A captured valid payload (from the attacker's own session or a leak) replayed against another browser | Same as fixation, without needing to create payloads | +| **Phantom identity** | Endpoint sets the cookie without an identity-graph row | Later requests carry an EC that the KV graph has never seen. Downstream sync and withdrawal logic operate on an identity that half-exists (the organic generation path explicitly refuses to write a cookie when the graph write fails, for exactly this reason) | +| **Un-tombstoneable identity** | Core does not recognize the module's identifier shape | Withdrawal cannot expire or tombstone the identity, which is a compliance failure and not only a bug | +| **Amplification** | The page script cannot observe an HttpOnly cookie, so it cannot know the cookie is already set | A POST on every page view of every session (PR #838's JS gated on reading a cookie its own server marked HttpOnly, making the guard permanently false) | + +## 3. Requirements on the endpoint + +`POST /_ts/api/v1/ec/resolve` MUST: + +1. **Reject cross-site requests with an origin check.** v1 authorizes a + request only when its `Origin` header is the same origin as an accepted + origin under RFC 6454, meaning the scheme, host and port triple of §4 + compared by the §5 rule, with a missing port standing for the scheme's + default, and with a value that is not a serialized origin under §6.1 + never matching. The + default accepted set is the single origin `https://{publisher.domain}` + and nothing else, so a sibling subdomain, the `http://` scheme and a + non-default port are all refused. A missing or foreign `Origin` is + rejected with `403`. Browsers always send `Origin` on POST `fetch`, so + its absence means a non-browser caller, which has no business on a + page-script endpoint. An operator may configure further accepted + origins, each of them compared by the same RFC 6454 rule, for a publisher + whose pages are served from `www` or from another domain. This is the + exact allowlist the 2026-07-31 draft asked for, and it replaces the + suffix match on `publisher.domain` an earlier revision of this spec + described, which admitted every subdomain of the apex including ones + the publisher may not control. The origin check is defense in depth + and not the primary control. The primary control is the module's + envelope verification with audience and session binding, which §3.9 + parks and which still has to land with the vendor scheme. +2. **Verify the payload per module.** The endpoint hands the posted + payload to the selected module's `resolve_from_client` and creates only + what the module returns. Whether the payload is trustworthy is the + module's responsibility, stated on the trait. A real vendor module + verifies a signed, audience-bound, expiring envelope. The draft's + session-binding and replay analysis (see §3.9) is the bar that + verification must clear when the vendor scheme lands. The demo module + verifies a fixed constant and is compiled out of production builds + (§5). `resolve_from_client` is normative in v1, with a no-op default so + server-side modules are untouched. +3. **Preserve the identity-graph invariant.** Implemented. The graph row is + written before the cookie is set, keyed by the module's + `normalize_id_for_kv` canonical form, exactly like the organic create + path. No graph available → no create (`204`), same as organic generation; + a graph write failure → `503`, no cookie. +4. **Round-trip through the lifecycle contract.** Implemented. Read-back + goes through the selected module's `accepts_id`, the KV key through + `normalize_id_for_kv`, and withdrawal reaches the row like any other + identity. A round-trip test drives an opaque client identifier through + organic deferral, resolve, cookie set, and verbatim read-back. +5. **Exist on every adapter, where parity means identical behavior, + including identical refusal.** Partially implemented, documented. The + Fastly adapter routes the endpoint (passing the same bot-gated identity + graph as organic generation, so unrecognized clients cannot create + through resolve either). The Axum, Cloudflare, and Spin adapters + deliberately do not route it, matching `identify` and `batch-sync`, + which need the same platform KV wiring those adapters do not have. The + route list in the Spin adapter documents all three together. The + draft's stronger ask, identical startup rejection of the client-cycle + selection on adapters that cannot serve it, is the agreed follow-up + when the portability adapters gain KV (§7.4). +6. **Be uncacheable and permission-gated on the module's full + declaration.** Implemented. Every response carries `Cache-Control: +no-store`, and the gate is the selected module's complete + `required_permissions()` through the same resolved permission state as + organic creating, not a hard-coded storage check. +7. **Bound every input.** Implemented. Request body at most 65,536 bytes, where + an advertised `Content-Length` over the limit answers `413` before the + read, and the read body is re-checked so a missing or false length does + not bypass the bound. `Content-Type` allowlist: `text/plain` and + `application/json`, matched on the media type alone, case-insensitively, + ignoring parameters (the browser's default `text/plain;charset=UTF-8` + passes); anything else → `415`. The created identifier must fit the + global identifier bounds (at most 256 bytes, cookie-safe alphabet, + shared with every other create path); violation → `400`, never a rewrite. + Core then applies the module's registered code envelope + (`provider-code-registry.md`): the cookie and the identity-graph key + carry `{code}~value`, so a client-set identity is namespaced to its + module exactly like an edge-created one (the demo's cookie value is + `cfix~an-ec`). Status codes are part of the contract: `400` out-of-bounds identifier, + `403` origin rejection, `409` different-identity conflict, `413` body, + `415` content type, `503` graph-write failure, `204` closed gate / no + module / no graph / unverified payload. Tests exercise each rejection. +8. **Define behavior against an existing identity, with no silent + replacement.** Implemented. Resolving to the same identity refreshes + idempotently. Resolving to a different identity while the request + carries a recognized EC is rejected with `409`. Any legitimate + re-identification flow (account link, vendor migration) is an explicit + linking design this spec does not authorize (§7). +9. **Replay consumption and graph persistence as one idempotent + sequence.** Not in v1, by decision rather than omission. The draft's + reservation design (atomic single-key CAS reservation with owner hash, + lease epochs, fenced transitions, and family-epoch revocation + linearization) presupposes a payload with a unique id, a session + binding, and a validity window, which are properties of a concrete vendor + envelope format that does not exist yet, and a CAS-class storage + primitive no production adapter exposes today. Designing the + reservation against a hypothetical envelope would repeat the mistake + this series exists to fix. v1's stance is that the endpoint is safe without it + for the modules v1 ships (the demo creates a constant, feature-gated + out of production), and the reservation lands with the first vendor + scheme, designed against its real envelope, with the draft's §3.9 as + the starting bar. Until then the draft's text is preserved below as the + agreed requirement. + +The 2026-07-31 draft's §3.9 reservation requirement is retained verbatim as +the bar for the vendor-scheme implementation: + +> Neither naive order works: consume-the-nonce-first makes a subsequent +> graph failure unretryable (the token is spent, the identity never +> existed); graph-first lets the losers of a replay race leave residual +> rows. Required shape: consumption is an atomic single-key reservation +> (CAS) keyed by the payload's unique id, with explicit states: `pending` → +> `committed` | `failed`, each carrying an owner hash (the session binding) +> and a monotonic lease epoch. Takeover of an expired `pending` lease +> increments the epoch, and every state transition is a fenced CAS on +> (state, epoch). `failed` is retryable by the same owner at a higher +> epoch. The graph write happens under the reservation and is deterministic +> under its key. A duplicate must never receive the cookie unless it proves +> the original session binding; a requester matching the reservation's +> owner hash has the `Set-Cookie` re-emitted (lost-response recovery), +> anyone else gets a terminal response with no cookie. The same-identity +> no-op first checks the family revocation record, and "revocation wins" is +> enforced by a CAS conditioned on the family epoch read at the start. + +## 4. Requirements on the page script + +- **The re-post guard must not depend on reading an HttpOnly cookie.** + Implemented as the draft's first option. The resolve response sets a + non-HttpOnly companion marker cookie (`ts-ecr=1`) carrying no identity, + which is the only signal the page has that a resolve succeeded. The + marker shares the Edge Cookie's scope and lifetime and is expired + together with it on withdrawal, so a visitor who later re-establishes + the permission can resolve again. +- **The marker must not outlive the module that set it.** The marker + carries no identity, so unlike the `ts-ec` cookie it is not namespaced + by the module code envelope, and a long `Max-Age` that only + withdrawal expires would leave it standing across a module switch. + The visitor would then hold a marker saying a resolve has already + succeeded with no identity behind it, and the page script would + suppress the re-post that would start a new one, so the visitor sits + with no identity rather than a restarted one. Core therefore expires + the marker on any request that carries a `ts-ec` the selected module + does not own, which is the same `{code}~` ownership test core already + applies to the identifier itself. A switch then restarts the client + cycle instead of stalling it. +- **The page leg is permission-gated before vendor contact.** The demo + module contacts no vendor (it posts a constant), but it follows the rule + a vendor module will follow: it declares the Data Use its server-side + module requires (`necessary.operations.storage`, kept in step by a Rust + test), waits on `tsjs.whenPermissions()`, and posts only when that Data + Use is in the set. With no permission state on the page it does not + post. The draft's live-CMP re-check binds the first vendor module rather + than v1, because the demo has no vendor contact to re-check before. A + vendor module must not derive identity or + contact the vendor for a visitor whose resolved permissions do not + satisfy the module's declaration, must re-check immediately before + vendor contact (consent can change between document delivery and + asynchronous vendor contact, including BFCache restoration), and its + injection is keyed off the module selection exactly as the demo's is + today. +- **How the resolved permissions reach the browser is now chosen.** The + requirement above assumes the page can read the server's decision, and + v1 had no carrier for it. The binding constraint is that the JavaScript + bundle is composed at startup and served under a content hash, so one + body is shared across every visitor and cannot carry per-visitor + permission state, which means the signal has to be per request. The + permission-model PR (#1045) picks the injected-value carrier and + delivers it. The page receives `window.tsjs.permissions`, an object + `{"set": ["necessary.operations.storage", "..."]}` naming the Data Uses + set for the request, using the same keys as `permissions.yaml` and + `Permission::as_str()`. Under inline assembly it is injected as a + `