Skip to content

Bound cross-origin disclosure by a probe budget, not by third-party cookies - #73

Draft
tomayac wants to merge 8 commits into
mainfrom
probe-budget
Draft

tomayac wants to merge 8 commits into
mainfrom
probe-budget

Conversation

@tomayac

@tomayac tomayac commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Refs #72.

In response to Anne's comment on the WebKit standards position, WebKit/standards-positions#703, and to the probe-limit critique in #72.

The explainer expected this API to be available only where third-party cookies are enabled, which reads as the feature switching off as soon as a browser improves its privacy posture.

What this changes

The third-party cookie condition is gone. In its place is a cross-origin probe budget. Each distinct hash a site can resolve cross-origin yields at most one bit, so the width of any identifier built by writing a chosen subset of resources and reading back which ones disclose is exactly the number of probes granted.

The budget is:

  • Keyed by site, not origin. Subdomains are free, so a per-origin budget would be defeated by minting 01.example.com, 02.example.com and so on, one per bit.
  • Counted in distinct hashes, not calls. Re-probing a hash yields no bit the caller does not already have.
  • Charged whether or not the file is found. Absence and presence are each one bit.
  • Replenished on user activation, up to an implementation-defined maximum, rather than per document or per navigation, which a page could refresh by navigating itself in a loop.
  • Held in a user-agent-maintained map from sites to records, matching how the COS registry is already specified.
  • Discarded only on a user-initiated clear. A site-initiated clearing, such as Clear-Site-Data, must not touch it, or a site could clear itself between probes and draw an unbounded number of them.

Storing origins and origins with a write in flight are exempt, so a site pays once per resource and never for one it wrote or is writing. requestFileHandle() now also rejects when the calling origin is opaque, since every <iframe sandbox> gets a fresh one and could otherwise mint unlimited budget.

The explainer, spec and questionnaire also now separate two threat models that the earlier text ran together. The Public Hash List and GREASE'ing bound what an attacker can infer about a user's browsing. Neither bounds a tracker that writes the resources itself, since a k-anonymity bar constrains an attacker that can only observe state and not one that can set it.

What is deliberately not settled here

This PR establishes the mechanism, not its final shape. Review raised several refinements that are being tracked separately rather than growing this PR:

  • how the budget should be keyed against an attacker assembling an identifier from many embedded frames
  • how to bound accumulation, given that the budget currently replenishes without a ceiling on total distinct hashes
  • entry removal as an ephemeral-fingerprinting channel, which the budget does not bound
  • whether replenishing on user activation is the right trigger, which needs cross-vendor alignment
  • the budget's actual value, on which there is no cross-vendor agreement

Thanks to @johannhof and @tomvangoethem for the review that produced most of the above.

Tests: web-platform-tests/wpt#61811. index.html is left for CI to regenerate.

…ookies

The explainer said user agents were expected to make this API available
only where third-party cookies are enabled. That reads as the feature
switching off the moment a browser improves its privacy posture, which
is what Anne raised in WebKit's standards position.

Replace it with the mechanism that actually bounds the risk. Each
distinct hash an origin can resolve cross-origin yields at most one bit,
so an identifier's width is exactly the number of distinct cross-origin
probes granted. Add a "maximum cross-origin probe count", a per-origin
budget and probe set, and a "charge a cross-origin probe" step on the
read path.

Counted in distinct hashes rather than calls, charged whether or not the
file is found, unpartitioned by top-level site, replenished on user
activation, and cleared with site data. Storing origins and pending
writers are exempt, so a site pays once per resource and never for one
it wrote or is writing.

Also separate the two threat models throughout. The Public Hash List and
GREASE'ing bound inference about a user's browsing, but neither bounds a
tracker that writes the resources itself: a k-anonymity bar constrains an
attacker that can only observe state, not one that can set it.
@tomayac

tomayac commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator Author

@johannhof @arturjanc requesting your review here. GitHub only lets review requests go to repo collaborators, hence the mention instead.

This is the change that came out of the discussion around Anne's comment: the third-party cookie condition is gone, replaced by a per-origin cross-origin probe budget that bounds identifier width directly.

