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).
59 lines
2.6 KiB
JavaScript
59 lines
2.6 KiB
JavaScript
/**
|
|
* Password Vault service worker — a NETWORK SANDBOX.
|
|
*
|
|
* The vault is offline-first (single-file, zero external deps). When installed
|
|
* as a PWA this worker enforces that: it allows requests ONLY for the app's own
|
|
* same-origin static resources (index.html, manifest, icons, and the worker
|
|
* itself) and rejects every cross-origin/third-party request.
|
|
*
|
|
* What this guards against: if a page within the PWA scope tries to exfiltrate
|
|
* or inject via <img>, <script src>, fetch(), an external manifest link, etc.,
|
|
* the worker refuses it before it leaves the device.
|
|
*
|
|
* Residual boundaries covered by the CSP in index.html (which a service worker
|
|
* cannot catch): WebSockets (connect-src 'none') and user-initiated top-level
|
|
* navigation. Form POSTs are blocked by form-action 'none'. img/media that a
|
|
* site tries to load are blocked here AND by img-src 'self'.
|
|
*/
|
|
|
|
const VERSION = '1.0.0'
|
|
|
|
self.addEventListener('install', () => self.skipWaiting())
|
|
self.addEventListener('activate', () => {
|
|
// Claim control of any pages already open in this scope.
|
|
self.clients?.claim?.()
|
|
})
|
|
|
|
/** URL/string->URL normalizer (worker self.location is unreliable in type). */
|
|
const toURL = (u) => (u instanceof URL) ? u : new URL(String(u))
|
|
|
|
/** Is this an in-scope resource for the app itself? */
|
|
function isAppOwnResource(url) {
|
|
const selfLoc = toURL(self.location)
|
|
// Cross-origin is always denied.
|
|
if (url.origin !== selfLoc.origin) return false
|
|
// Same-origin is only allowed WITHIN the worker's own directory scope (the
|
|
// index.html, manifest, and icons the app ships). A same-origin request to
|
|
// another app/dir on the same server is outside the sandbox and denied.
|
|
const dir = selfLoc.pathname.slice(0, selfLoc.pathname.lastIndexOf('/') + 1)
|
|
return url.pathname.startsWith(dir)
|
|
}
|
|
|
|
/** Block cross-origin requests; let same-origin app resources through. */
|
|
self.addEventListener('fetch', (event) => {
|
|
const url = new URL(event.request.url)
|
|
if (!isAppOwnResource(url)) {
|
|
event.respondWith(Promise.resolve(new Response(null, { status: 403, statusText: 'Blocked by Vault net policy' })))
|
|
return
|
|
}
|
|
// Same-origin: pass through to network (covers reload of index.html and the
|
|
// manifest/icons). We do not cache, so the app is always the current build.
|
|
event.respondWith(fetch(event.request).catch(() => {
|
|
// Network fallback: serve a minimal offline placeholder so a launched app
|
|
// never hangs open on a dead link. The real HTML is never cached here.
|
|
return new Response('offline', { status: 200 })
|
|
}))
|
|
})
|
|
|
|
console.log(`[vault-sw] ${VERSION} active — network sandbox on`)
|