Triiiceratops Content Security Policy

Content Security Policy

Triiiceratops under a strict Content Security Policy: no unsafe-eval, no unsafe-inline for scripts, and what your policy does need to allow.

Triiiceratops runs under a strict Content Security Policy. It does not require unsafe-eval, and it does not require unsafe-inline for scripts. Runtime styles (the plugin/global style service) prefer constructable stylesheets (adoptedStyleSheets), which need no inline <style> and are CSP-friendly by default. The service falls back to a <style> element, carrying your CSP nonce, whenever constructable stylesheets are unavailable, or whenever the host advertises a nonce at all. Nonces cannot attach to constructable stylesheets, so advertising one always opts back into the <style> path.

The viewer itself also uses inline style="…" attributes for dynamic layout (panel widths, positioning, and similar). A nonce does not cover style attributes; only 'unsafe-inline' does, so every policy below allows them via style-src-attr.

The two recipes need slightly different policies, because only the light-DOM Svelte package's component styles are nonce-addressable.

Which recipe applies to you?

Your integration

Recipe

Svelte (triiiceratops/svelte)

Light DOM

React (triiiceratops/react)

Shadow DOM

Vue (triiiceratops/vue)

Shadow DOM

Any framework (the custom element)

Shadow DOM

The React and Vue wrappers host the same <triiiceratops-viewer> custom element every other non-Svelte integration uses, so they have the same shadow-root style requirements. They follow the shadow-DOM recipe, and there is no nonce for them to advertise.

Light DOM (Svelte component)

Component styles are extracted to a same-origin stylesheet at build time, so <style> elements can be locked to 'self' plus a nonce:

Content-Security-Policy:
    default-src 'self';
    script-src 'self';
    style-src 'self' 'nonce-<RANDOM>';
    style-src-attr 'unsafe-inline';
    img-src 'self' data: blob: https:;
    media-src 'self' blob: https:;
    connect-src 'self' https:;
    worker-src 'self' blob:;
    object-src 'none';
    base-uri 'self'

Shadow DOM (custom element)

Used by the custom element directly and by the React and Vue wrappers.

The custom element injects each Svelte component's scoped CSS into its shadow root as a <style> element at runtime. Svelte has no per-element style nonce hook for this path, so the shadow root needs 'unsafe-inline' in style-src itself, not just a nonce:

Content-Security-Policy:
    default-src 'self';
    script-src 'self';
    style-src 'self' 'unsafe-inline';
    style-src-attr 'unsafe-inline';
    img-src 'self' data: blob: https:;
    media-src 'self' blob: https:;
    connect-src 'self' https:;
    worker-src 'self' blob:;
    object-src 'none';
    base-uri 'self'

Notes (both recipes):

  • script-src 'self': no unsafe-eval, no unsafe-inline. Serve the viewer and plugin scripts from your own origin (or add the CDN origin you load them from).

  • style-src-attr 'unsafe-inline': covers the viewer's inline layout style attributes. These cannot execute script under the strict script-src above, so we recommend allowing them.

  • img-src includes data: for canvas/thumbnail data URIs, blob: for generated image exports, and https: for remote IIIF image tiles; tighten to the specific IIIF hosts you use.

  • media-src governs <audio>, <video> and <track>, so it matters only when the AV plugin is loaded (see audio and video below). Drop the directive entirely if you never play time-based media.

  • worker-src 'self' blob:: a plugin may run work off the main thread through a blob-URL Worker; hls.js, loaded by the AV plugin, is the first-party example.

  • connect-src must allow the IIIF hosts your manifests and image services live on and, if you pass content states, the hosts those live on too (see content states below). A plugin's own server belongs here as well: an annotation storage adapter fetches from whatever host you wrote it for, and the viewer cannot know that host to allow it for you.

Content states

A content state delivered as a bare IIIF URI is dereferenced: the viewer fetches the document at that URI before it can know which manifest to open. The request goes through the same fetch path a manifest-id uses, so it is governed by connect-src. This is easy to miss, because the URI is often not on the host your manifests come from.

A policy locked down to your own manifest host, like

connect-src 'self' https://iiif.example.edu;

opens every manifest you publish and refuses a content state someone links to you from anywhere else. The failure is quiet by design: ingestion never throws, so the browser blocks the request and the viewer reports it as a content-state scoped viewererror with the code content-state-dereference-failed. It then falls back to loading the URI as a manifest. That second request goes to the same blocked host, the policy refuses it too, and nothing opens. Allow the hosts you accept content states from, or accept content states only as JSON on the content-state input, which fetches nothing.

A content-state URI gets the same trust as a manifest URI, and this policy controls both.

Audio and video

Core is an image viewer and needs no media-src at all. The directive is in the recipes above because the AV plugin is opt-in but common enough to plan for. Three things to know:

  • media-src covers the caption track too. CSP treats <track> as a media request, so a policy that allows the video host but not the VTT host loads the media and silently offers no captions.

  • blob: is required for HLS. Where a browser has no native HLS the plugin falls back to hls.js, which drives the media element through Media Source Extensions, so the element's src becomes a blob URL. Without blob: in media-src, progressive files play and HLS streams do not.

  • The lazy chunks are scripts. The plugin fetches hls.js, the waveform parsers, the segment sequencer and the transcript panel as separate ES modules at the moment a manifest needs them, so they are governed by script-src and must be served from an origin it allows. Under the IIFE distribution that means the whole dist/ directory, not one file out of it.

Media also has to be CORS-readable. That is a requirement of the plugin rather than your CSP, and the usual reason a file that plays in a plain <video> tag will not play here. See the AV plugin's page for details.

The PDF export plugin has one lazy chunk too. Its IIFE fetches pdf-lib from the sibling pdf-lib.js the first time a user exports, so that file is governed by script-src in the same way and is served from the same dist/ directory.

Advertising the style nonce

This applies to the light-DOM recipe only. The shadow-DOM recipe already allows 'unsafe-inline' in style-src for its shadow-root styles, so it has no nonce to advertise.

The style service discovers a nonce automatically. Advertise yours with a meta tag (substitute the same random value used in the CSP header), or on any nonced <style> / <script> / <link> element already on the page:

<meta property="csp-nonce" content="<RANDOM>" />

The service reads the .nonce IDL property in preference to the nonce attribute, matching how browsers hide the attribute after parsing. When the host supplies no nonce and constructable stylesheets are available, no inline style is emitted at all.

Trusted Types

The viewer and plugins operate under a Trusted Types policy (require-trusted-types-for 'script') in Chromium: DOM-sink usage in core and the SDK goes through a policy or safe APIs. Core installs a pass-through default Trusted Types policy (untrusted HTML is sanitized upstream before it reaches any DOM sink). If your app already installs its own default policy, core defers to it. Svelte creates its own svelte-trusted-html policy at module load, so that name must be allowed too or the viewer never mounts. Every bundle that embeds Svelte creates it again (the element IIFE and each plugin IIFE), and a repeated policy name throws without 'allow-duplicates'. If you enforce Trusted Types, allow these names:

Content-Security-Policy: require-trusted-types-for 'script'; trusted-types default svelte-trusted-html 'allow-duplicates';

Verified against the packed packages

CI runs CSP packed-consumer fixtures under a realistic strict policy (no unsafe-eval, no script unsafe-inline) across Chromium, Firefox, and WebKit: a light-DOM Svelte consumer and a custom element IIFE page render and theme correctly, a plugin installs styles via the nonce fallback path, and the page records zero securitypolicyviolation events. A Trusted Types page is verified on Chromium.