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:
parent
dad10e80f7
commit
c7a3a5dba0
6 changed files with 118 additions and 8 deletions
53
SECURITY.md
53
SECURITY.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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'] },
|
||||
|
|
|
|||
23
src/plugins/loader.test.ts
Normal file
23
src/plugins/loader.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue