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).
51 lines
2.5 KiB
JavaScript
51 lines
2.5 KiB
JavaScript
import { describe, it, expect } from 'vitest'
|
|
|
|
/**
|
|
* Canonical network-sandbox decision logic for the Password Vault PWA.
|
|
* Mirrors `isAppOwnResource` in public/sw.js. It decides whether a request is
|
|
* permitted: only SAME-ORIGIN requests within the worker's OWN directory scope
|
|
* are allowed; everything else (cross-origin, or same-origin but a different
|
|
* app/dir on the same server) is denied.
|
|
*/
|
|
function isAppOwnResource(requestUrl, selfUrl) {
|
|
const req = new URL(requestUrl)
|
|
const self = new URL(selfUrl)
|
|
if (req.origin !== self.origin) return false
|
|
const dir = self.pathname.slice(0, self.pathname.lastIndexOf('/') + 1)
|
|
return req.pathname.startsWith(dir)
|
|
}
|
|
|
|
// The deployed worker lives under /password_manager/ -> selfUrl is its own URL.
|
|
const SELF = 'https://thecookiejar.me/password_manager/sw.js'
|
|
|
|
describe('Vault network-sandbox policy', () => {
|
|
it('allows the app is own in-scope static resources', () => {
|
|
expect(isAppOwnResource('https://thecookiejar.me/password_manager/index.html', SELF)).toBe(true)
|
|
expect(isAppOwnResource('https://thecookiejar.me/password_manager/manifest.webmanifest', SELF)).toBe(true)
|
|
expect(isAppOwnResource('https://thecookiejar.me/password_manager/icons/icon-192.png', SELF)).toBe(true)
|
|
expect(isAppOwnResource('https://thecookiejar.me/password_manager/sw.js', SELF)).toBe(true)
|
|
})
|
|
|
|
it('denies any cross-origin request (third-party exfiltration)', () => {
|
|
expect(isAppOwnResource('https://evil.example.com/collect', SELF)).toBe(false)
|
|
expect(isAppOwnResource('https://api.open-meteo.com/v1/forecast', SELF)).toBe(false)
|
|
expect(isAppOwnResource('https://google.com/', SELF)).toBe(false)
|
|
expect(isAppOwnResource('https://thecookiejar.me.evil.com/', SELF)).toBe(false)
|
|
})
|
|
|
|
it('denies same-origin requests outside the app directory scope', () => {
|
|
// Same server, different app/vault dir -> outside the sandbox.
|
|
expect(isAppOwnResource('https://thecookiejar.me/weather/index.html', SELF)).toBe(false)
|
|
expect(isAppOwnResource('https://thecookiejar.me/static/switch.css', SELF)).toBe(false)
|
|
expect(isAppOwnResource('https://thecookiejar.me/', SELF)).toBe(false)
|
|
})
|
|
|
|
it('treats a different scheme (http vs https) as a different origin', () => {
|
|
expect(isAppOwnResource('http://thecookiejar.me/password_manager/index.html', SELF)).toBe(false)
|
|
})
|
|
|
|
it('treats a different port as a different origin', () => {
|
|
expect(isAppOwnResource('https://thecookiejar.me:8443/password_manager/index.html', SELF)).toBe(false)
|
|
})
|
|
})
|