Comment thread index.bs Outdated
Comment thread index.bs Outdated
Comment thread README.md Outdated
Comment thread index.bs Outdated
Comment thread index.bs Outdated
Comment thread index.bs Outdated
Comment thread README.md Outdated
Comment thread security-privacy-questionnaire.md Outdated
Comment thread README.md
Per review: expressing this as "each origin has an associated budget
that persists across page loads" hand-waves the persistence. Make it a
global cross-origin probe map from origins to records, matching how the
COS registry is already specified, with budget and probed hashes as
struct items and a "get the cross-origin probe record" helper.
Per review: subdomains are free, so a per-origin budget is defeated by
minting 01.example.com, 02.example.com and so on, one per bit, each
collecting a full budget. A registrable domain has a cost, which is what
makes the key meaningful.

The cross-origin probe map is now keyed by site, obtained from the
requesting origin, and the explainer and questionnaire follow.

This does not address the second half of that review comment, whether
the limit should additionally be enforced per top-level site. That is
still open.
Per review: the budget reset was written as "when a user clears an
origin's site data", but a site can clear its own via Clear-Site-Data.
That gave any site an unbounded probe count for the price of one header,
defeating the mechanism entirely.

Only a user-initiated clear, through the user agent's own settings or
storage UI, discards a cross-origin probe record. A site-initiated
clearing must not modify the map at all.

The rule also moves from Eviction, which is non-normative, up to Storage
limits alongside the map definition, so a security-critical requirement
carries normative force.
Per review: every sandboxed iframe gets a fresh opaque origin, so a page
could mint unlimited probe budget by spawning them and postMessage-ing
the bits back.

The spec previously left the opaque calling-origin case undefined, which
is no longer tenable now that a budget hangs off the caller's identity.
Reject with NotAllowedError. An opaque origin can never be a storing
origin, never matches a same-site comparison, and offers no key to bound
a budget under, so there is nothing coherent to grant it.
Per review: 32 is the more common size and still comfortably covers most
trackers' user bases. Also note that many trackers would settle for 16
bits and absorb the collisions, which makes the point sharper than
framing 33 as the threshold to worry about.
@tomayac

tomayac commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator Author

Proposal for the three open threads on budget keying

@johannhof @tomvangoethem, three of the open threads are the same defect seen from different angles, so answering them separately would fork one discussion into three. Putting a concrete proposal here instead.

First, a correction to something I said in two of those threads. I claimed a <script src> tracker was the decisive gap because it runs in the first party's origin and is charged to the embedding site rather than to itself. The first half is true, the conclusion was overstated: such a tracker can resolve at most the embedding site's budget worth of distinct hashes, and it reads the same hashes on every site, so its identifier width is bounded by the budget like anyone else's. It is a case of the accumulation problem below, not a separate unbounded one. Sorry for the noise.

What is actually unbounded is assembly. N iframes, each at its own site, each drawing a full budget, postMessage-ing their share into one page context, gives an identifier of N times the budget. That is Tom's N-iframes point and Johann's bit1.com ... bit16.com point, and site-keying only raised the unit price from a subdomain to a registrable domain.

What grows without limit is tenure. The budget replenishes on user activation with no ceiling on total distinct hashes, and a hash stays in the probe set free forever once resolved. So a site the user visits often keeps everything it has already resolved and keeps earning more. Tom is right, and the current text is more permissive than his reading of it: it is a lifetime accumulator, not a per-visit allowance.

Proposed shape, two changes:

  1. A second budget keyed on the top-level site, charged in addition to the per-requesting-site one, and only when the requesting site differs from the top-level site. It counts distinct hashes across all third parties in that page context, which is exactly the quantity the assembly attack maximises. Exempting same-site-as-top-level keeps a first party loading several of its own models from competing with its embeds, and a single party cannot mount the assembly attack against itself.

  2. A cap on the probe set's size, LRU-evicted, so a site can hold at most a bounded number of hashes resolvable for free at once. Re-resolving an evicted hash costs budget again. This bounds concurrent width, which is what matters for recognition, instead of letting it grow with how long a site has been in the user's life.

