Cross-origin reads: what niso guarantees
Generated from docs/security.md when the site was built at 7aa23e2.
This is niso’s threat model for cross-origin reads: what a page’s script can learn about responses from other origins and about local files, and what it can’t. The attacker is a page, meaning its scripts and anything it loads. The assets are the bytes, headers and status of responses from origins other than the page’s own, and the files on the machine niso runs on.
The fetch algorithms are in crates/niso-core/src/net/fetch.rs and the
transport and resource loading are in crates/niso-core/src/net.rs. The
page-level tests for each guarantee are in crates/niso-quickjs/tests/net.rs,
and the unit tests are at the end of fetch.rs.
No credentials, so network position is the only ambient authority
Section titled “No credentials, so network position is the only ambient authority”niso has no cookie jar, no HTTP authentication, no client certificates and
no HTTP cache. A request with credentials: "include" therefore sends
nothing a server could recognise. The credentials mode still decides the CORS
checks, though: with credentials, Access-Control-Allow-Origin: * doesn’t
pass, and a preflight cache entry made without credentials doesn’t serve a
request that has them. Those rules are already right for when credentials
arrive.
So a page can’t read a response that the server tailored to the user’s
session, because there is no session. What a page can still reach is
whatever the machine’s network position reaches: services on localhost, on
the LAN or on an intranet. CORS protects what those services return, but
not the requests themselves. A simple request (a GET, HEAD or POST with
safelisted headers) is sent whatever the response’s CORS headers say, as in
browsers. niso has no Private Network Access checks yet.
Response tainting decides every read
Section titled “Response tainting decides every read”Each response niso fetches for a page gets a tainting. It is basic when the response is from the page’s own origin and cors when it is from another origin and passed the CORS check. It is opaque when a no-cors request got it from another origin, including when a redirect took the request there. Basic and cors responses are CORS-same-origin and opaque ones are CORS-cross-origin. Every API below takes its answer from this one tainting, not from comparing origins.
- fetch(): an opaque response has status 0, no status text, no headers,
no body, an empty URL and
redirectedfalse. Hidingredirectedkeeps the other origin from telling the page where it redirects. - XMLHttpRequest: runs on the same fetch algorithms in CORS mode, so a
cross-origin response that fails the CORS check is a network error, and
getResponseHeaderandgetAllResponseHeadersexpose only the CORS-safelisted headers and those the server lists inAccess-Control-Expose-Headers. An upload listener forces a preflight. - Classic scripts: a script with an opaque response runs, but its errors
are muted. The page’s
errorevents see “Script error.” with no filename, position or error object. - importScripts: the script is fetched as a no-cors script. If its
response is opaque, its errors are muted, and anything it throws reaches
the worker as a bare
NetworkErrorrather than the script’s own error. - Module scripts: these are fetched in CORS mode, so a cross-origin
module must pass the CORS check or it doesn’t load. A dedicated worker’s
own script must be same-origin or a
data:URL. - CSSOM:
cssRules,rules,insertRule,deleteRule,addRuleandremoveRuleon a link’s style sheet throwSecurityErrorunless the sheet’s response was CORS-same-origin. The same check guards any rule object the page got from that sheet earlier. Acrossoriginlink gets its sheet in CORS mode, so CORS can allow the read. - Fonts:
@font-facesources load in CORS mode with same-origin credentials. A font from another origin that doesn’t allow CORS never loads. - Canvas: an img’s source is fetched as an image request with the img’s
crossoriginsettings, whether blitz fetches it (in the document) or niso does (outside it). The image loader records each response’s tainting, and a canvas that draws an image whose response was opaque loses its origin-clean flag, sogetImageData,toDataURLandtoBlobthrowSecurityError. A no-cors image from another origin, or one that redirected through another, is opaque; acrossoriginimage the server allows is not.Dom::cors_same_origingives the same answer for a response described by its URLs and headers.
Opaque bytes still reach rendering, so their type is checked
Section titled “Opaque bytes still reach rendering, so their type is checked”An opaque response’s body never reaches the page’s script, but rendering and script execution use it. An opaque image is drawn, an opaque style sheet is applied, and an opaque classic script runs. Applying a style sheet or running a script reveals some of its effect, so the request’s destination limits what such a response may be:
- A script or worker script can’t be
audio/*,image/*,video/*ortext/csv. WithX-Content-Type-Options: nosniff, it must have a JavaScript MIME type. - A style sheet that has a MIME type must be
text/css, and withnosniffeven an untyped one is refused. Otherwise another origin’s HTML or JSON could be parsed as CSS and probed through computed styles. This applies to linked sheets and to their@imports. - Opaque-response blocking refuses an opaque response whose MIME type is on
ORB’s never-sniffed list. The list includes JSON-like, archive and
document types, DASH (
application/dash+xml) and HLS (application/vnd.apple.mpegurl).
blitz, which renders the page, asks for its resources without saying what
they are for. niso doesn’t start those requests until it knows. A request is
a style sheet when its URL is a stylesheet link’s href (in the link’s
CORS mode), or an @import of a sheet niso is handing to blitz or of a
<style> element. It is an image when it is an <img>’s src (in the
image’s CORS mode). It is a font when it is a @font-face source.
Anything else is treated as an image, which covers background images.
The embedder’s loader
Section titled “The embedder’s loader”A host can answer requests itself through a loader that maps a URL to bytes. niso treats the URL as the response’s identity, so a loader must never serve one origin’s private data under another origin’s URL. The loader has no headers, so:
- The loader’s answer for the page’s own origin is basic.
- Its answer for another origin is opaque to a no-cors request. To a CORS or same-origin request it is a network error, because nothing could pass the CORS check.
- Only a page that is itself a
file:URL can have the loader read afile:URL. A request no page made (the host’s own) can read one too.
What a page’s requests are held to
Section titled “What a page’s requests are held to”The Request constructor and the Headers guards throw for a bad request. The
network enforces the same rules again in Rust for every caller, so a C
caller of niso_net_fetch_request can’t skip them:
- A forbidden method (
CONNECT,TRACE,TRACK) is a network error. - Forbidden request headers are dropped:
Cookie,Origin,Referer,Host,Sec-*,Proxy-*, and method overrides that name a forbidden method. - A no-cors request can only be a GET, HEAD or POST, and it keeps only the no-CORS-safelisted headers.
- A referrer URL from another origin becomes the client’s own URL.
- No page’s request can use the
navigatemode.
The JS binding gets its header predicates from the network itself, through
niso_fetch_forbidden_request_header and
niso_fetch_cors_safelisted_request_header, so there is only one copy of
those rules.
Failures say nothing about the other origin
Section titled “Failures say nothing about the other origin”fetch() rejects every failure with TypeError("Failed to fetch"). This
covers a CORS failure (whatever Access-Control-Allow-Origin said), a
preflight’s status, an ORB block, a refused connection and a TLS error. The
reason goes only to the host’s console, which the page can’t read. A page
therefore can’t use error text to read another origin’s headers or to scan
ports. WebSocket failures close with code 1006 and an empty reason.
Mixed content
Section titled “Mixed content”A client has an HTTPS state. A document has it when https delivered it. A
blob:, data: or about: document or worker has it when its creator had
it, so a data: worker can’t use its URL to escape the check. A client
with an HTTPS state gets a network error for any http request, except that
an http image is upgraded to https. Requests to the machine itself
(localhost and loopback addresses) are exempt.
The preflight cache
Section titled “The preflight cache”Preflight results are cached by the requesting client’s origin (niso has no nested browsing contexts, so this is the partition), the request’s origin, the URL, the credentials mode, and the method or header name. One response can add at most 64 entries, the cache holds at most 4096, and expired entries are dropped when it is full. A thread that panics while holding the cache leaves it usable.
What niso doesn’t guarantee
Section titled “What niso doesn’t guarantee”- Origin isolation in the process: every origin’s scripts and the opaque bytes they loaded share one process and address space. These guarantees hold against script that uses the web platform’s APIs. They don’t hold against an exploit of the JS engine or a speculative-execution side channel.
- Response sniffing: ORB doesn’t sniff bodies. An opaque HTML or JSON response served with a script-compatible type, or with no type, can still run as a classic script. JavaScript’s own syntax keeps that from revealing most data, as it does in browsers without ORB’s sniffer.
- Content Security Policy, CORP, COEP and COOP: none of these is
implemented. A page can’t restrict what it loads, and a server can’t use
Cross-Origin-Resource-Policyto refuse no-cors loads. - DNS rebinding and private networks: origins are compared by host name, and nothing stops a public origin from reaching private addresses.
- Timing: how long a cross-origin load takes is observable, as on the web.
When credentials arrive
Section titled “When credentials arrive”A cookie jar or HTTP authentication would make credentials: "include"
meaningful, and the threat model would change with it:
- Opaque responses would carry session-specific data, which makes ORB’s missing sniffer and the missing CORP more serious.
- SameSite and third-party cookie rules would be needed.
- The loader, which has no notion of credentials, would need one.