Skip to content

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.

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 redirected false. Hiding redirected keeps 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 getResponseHeader and getAllResponseHeaders expose only the CORS-safelisted headers and those the server lists in Access-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 error events 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 NetworkError rather 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, addRule and removeRule on a link’s style sheet throw SecurityError unless the sheet’s response was CORS-same-origin. The same check guards any rule object the page got from that sheet earlier. A crossorigin link gets its sheet in CORS mode, so CORS can allow the read.
  • Fonts: @font-face sources 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 crossorigin settings, 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, so getImageData, toDataURL and toBlob throw SecurityError. A no-cors image from another origin, or one that redirected through another, is opaque; a crossorigin image the server allows is not. Dom::cors_same_origin gives 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/* or text/csv. With X-Content-Type-Options: nosniff, it must have a JavaScript MIME type.
  • A style sheet that has a MIME type must be text/css, and with nosniff even 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.

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 a file: URL. A request no page made (the host’s own) can read one too.

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 navigate mode.

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.

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.

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.

  • 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-Policy to 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.

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.