What I am unsure about. Whether a shared page budget lets a hostile embed starve a legitimate one, and whether the eventual constant is small enough to matter given that concurrent width would still be on the order of the cap plus one visit's budget rather than exactly the cap.

Does this look like the right shape? Happy to take it to a meeting instead if that is faster.

tomayac added a commit that referenced this pull request Aug 25, 2026
The 2000-burst / 20-per-second token bucket was a throughput limit, and
it was being read as this proposal's privacy defense, which it was never
derived to be. It was quoted back in #72 as evidence against the design,
fairly, since 2000 binary queries is ample to identify a user.

Remove the figures and separate the two concepts the section conflated:
rate limiting as ordinary abuse resistance, and a probe budget bounding
total distinct hashes as the actual privacy mechanism. Point at #72 and
#73, where the replacement is being worked out, rather than publishing a
number nobody should implement yet.
tomayac added a commit that referenced this pull request Aug 25, 2026
The 2000-burst / 20-per-second token bucket was a throughput limit, and
it was being read as this proposal's privacy defense, which it was never
derived to be. It was quoted back in #72 as evidence against the design,
fairly, since 2000 binary queries is ample to identify a user.

Remove the figures and separate the two concepts the section conflated:
rate limiting as ordinary abuse resistance, and a probe budget bounding
total distinct hashes as the actual privacy mechanism. Point at #72 and
#73, where the replacement is being worked out, rather than publishing a
number nobody should implement yet.
tomayac added a commit that referenced this pull request Aug 26, 2026
The cross-site probing section treats a requestFileHandle() call as the
unit of probing, which was already incomplete for the three declarative
integrations and is worse now that fetch() can reach COS: a site learns
the outcome of any integration lookup by observing whether its own
server received the fallback request, which is the same single bit a
NotFoundError carries.

No integration discloses more than the imperative API, and origins
scoping, availability gating, and GREASE'ing apply to all of them
unchanged. The limit is what breaks: counting only imperative calls
leaves it trivially avoidable, since a fetch() lookup is as scriptable
in a loop as requestFileHandle() is. Say so in the explainer, in the
spec's non-normative note, and in question 3 of the questionnaire.

This bears on the probe budget being worked out in #72 and #73, which
currently counts imperative calls only.
Brings in the fetch() host integration and the link fixes.

Three conflicts, all where main's "integration lookups are probes too"
change met this branch's probe budget:

- README and index.bs: main said a probe limit has to count all four
  surfaces; this branch has two bounds rather than one, so say that a
  hash first resolved through the fetch() integration is rate-limited
  and charged against the budget exactly as an imperative read is, and
  move the paragraph up into the cross-site probing section where the
  bounds are introduced.
- Questionnaire Q02 and Q03: keep this branch's rewrites and fold in
  main's additions, the COS-entry-is-bytes-only note in Q02 and the
  integration probing paragraph in Q03, phrased against the budget.
The three notes added in 591820e say an integration lookup counts as a
probe, but nothing made that true. The budget is charged in "complete a
read request", which only requestFileHandle() invokes, so a fetch() with
crossOriginStorage reached the registry without paying anything. Section
8 likewise listed what integrations build on and omitted the budget.

Factor the read path into an exported "resolve a COS lookup", taking a
hash and an origin and returning an entry or one of "refused",
"pending", "unavailable". It carries everything that bounds a read: the
opaque-origin refusal, the storing-origins and pending-writers
exemptions, the probe charge, and availability gating. "complete a read
request" now maps the failure values onto the DOMException each case
calls for and wraps a success in a handle; integrations run the same
algorithm and must treat all three failures alike as a cache miss.

This also closes a smaller leak: the opaque-origin refusal lived in the
requestFileHandle() steps alone, so an integration invoked from a
sandboxed iframe would have sidestepped it and minted fresh budget.

Explainer and questionnaire follow: the budget's shape list now says it
is charged on every surface, and "not calls" becomes "not lookups".
@tomayac
tomayac marked this pull request as draft September 18, 2026 23:04
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.

3 participants