Plugin SRI support + documented deployment CSP

Plugin entries may now be { url, integrity, crossorigin } so off-origin
plugins can be pinned with Subresource Integrity (crossorigin defaults to
anonymous when a hash is set). Documents a sample CSP header and SRI hash
generation in SECURITY.md; the policy is a header (deployment-specific
origins) rather than a baked meta tag.
This commit is contained in:
reverse 2026-06-20 08:37:17 +00:00
parent dad10e80f7
commit c7a3a5dba0
6 changed files with 118 additions and 8 deletions

View file

@ -29,3 +29,56 @@ A few things that are intentional, not bugs:
Things we *do* want to hear about: ways a remote party (a malicious message,
channel, or server response) could run code, steal a session, or break out of
the intended sandbox in the client.
## Hardening a deployment
### Subresource Integrity for plugins
Pin any plugin served from a third-party origin with an SRI hash, so a
compromised host can't silently swap the file. A `plugins` entry may be an
object instead of a bare URL:
```json
{
"plugins": [
"/app/plugins/orbit-clock.js",
{ "url": "https://cdn.example/orbit-x.js", "integrity": "sha384-…" }
]
}
```
Generate the hash with: `openssl dgst -sha384 -binary file.js | openssl base64 -A`
(prefix the result with `sha384-`). Same-origin plugins don't strictly need it.
### Content-Security-Policy
Orbit ships no `<meta>` CSP because the right policy depends on your deployment
(IRC WebSocket host, whether you use the Turnstile challenge, where plugins are
hosted). Set it as a response header at your edge. A good starting point, with
the parts you must adjust marked:
```nginx
# Replace wss://YOUR-IRC-HOST with config.json → server.url's origin.
# Add any off-origin plugin hosts to script-src. Drop the challenges.cloudflare.com
# lines if you don't use the Turnstile challenge.
add_header Content-Security-Policy "
default-src 'self';
script-src 'self' https://challenges.cloudflare.com;
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
font-src 'self' https://fonts.gstatic.com;
img-src 'self' data: blob: https:;
connect-src 'self' wss://YOUR-IRC-HOST;
frame-src https://challenges.cloudflare.com;
worker-src 'self';
manifest-src 'self';
base-uri 'self';
form-action 'self';
object-src 'none';
frame-ancestors 'self';
" always;
```
Roll it out with `Content-Security-Policy-Report-Only` first and watch the
browser console / `report-to` for violations, then switch to the enforcing
header once it's clean. Self-hosting the Google Fonts CSS/woff2 files lets you
drop the `fonts.googleapis.com` / `fonts.gstatic.com` origins entirely.

View file

