Makes the offline-first vault installable as a standalone PWA, and enforces "no network access aside from the app's own resources" once installed. PWA: - public/manifest.webmanifest: scope ./ (confined to the app's own dir), three icons (192/512 + transparent maskable), standalone display, brand theme. No push/shortcuts (deliberate: offline-first vault, nothing to push). - public/sw.js: a NETWORK SANDBOX service worker. Allows only same-origin requests inside the worker's own directory (index, manifest, icons); returns 403 for every cross-origin request and for same-origin paths outside the app dir. This blocks in-app <img>/<script>/fetch exfiltration at the source. - src/lib/pwa.js + main.js: register the worker (relative path, works at / in dev and under /password_manager/ in prod). - index.html: dev CSP (permits Vite HMR WebSocket) + PWA meta/link tags. Security: - scripts/inline-assets.js swaps the dev CSP for the STRICT production CSP in the shipped dist: connect-src 'none' (no fetch/XHR/WebSockets anywhere), form-action 'none', object-src/base-uri 'none', img/font/media 'self' data:. The browser-native connect-src closes the WebSocket gap the SW cannot see. - Known boundary documented in-file: frame-ancestors / X-Frame-Options is HTTP-header-only (ignored in meta) and not set by the static host, so clickjacking is permissive; all exfiltration channels are closed regardless. Build/deploy: - inline-assets.js now PRESERVES manifest/sw.js/icons in dist (was deleting). - deploy.yml uploads + byte-verifies index.html, manifest, sw.js, and all three icons to WebDAV. Tests: - tests/lib/sw-policy.test.js: 5 tests for the sandbox allow/deny logic (in-scope allowed; cross-origin, out-of-scope, scheme/port mismatch denied). - Verified in headless Chromium against the built dist: SW registers + is active, and both a cross-origin fetch and a same-origin-out-of-scope fetch are refused by the CSP before leaving the device. Fixup: dropped 'frame-ancestors' from the meta CSP after confirming the browser ignores it there (it is a header-only directive).
90 lines
3.4 KiB
JavaScript
90 lines
3.4 KiB
JavaScript
/**
|
|
* Post-build script: inline remaining external assets (favicon) into index.html
|
|
* and remove leftover files so only a single HTML file remains.
|
|
*/
|
|
import { readFileSync, writeFileSync, rmSync, existsSync } from 'fs'
|
|
import { join, dirname } from 'path'
|
|
import { fileURLToPath } from 'url'
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
const distDir = join(__dirname, '..', 'dist')
|
|
|
|
// Read favicon SVG and encode as data URI
|
|
const faviconPath = join(distDir, 'favicon.svg')
|
|
if (existsSync(faviconPath)) {
|
|
const svgContent = readFileSync(faviconPath, 'utf8')
|
|
const encoded = Buffer.from(svgContent).toString('base64')
|
|
const dataUri = `data:image/svg+xml;base64,${encoded}`
|
|
|
|
// Replace the favicon link in index.html
|
|
const indexPath = join(distDir, 'index.html')
|
|
let html = readFileSync(indexPath, 'utf8')
|
|
html = html.replace(
|
|
/<link rel="icon"[^>]*href="[^"]*favicon\.svg"[^>]*\/?>/i,
|
|
`<link rel="icon" type="image/svg+xml" href="${dataUri}" />`
|
|
)
|
|
writeFileSync(indexPath, html)
|
|
|
|
// Remove the standalone SVG
|
|
rmSync(faviconPath)
|
|
console.log('[inline-assets] Inlined favicon.svg into index.html')
|
|
}
|
|
|
|
// Remove any other leftover asset files (e.g. icons.svg from Svelte compiler,
|
|
// and the source pwa-icon.svg which is only needed to regenerate the PNGs).
|
|
for (const leftover of ['icons.svg', 'pwa-icon.svg']) {
|
|
const p = join(distDir, leftover)
|
|
if (existsSync(p)) {
|
|
rmSync(p)
|
|
console.log(`[inline-assets] Removed ${leftover}`)
|
|
}
|
|
}
|
|
|
|
// Remove assets directory if it exists
|
|
const assetsDir = join(distDir, 'assets')
|
|
if (existsSync(assetsDir)) {
|
|
rmSync(assetsDir, { recursive: true })
|
|
console.log('[inline-assets] Removed assets/ directory')
|
|
}
|
|
|
|
// ---- Production network sandbox CSP -----------------------------
|
|
// Replace the dev CSP with the strict, shipped one. The app is single-file and
|
|
// offline-first, so script/style keep 'unsafe-inline' (they are inline), but
|
|
// every channel that could exfiltrate vault data is denied at the browser level:
|
|
// connect-src 'none' (no fetch/XHR/WebSocket anywhere), form-action 'none',
|
|
// object-src 'none', base-uri 'none'. (Clickjacking via frame-ancestors /
|
|
// X-Frame-Options is a header-only directive and must be set by the serving
|
|
// layer, so it is intentionally absent here.)
|
|
const indexPath = join(distDir, 'index.html')
|
|
let html = readFileSync(indexPath, 'utf8')
|
|
const PROD_CSP = [
|
|
"default-src 'self';",
|
|
"script-src 'self' 'unsafe-inline';",
|
|
"style-src 'self' 'unsafe-inline';",
|
|
"img-src 'self' data:;",
|
|
"font-src 'self' data:;",
|
|
"media-src 'self' data:;",
|
|
"connect-src 'none';",
|
|
"form-action 'none';",
|
|
"object-src 'none';",
|
|
"base-uri 'none';",
|
|
"manifest-src 'self';",
|
|
'upgrade-insecure-requests',
|
|
].join('\n ')
|
|
html = html.replace(
|
|
/<meta http-equiv="Content-Security-Policy" content="[\s\S]*?"\s*\/>/i,
|
|
`<meta http-equiv="Content-Security-Policy" content="\n ${PROD_CSP}\n " />`
|
|
)
|
|
writeFileSync(indexPath, html)
|
|
console.log('[inline-assets] Replaced dev CSP with strict production sandbox')
|
|
|
|
// ---- Keep the PWA companion files -------------------------------
|
|
// These MUST ship alongside index.html so the app is installable: the web
|
|
// manifest, the network-sandbox service worker, and the icon set.
|
|
for (const keep of ['manifest.webmanifest', 'sw.js', 'icons']) {
|
|
const p = join(distDir, keep)
|
|
if (existsSync(p)) console.log(`[inline-assets] Preserved PWA ${keep}: ${p}`)
|
|
}
|
|
|
|
console.log('[inline-assets] Done — dist/ contains index.html + PWA files')
|