spark-html-sri 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,102 @@
1
+ # ⚡ spark-html-sri
2
+
3
+ Subresource Integrity for [spark-html](https://www.npmjs.com/package/spark-html)
4
+ — auto-hash every built asset **and** verify URL-imported components at
5
+ runtime. Same mental model as `<script integrity>`, applied to the whole
6
+ app. Zero dependencies, zero bytes added to the spark-html core.
7
+
8
+ ```js
9
+ // src/main.js — before mount()/router()
10
+ import { sri } from 'spark-html-sri';
11
+ sri();
12
+ ```
13
+
14
+ ```js
15
+ // vite.config.js — after prerender()
16
+ import spark from 'spark-html/vite';
17
+ import prerender from 'spark-prerender/vite';
18
+ import sriPlugin from 'spark-html-sri/vite';
19
+
20
+ export default { plugins: [spark(), prerender(), sriPlugin()] };
21
+ ```
22
+
23
+ ## Install
24
+
25
+ ```bash
26
+ npm install spark-html-sri
27
+ ```
28
+
29
+ ## What you get
30
+
31
+ **Local files — fully automatic, zero config.** At build time the vite
32
+ plugin hashes every JS/CSS file and every component fragment (SHA-384 by
33
+ default), stamps `integrity` + `crossorigin="anonymous"` onto the
34
+ `<script>`/`<link>` tags (the browser enforces those natively), and bakes
35
+ a path → hash manifest into each page. At runtime `sri()` verifies every
36
+ component fetch against that manifest before spark-html boots it. A
37
+ tampered file — a compromised host, a poisoned cache — is rejected with a
38
+ clear console error instead of running.
39
+
40
+ **Remote URL imports** (`<div import="https://…">`) — **allow list + TOFU.**
41
+ Only whitelisted origins can be imported at all:
42
+
43
+ ```js
44
+ sri({
45
+ allow: ['cdn.jsdelivr.net', 'unpkg.com', 'esm.sh', 'raw.githubusercontent.com'], // the default
46
+ });
47
+ ```
48
+
49
+ For allowed origins, integrity is verified via **trust on first use**: the
50
+ first fetch stores the content hash (in `localStorage`), and every later
51
+ load must hash the same. A CDN compromised *after* your first visit serves
52
+ bytes that no longer match — the component is blocked before it runs.
53
+ Import pinned URLs (`…@1.2.3/card.html`) so legitimate updates are new
54
+ URLs; if you import a mutable URL and it changes on purpose, call
55
+ `resetTofu()` (or bump the URL).
56
+
57
+ **Your API calls are untouched.** Only same-origin paths present in the
58
+ build manifest and cross-origin `.html` component imports are governed —
59
+ every other fetch passes straight through.
60
+
61
+ ## Dev vs production
62
+
63
+ `enforce: 'auto'` (the default) **fails open on localhost** — violations
64
+ warn in the console but nothing is blocked, so dev servers and HMR are
65
+ never in your way — and **enforces everywhere else**. Set `enforce: true`
66
+ or `false` to override.
67
+
68
+ ## Options
69
+
70
+ ```js
71
+ sri({
72
+ manifest: { '/components/nav.html': 'sha384-…' }, // default: baked in by the vite plugin
73
+ allow: ['esm.sh'], // remote hosts (subdomains included)
74
+ enforce: 'auto', // true | false | 'auto' (auto = enforce unless localhost)
75
+ onViolation: (msg, url) => report(msg, url),
76
+ });
77
+ ```
78
+
79
+ ```js
80
+ sriPlugin({ algorithm: 'sha384' }); // 'sha256' | 'sha384' | 'sha512'
81
+ ```
82
+
83
+ ## API
84
+
85
+ | Export | Meaning |
86
+ |--------|---------|
87
+ | `sri(options?)` | Install the fetch guard. Returns a restore function. |
88
+ | `integrity(data, algo?)` | Compute an SRI string — `"sha384-…"`. |
89
+ | `verify(data, integrityString)` | Check data against an SRI string (space-separated list allowed). |
90
+ | `resetTofu()` | Forget every remembered remote-component hash. |
91
+ | `DEFAULT_ALLOW` | The default remote allow list. |
92
+ | `spark-html-sri/vite` | Build plugin — hash, stamp, bake the manifest. |
93
+
94
+ ## Why not put this in the core?
95
+
96
+ The spark-html runtime has a frozen 13 kB budget. Verification lives here
97
+ instead, as an opt-in wrapper around `fetch` — projects that don't use SRI
98
+ pay zero bytes, and the core stays tiny.
99
+
100
+ ## License
101
+
102
+ MIT
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "spark-html-sri",
3
+ "version": "0.1.0",
4
+ "description": "Subresource Integrity for spark-html — auto-hash built assets and components, verify at runtime, and make URL-imported components safe with an origin allow list + trust-on-first-use. Zero dependencies.",
5
+ "type": "module",
6
+ "main": "./src/index.js",
7
+ "types": "./src/index.d.ts",
8
+ "homepage": "https://wilkinnovo.github.io/spark",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./src/index.d.ts",
12
+ "default": "./src/index.js"
13
+ },
14
+ "./vite": {
15
+ "types": "./src/vite.d.ts",
16
+ "default": "./src/vite.js"
17
+ }
18
+ },
19
+ "scripts": {
20
+ "test": "node test/sri.js"
21
+ },
22
+ "files": [
23
+ "src"
24
+ ],
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/wilkinnovo/spark.git",
28
+ "directory": "packages/spark-html-sri"
29
+ },
30
+ "keywords": [
31
+ "spark-html",
32
+ "sri",
33
+ "subresource-integrity",
34
+ "integrity",
35
+ "security",
36
+ "tofu",
37
+ "vite-plugin"
38
+ ],
39
+ "license": "MIT"
40
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,45 @@
1
+ /** Remote origins allowed by default for URL-imported components. */
2
+ export const DEFAULT_ALLOW: string[];
3
+
4
+ export type SriAlgorithm = 'sha256' | 'sha384' | 'sha512';
5
+
6
+ /** Compute an SRI string — `integrity('hi')` → `"sha384-…"`. */
7
+ export function integrity(
8
+ data: string | Uint8Array | ArrayBuffer,
9
+ algo?: SriAlgorithm,
10
+ ): Promise<string>;
11
+
12
+ /** Verify data against an SRI string (space-separated list allowed; any match passes). */
13
+ export function verify(
14
+ data: string | Uint8Array | ArrayBuffer,
15
+ integrityString: string,
16
+ ): Promise<boolean>;
17
+
18
+ export interface SriOptions {
19
+ /** path → SRI string. Default: the manifest the vite plugin baked into the page. */
20
+ manifest?: Record<string, string>;
21
+ /** Allowed remote hosts for URL imports (subdomains included). */
22
+ allow?: string[];
23
+ /** Block on failure. 'auto' (default) enforces everywhere except localhost. */
24
+ enforce?: boolean | 'auto';
25
+ /** Observe failures (fires whether or not the request was blocked). */
26
+ onViolation?: (message: string, url: string) => void;
27
+ }
28
+
29
+ /**
30
+ * Install the integrity guard around `fetch`. Call once from main.js,
31
+ * before mount()/router(). Returns a function that restores the original fetch.
32
+ */
33
+ export function sri(options?: SriOptions): () => void;
34
+
35
+ /** Forget every remembered remote-component hash (TOFU store). */
36
+ export function resetTofu(): void;
37
+
38
+ declare const _default: {
39
+ sri: typeof sri;
40
+ integrity: typeof integrity;
41
+ verify: typeof verify;
42
+ resetTofu: typeof resetTofu;
43
+ DEFAULT_ALLOW: string[];
44
+ };
45
+ export default _default;
package/src/index.js ADDED
@@ -0,0 +1,205 @@
1
+ /**
2
+ * spark-html-sri — Subresource Integrity for spark-html apps.
3
+ *
4
+ * Two halves, one mental model (`<script integrity>` — familiar to every
5
+ * web developer):
6
+ *
7
+ * **Local files** — fully automatic, zero config. The vite plugin
8
+ * (spark-html-sri/vite) hashes every built JS/CSS/component fragment,
9
+ * injects `integrity` + `crossorigin` into `<script>`/`<link>` tags, and
10
+ * bakes a manifest into each page. At runtime `sri()` verifies every
11
+ * component fetch against that manifest before spark-html boots it.
12
+ *
13
+ * **Remote URL imports** (`<div import="https://…">`) — whitelist + TOFU.
14
+ * Only allowed origins may be imported; for those, the first fetch stores
15
+ * the content hash (trust on first use) and every later load must match.
16
+ * A CDN compromised after your first visit serves a component that no
17
+ * longer hashes — it's rejected before it runs.
18
+ *
19
+ * import { sri } from 'spark-html-sri';
20
+ * sri(); // defaults for everything
21
+ * sri({ allow: ['esm.sh'], enforce: true }); // tighten
22
+ *
23
+ * Fail open in dev (localhost warns, never blocks), enforce in production.
24
+ * Nothing here touches the spark-html core — apps that don't use SRI pay
25
+ * zero bytes.
26
+ */
27
+
28
+ /** Remote origins allowed by default for URL-imported components. */
29
+ export const DEFAULT_ALLOW = ['cdn.jsdelivr.net', 'unpkg.com', 'esm.sh', 'raw.githubusercontent.com'];
30
+
31
+ const TOFU_KEY = 'spark-sri:tofu';
32
+ const ALGOS = { sha256: 'SHA-256', sha384: 'SHA-384', sha512: 'SHA-512' };
33
+
34
+ function subtle() {
35
+ const s = globalThis.crypto && globalThis.crypto.subtle;
36
+ if (!s) throw new Error('[spark-sri] WebCrypto unavailable (secure context required)');
37
+ return s;
38
+ }
39
+
40
+ function toBytes(data) {
41
+ if (typeof data === 'string') return new TextEncoder().encode(data);
42
+ return data instanceof Uint8Array ? data : new Uint8Array(data);
43
+ }
44
+
45
+ function b64(buf) {
46
+ let s = '';
47
+ const bytes = new Uint8Array(buf);
48
+ for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
49
+ return btoa(s);
50
+ }
51
+
52
+ /**
53
+ * Compute an SRI string — `integrity('hi')` → `"sha384-…"`.
54
+ * @param {string|Uint8Array|ArrayBuffer} data
55
+ * @param {'sha256'|'sha384'|'sha512'} [algo='sha384']
56
+ */
57
+ export async function integrity(data, algo = 'sha384') {
58
+ const name = ALGOS[algo];
59
+ if (!name) throw new Error(`[spark-sri] unknown algorithm "${algo}"`);
60
+ const digest = await subtle().digest(name, toBytes(data));
61
+ return `${algo}-${b64(digest)}`;
62
+ }
63
+
64
+ /**
65
+ * Verify data against an SRI string. Like the platform, a space-separated
66
+ * list is accepted and ANY match passes.
67
+ * @returns {Promise<boolean>}
68
+ */
69
+ export async function verify(data, integrityString) {
70
+ if (!integrityString) return false;
71
+ for (const token of String(integrityString).trim().split(/\s+/)) {
72
+ const algo = token.slice(0, token.indexOf('-'));
73
+ if (!ALGOS[algo]) continue;
74
+ if ((await integrity(data, algo)) === token) return true;
75
+ }
76
+ return false;
77
+ }
78
+
79
+ // ── runtime fetch guard ─────────────────────────────────────────────────
80
+
81
+ function readInlineManifest() {
82
+ if (typeof document === 'undefined') return null;
83
+ const el = document.querySelector('script[type="application/json"][data-spark-sri]');
84
+ if (!el) return null;
85
+ try { return JSON.parse(el.textContent); } catch { return null; }
86
+ }
87
+
88
+ function loadTofu() {
89
+ try { return JSON.parse(localStorage.getItem(TOFU_KEY)) || {}; } catch { return {}; }
90
+ }
91
+ function saveTofu(map) {
92
+ try { localStorage.setItem(TOFU_KEY, JSON.stringify(map)); } catch { /* private mode etc. */ }
93
+ }
94
+
95
+ function isLocalhost() {
96
+ if (typeof location === 'undefined') return true;
97
+ return ['localhost', '127.0.0.1', '[::1]', ''].includes(location.hostname);
98
+ }
99
+
100
+ function hostAllowed(host, allow) {
101
+ return allow.some((a) => host === a || host.endsWith('.' + a));
102
+ }
103
+
104
+ function blockedResponse(reason) {
105
+ return new Response(`/* blocked by spark-html-sri: ${reason} */`, {
106
+ status: 424,
107
+ statusText: 'integrity check failed',
108
+ });
109
+ }
110
+
111
+ /**
112
+ * Install the integrity guard around `fetch`. Call once from main.js,
113
+ * BEFORE mount()/router() so component fetches flow through it.
114
+ *
115
+ * - Same-origin URLs listed in the manifest are hash-verified.
116
+ * - Cross-origin `.html` component imports must come from an allowed
117
+ * origin and (after the first load) keep hashing the same (TOFU).
118
+ * - Everything else — your API calls, images, other origins' JSON —
119
+ * passes through untouched.
120
+ *
121
+ * @param {object} [options]
122
+ * @param {Record<string,string>} [options.manifest] path → SRI string. Default: the
123
+ * manifest the vite plugin baked into the page (absent in dev — nothing to verify).
124
+ * @param {string[]} [options.allow] Allowed remote hosts for URL imports
125
+ * (default: jsdelivr/unpkg/esm.sh/raw.githubusercontent — subdomains included).
126
+ * @param {boolean|'auto'} [options.enforce='auto'] Block on failure. 'auto'
127
+ * enforces everywhere except localhost (fail open in dev).
128
+ * @param {(msg: string, url: string) => void} [options.onViolation] Observe failures.
129
+ * @returns {() => void} restore the original fetch.
130
+ */
131
+ export function sri(options = {}) {
132
+ if (typeof globalThis.fetch !== 'function') return () => {};
133
+ const manifest = options.manifest || readInlineManifest() || {};
134
+ const allow = options.allow || DEFAULT_ALLOW;
135
+ const enforce = options.enforce === 'auto' || options.enforce === undefined
136
+ ? !isLocalhost()
137
+ : !!options.enforce;
138
+
139
+ const violate = (msg, url) => {
140
+ console[enforce ? 'error' : 'warn'](`[spark-sri] ${msg} — ${url}${enforce ? '' : ' (dev: allowed)'}`);
141
+ if (options.onViolation) { try { options.onViolation(msg, url); } catch { /* observer only */ } }
142
+ };
143
+
144
+ const origFetch = globalThis.fetch;
145
+ const tofu = loadTofu();
146
+
147
+ const guarded = async function (input, init) {
148
+ const method = ((init && init.method) || (input && input.method) || 'GET').toUpperCase();
149
+ const rawUrl = typeof input === 'string' ? input : (input && input.url) || String(input);
150
+ let url;
151
+ try { url = new URL(rawUrl, typeof location !== 'undefined' ? location.href : undefined); } catch { url = null; }
152
+ if (!url || method !== 'GET' || (url.protocol !== 'http:' && url.protocol !== 'https:')) {
153
+ return origFetch.call(this, input, init);
154
+ }
155
+
156
+ const sameOrigin = typeof location === 'undefined' || url.origin === location.origin;
157
+
158
+ // Same-origin: verify only what the build manifest covers.
159
+ if (sameOrigin) {
160
+ const expected = manifest[url.pathname];
161
+ if (!expected) return origFetch.call(this, input, init);
162
+ const res = await origFetch.call(this, input, init);
163
+ if (!res.ok) return res;
164
+ const bytes = new Uint8Array(await res.clone().arrayBuffer());
165
+ if (await verify(bytes, expected)) return res;
166
+ violate('integrity mismatch (build manifest)', url.href);
167
+ return enforce ? blockedResponse('integrity mismatch') : res;
168
+ }
169
+
170
+ // Cross-origin: only component imports (.html) are governed.
171
+ if (!url.pathname.endsWith('.html')) return origFetch.call(this, input, init);
172
+
173
+ if (!hostAllowed(url.hostname, allow)) {
174
+ violate(`origin "${url.hostname}" is not in the allow list`, url.href);
175
+ if (enforce) return blockedResponse(`origin ${url.hostname} not allowed`);
176
+ return origFetch.call(this, input, init);
177
+ }
178
+
179
+ const res = await origFetch.call(this, input, init);
180
+ if (!res.ok) return res;
181
+ const bytes = new Uint8Array(await res.clone().arrayBuffer());
182
+ const known = tofu[url.href];
183
+ if (!known) {
184
+ // Trust on first use — remember what this URL looked like.
185
+ tofu[url.href] = await integrity(bytes);
186
+ saveTofu(tofu);
187
+ return res;
188
+ }
189
+ if (await verify(bytes, known)) return res;
190
+ violate('remote component changed since first use (TOFU mismatch)', url.href);
191
+ return enforce ? blockedResponse('TOFU mismatch') : res;
192
+ };
193
+
194
+ globalThis.fetch = guarded;
195
+ return () => {
196
+ if (globalThis.fetch === guarded) globalThis.fetch = origFetch;
197
+ };
198
+ }
199
+
200
+ /** Forget every remembered remote-component hash (TOFU store). */
201
+ export function resetTofu() {
202
+ try { localStorage.removeItem(TOFU_KEY); } catch { /* ignore */ }
203
+ }
204
+
205
+ export default { sri, integrity, verify, resetTofu, DEFAULT_ALLOW };
package/src/vite.d.ts ADDED
@@ -0,0 +1,18 @@
1
+ import type { SriAlgorithm } from './index.js';
2
+
3
+ export interface SriVitePluginOptions {
4
+ /** Hash algorithm for assets and the manifest (default 'sha384'). */
5
+ algorithm?: SriAlgorithm;
6
+ }
7
+
8
+ /**
9
+ * Vite plugin: hashes every built JS/CSS/component fragment, stamps
10
+ * integrity + crossorigin onto script/link tags, and bakes the manifest
11
+ * into each page. Put it after prerender() in `plugins`.
12
+ */
13
+ export default function sparkSri(options?: SriVitePluginOptions): {
14
+ name: string;
15
+ apply: 'build';
16
+ configResolved(config: unknown): void;
17
+ closeBundle: { order: 'post'; handler(): Promise<void> };
18
+ };
package/src/vite.js ADDED
@@ -0,0 +1,117 @@
1
+ /**
2
+ * spark-html-sri/vite — hash the build, stamp the pages.
3
+ *
4
+ * Runs in closeBundle (order post, after spark-prerender has written its
5
+ * per-route HTML — put it after prerender() in `plugins`). For every page:
6
+ *
7
+ * 1. hashes every .js/.css in the output and adds `integrity` +
8
+ * `crossorigin="anonymous"` to the <script src> / <link rel=stylesheet>
9
+ * tags that reference them — the browser enforces these natively;
10
+ * 2. hashes every component fragment (.html without <head>) into a
11
+ * manifest and bakes it into the page as
12
+ * <script type="application/json" data-spark-sri>…</script> — the
13
+ * sri() runtime picks it up with zero config.
14
+ *
15
+ * import sriPlugin from 'spark-html-sri/vite';
16
+ * plugins: [spark(), prerender(), sriPlugin()]
17
+ */
18
+ import { join, resolve, relative, sep } from 'node:path';
19
+ import { readFile, writeFile, readdir, stat } from 'node:fs/promises';
20
+ import { existsSync } from 'node:fs';
21
+ import { createHash } from 'node:crypto';
22
+
23
+ async function walk(dir) {
24
+ const out = [];
25
+ for (const name of await readdir(dir)) {
26
+ const full = join(dir, name);
27
+ const s = await stat(full);
28
+ if (s.isDirectory()) out.push(...await walk(full));
29
+ else out.push(full);
30
+ }
31
+ return out;
32
+ }
33
+
34
+ function sriHash(buf, algo) {
35
+ return `${algo}-${createHash(algo).update(buf).digest('base64')}`;
36
+ }
37
+
38
+ // Attribute-order-agnostic tag rewriting: find <script src>/<link stylesheet>
39
+ // tags, resolve their URL against the manifest, splice the attributes in.
40
+ function stampTags(html, lookup) {
41
+ return html
42
+ .replace(/<script\b[^>]*\bsrc\s*=\s*"([^"]+)"[^>]*>/g, (tag, src) => stamp(tag, src, lookup))
43
+ .replace(/<link\b[^>]*\brel\s*=\s*"stylesheet"[^>]*>/g, (tag) => {
44
+ const href = (tag.match(/\bhref\s*=\s*"([^"]+)"/) || [])[1];
45
+ return href ? stamp(tag, href, lookup) : tag;
46
+ });
47
+ }
48
+
49
+ function stamp(tag, url, lookup) {
50
+ if (/\bintegrity\s*=/.test(tag)) return tag; // already stamped
51
+ if (/^(https?:)?\/\//.test(url)) return tag; // remote — not ours to hash
52
+ const hash = lookup(url.split(/[?#]/)[0]);
53
+ if (!hash) return tag;
54
+ const attrs = ` integrity="${hash}"` + (/\bcrossorigin\b/.test(tag) ? '' : ' crossorigin="anonymous"');
55
+ return tag.replace(/\s*\/?>$/, (end) => `${attrs}${end}`);
56
+ }
57
+
58
+ /**
59
+ * @param {object} [options]
60
+ * @param {'sha256'|'sha384'|'sha512'} [options.algorithm='sha384']
61
+ */
62
+ export default function sparkSri(options = {}) {
63
+ const algo = options.algorithm || 'sha384';
64
+ let outDir = 'dist';
65
+ let base = '/';
66
+ return {
67
+ name: 'spark-html-sri',
68
+ apply: 'build',
69
+ configResolved(viteConfig) {
70
+ if (viteConfig && viteConfig.build && viteConfig.build.outDir) outDir = viteConfig.build.outDir;
71
+ if (viteConfig && viteConfig.base) base = viteConfig.base;
72
+ },
73
+ closeBundle: {
74
+ order: 'post',
75
+ async handler() {
76
+ const root = resolve(outDir);
77
+ if (!existsSync(root)) return;
78
+ const baseDir = base.endsWith('/') ? base : base + '/';
79
+
80
+ const files = await walk(root);
81
+ const manifest = {}; // served pathname → sri string
82
+ const pages = [];
83
+ for (const file of files) {
84
+ const pathname = baseDir + relative(root, file).split(sep).join('/');
85
+ if (/\.(js|css)$/.test(file)) {
86
+ manifest[pathname] = sriHash(await readFile(file), algo);
87
+ } else if (file.endsWith('.html')) {
88
+ const html = await readFile(file, 'utf8');
89
+ if (/<\/head>/i.test(html)) pages.push({ file, html });
90
+ else manifest[pathname] = sriHash(Buffer.from(html), algo); // component fragment
91
+ }
92
+ }
93
+
94
+ const json = JSON.stringify(manifest);
95
+ let stamped = 0;
96
+ for (const { file, html } of pages) {
97
+ if (html.includes('data-spark-sri')) continue; // idempotent
98
+ // Resolve a tag's URL to a manifest key: absolute pathnames match
99
+ // directly; relative ones resolve against the page's directory.
100
+ const pageDir = baseDir + relative(root, join(file, '..')).split(sep).join('/');
101
+ const lookup = (url) => {
102
+ if (url.startsWith('/')) return manifest[url] || manifest[baseDir.replace(/\/$/, '') + url];
103
+ const dir = pageDir === baseDir + '.' ? baseDir : pageDir + '/';
104
+ return manifest[dir.replace(/\/\.\//, '/') + url.replace(/^\.\//, '')];
105
+ };
106
+ let out = stampTags(html, lookup);
107
+ out = out.replace(/<\/head>/i, `<script type="application/json" data-spark-sri>${json}</script>\n</head>`);
108
+ await writeFile(file, out, 'utf8');
109
+ stamped++;
110
+ }
111
+ if (stamped) {
112
+ console.log(`[spark-html-sri] ${Object.keys(manifest).length} asset(s) hashed, ${stamped} page(s) stamped`);
113
+ }
114
+ },
115
+ },
116
+ };
117
+ }