Skip to content

[pre-flight] Confine non-special-scheme 'self' matching to a registered scheme allowlist - #3

Draft
jonathanKingston wants to merge 4 commits into
cifrom
self-tuple-origin
Draft

[pre-flight] Confine non-special-scheme 'self' matching to a registered scheme allowlist#3
jonathanKingston wants to merge 4 commits into
cifrom
self-tuple-origin

Conversation

@jonathanKingston

@jonathanKingston jonathanKingston commented Aug 27, 2026

Copy link
Copy Markdown

Pre-flight CI run for the branch behind rust-ammonia#76. Not for merge here.

What changed

Review feedback on rust-ammonia#76 was that giving component comparison to every scheme without a default port "willfully violate[s] the spec by default", and that the client application should have to opt in. Chrome's version of that opt-in is an allowlist, so this follows Chrome's model.

The new commits add scheme_registry, an allowlist of schemes whose origin the embedder — not the URL Standard — defines:

use content_security_policy::scheme_registry;

scheme_registry::add_standard_scheme("tauri");

The 'self' component-comparison branch added by the first commit is now gated on that registry. It is empty by default, so a caller that does not opt in keeps exactly the behaviour the specification prescribes: a non-special scheme has an opaque origin and 'self' matches nothing.

How Chrome does it

Chrome registers custom schemes as standard at start-up — url::AddStandardScheme, populated from ContentClient::AddAdditionalSchemes — and gates CSP host matching on GURL::IsStandard:

// services/network/public/cpp/content_security_policy/csp_source.cc
bool SourceAllowHost(const mojom::CSPSource& source, const GURL& url) {
  return SourceAllowHost(source, url.IsStandard() ? url.GetHost() : "");
}

A URL whose scheme is not standard reports an empty host, so it matches no host-bearing source expression, 'self' included. Chrome's 'self' source is itself built from the document URL's scheme, host and port (ComputeSelfOrigin), never from an origin, which is why an opaque origin does not stand in its way there.

Deliberate departures from Chrome:

  • The registry is behind an RwLock, so Chrome's url::LockSchemeRegistries step (which exists because its vectors are unsynchronised) is not needed.
  • Chrome records a SchemeType per scheme; every scheme here is treated as SCHEME_WITH_HOST_AND_PORT.
  • Chrome's built-in standard schemes share the same table. Here the registry holds only what the embedder registered, so is_standard_scheme is false for http, https and friends, whose origins the URL Standard already defines.

Scope

  • Registration grants same-scheme, same-host, same-port matching and no upgrade allowance, so 'self' on a registered custom://example.com document still does not reach https://example.com/.
  • Registering a scheme with a default port is refused rather than accepted and then quietly ignored at matching time: a tuple origin cannot tell a default port from an absent one, so 'self' matching skips those schemes either way. Registration and the matching site read the same default_port table, so they cannot drift.
  • file is registerable, since it has no default port. That admits only file://server/path against a matching tuple origin — file:///a/b parses with no host at all, so component comparison cannot match it however it is registered.
  • Host-source expressions are untouched. They name their own scheme, so they never needed the registry.
  • A caller that never opts in pays one relaxed atomic load per 'self' source expression.

Testing

cargo test, cargo test --features=serde, cargo test --features=version-sync, cargo fmt --check, and cargo clippy --all-targets --all-features (no new warnings) all pass.

tests/self-tuple-origin.rs gains coverage for the gate itself: an unregistered scheme matches nothing however exactly its components line up, registering one scheme admits no other, and host-source expressions still work without registration.

Jonathan Kingston added 4 commits August 24, 2026 23:13
The 'self' fast path compares the protected resource's origin against
url.origin(), but the URL standard gives every non-special-scheme URL an
opaque origin - so for a document served over a scheme the user agent has
registered itself, 'self' could never match even an exactly same-origin
subresource, and the fallback branch only admits http(s)/ws(s). Any policy
at all therefore blocked every same-origin subresource on such a document.

A user agent that models those documents with tuple origins - the treatment
Chromium and WebKit give schemes registered as "standard", and what makes
`script-src 'self'` work on a chrome-extension:// or moz-extension:// page
today - now gets component comparison: equal scheme, equal host, and equal
ports, where "no port on either side" (tuple sentinel port 0, no default
port for the scheme) also counts as equal.

Same scheme only, and only for schemes with no default port. The existing
relaxation is the http -> https/wss upgrade allowance, which is meaningful
only for those schemes; extending it to a registered scheme would let
'self' in a custom://example.com document match https://example.com/.
Special schemes are otherwise unaffected: a same-scheme match there was
already taken by the origin-equality fast path.
…owlist

The previous commit gave component comparison to every scheme without a
default port, which changes what a policy means for schemes the caller never
asked about. The specification says such a URL has an opaque origin and so
matches nothing; departing from that should be something an embedder opts
into, not the default.

Chrome's version of this is an allowlist: the embedder registers its schemes
as "standard" at start-up (url::AddStandardScheme, populated from
ContentClient::AddAdditionalSchemes), and CSP host matching is gated on
GURL::IsStandard - a URL whose scheme is not standard reports an empty host,
so it matches no host-bearing source expression, 'self' included.

Add `scheme_registry` as that list. It is empty by default, so a caller that
does not opt in keeps exactly the behaviour the specification prescribes, and
pays one relaxed atomic load to find that out. An embedder that registers a
scheme is asserting that it, rather than the URL Standard, defines the origin
of URLs with that scheme, and that it supplies the matching tuple origin.

Registration grants same-scheme, same-host, same-port matching and nothing
else, so 'self' on a registered custom://example.com document still does not
reach https://example.com/. Registering a scheme the registry cannot speak
for - a special scheme, or one with a default port a tuple origin could not
tell from an absent one - is refused rather than accepted and then ignored at
matching time.

Host-source expressions are untouched: they name their own scheme, so they
never needed the registry and keep working without it.
The module doc explained the opaque-origin problem twice and carried a
"Differences from Chromium" section longer than the code it described, and
several tests restated their own names. Keep what a caller cannot infer -
what registering a scheme grants, that the registry is empty by default,
that the embedder must still supply the tuple origin - and drop the rest.
`default_port` already refuses ftp, http, https, ws and wss, so the list
duplicated it for every entry but one. The exception was `file`, and excluding
it bought little: a `file:///a/b` URL has no host, so component comparison
cannot match it whatever the registry says. All the carve-out blocked was
`file://server/path` against a matching tuple origin - which is the decision an
embedder is making by registering the scheme at all.

Leaves one condition, derived from the same `default_port` the matching site
uses, so the two cannot drift.
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.

1 participant