@ -18,7 +18,15 @@ Add script URLs to `plugins` in [`config.json`](../CONFIG.md):
```
They load in order, after the app boots. Host them anywhere the page can reach
(same-origin recommended).
(same-origin recommended). For a third-party origin, pin the file with
Subresource Integrity by giving an object instead of a URL:
```json
{ "plugins": [{ "url": "https://cdn.example/x.js", "integrity": "sha384-…" }] }
```
(`crossorigin` defaults to `anonymous` when an `integrity` hash is set.) See
[SECURITY.md](../SECURITY.md) for generating the hash and a sample CSP header.
## Writing a plugin

View file

@ -1,7 +1,7 @@
// Tchatou service worker — installable PWA + offline app shell.
// Scope: /app/. Only handles same-origin /app/ GETs; the IRC websocket and all
// API calls (cloudflare, change_password, upload) pass straight through.
const CACHE = 'tchatou-v46';
const CACHE = 'tchatou-v47';
const SHELL = ['/app/', '/app/index.html', '/app/favicon.svg', '/app/orbit-icon.svg', '/app/manifest.webmanifest'];
self.addEventListener('install', (e) => {

View file

@ -46,10 +46,18 @@ export interface AppConfig {
imageUpload: boolean; // the composer image button + paste/drag upload
register: boolean; // account creation (the "Créer un compte" tab)
};
/** Operator-listed plugin scripts (URLs) loaded at startup. See docs/PLUGINS.md. */
plugins?: string[];
/**
* Operator-listed plugin scripts loaded at startup. Each entry is a URL, or an
* object adding Subresource Integrity (recommended for off-origin plugins):
* "plugins": ["/app/plugins/x.js", { "url": "https://cdn/y.js", "integrity": "sha384-…" }]
* See docs/PLUGINS.md.
*/
plugins?: PluginEntry[];
}
/** A plugin to load: a bare URL, or a URL with an SRI hash (and optional crossorigin). */
export type PluginEntry = string | { url: string; integrity?: string; crossorigin?: string };
export const DEFAULT_CONFIG: AppConfig = {
server: { url: 'wss://www.swaygo.fr/irc/' },
startup: { channels: ['#taverne'] },

View file

@ -0,0 +1,23 @@
import { describe, it, expect } from 'vitest';
import { scriptAttrs } from './loader';
describe('scriptAttrs', () => {
it('wraps a bare URL string with no SRI', () => {
expect(scriptAttrs('/app/plugins/x.js')).toEqual({ url: '/app/plugins/x.js' });
});
it('keeps the integrity hash and defaults crossorigin to anonymous (required for SRI)', () => {
expect(scriptAttrs({ url: 'https://cdn/y.js', integrity: 'sha384-abc' }))
.toEqual({ url: 'https://cdn/y.js', integrity: 'sha384-abc', crossorigin: 'anonymous' });
});
it('respects an explicit crossorigin alongside integrity', () => {
expect(scriptAttrs({ url: 'https://cdn/y.js', integrity: 'sha384-abc', crossorigin: 'use-credentials' }))
.toEqual({ url: 'https://cdn/y.js', integrity: 'sha384-abc', crossorigin: 'use-credentials' });
});
it('passes crossorigin through without integrity', () => {
expect(scriptAttrs({ url: 'https://cdn/y.js', crossorigin: 'anonymous' }))
.toEqual({ url: 'https://cdn/y.js', crossorigin: 'anonymous' });
});
});

View file

@ -1,17 +1,35 @@
// Loads operator-listed plugins from config.json (`plugins: ["url.js", …]`) by
// injecting <script> tags. These are deployment-controlled (same trust level as
// the app), NOT user-uploaded.
import { getConfig } from '../config';
//
// An entry may be a bare URL string, or `{ url, integrity, crossorigin }` to pin
// the script with Subresource Integrity — strongly recommended for any plugin
// served from a third-party origin, so a compromised host can't swap the file.
import { getConfig, type PluginEntry } from '../config';
export interface ScriptAttrs { url: string; integrity?: string; crossorigin?: string }
// Pure: resolve a config entry to the <script> attributes it implies. An SRI
// hash forces a CORS request, so default crossorigin to 'anonymous' when set.
export function scriptAttrs(entry: PluginEntry): ScriptAttrs {
const e = typeof entry === 'string' ? { url: entry } : entry;
const out: ScriptAttrs = { url: e.url };
if (e.integrity) { out.integrity = e.integrity; out.crossorigin = e.crossorigin ?? 'anonymous'; }
else if (e.crossorigin) { out.crossorigin = e.crossorigin; }
return out;
}
export function loadPlugins(): void {
const list = (getConfig().plugins ?? []).filter(Boolean);
for (const url of list) {
const list = (getConfig().plugins ?? []).filter(Boolean).map(scriptAttrs).filter((p) => p.url);
for (const { url, integrity, crossorigin } of list) {
const el = document.createElement('script');
el.src = url;
el.async = true;
el.dataset.orbitPlugin = url;
if (integrity) el.integrity = integrity;
if (crossorigin) el.crossOrigin = crossorigin;
el.onerror = () => console.error('[plugins] failed to load', url);
document.head.appendChild(el);
}
if (list.length) console.log(`[plugins] loading ${list.length} plugin(s)`, list);
if (list.length) console.log(`[plugins] loading ${list.length} plugin(s)`, list.map((p) => p.url));
}