Add PWA support with a network-sandbox for the installed app
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).
@ -57,39 +57,61 @@ jobs:
|
|||||||
- name: Verify build output
|
- name: Verify build output
|
||||||
run: test -f dist/index.html
|
run: test -f dist/index.html
|
||||||
|
|
||||||
# Publish dist/index.html to the WebDAV mirror (/password_manager).
|
# Publish index.html + PWA companion files (manifest, sandbox service
|
||||||
# The pretty URL (…/password_manager/index.html) 401s without auth; the
|
# worker, icons) to the WebDAV mirror. The pretty URL
|
||||||
# file is the full www/static/password_manager/index.html with basic auth.
|
# (…/password_manager/index.html) 401s without auth; files live at
|
||||||
|
# www/static/password_manager/<name> with basic auth.
|
||||||
- name: Publish to WebDAV (/password_manager)
|
- name: Publish to WebDAV (/password_manager)
|
||||||
env:
|
env:
|
||||||
WEBDAV_USER: ${{ secrets.WEBDAV_USER }}
|
WEBDAV_USER: ${{ secrets.WEBDAV_USER }}
|
||||||
WEBDAV_PASS: ${{ secrets.WEBDAV_PASS }}
|
WEBDAV_PASS: ${{ secrets.WEBDAV_PASS }}
|
||||||
WEBDAV_URL: https://files.thecookiejar.me/www/static/password_manager/index.html
|
WEBDAV_BASE: https://files.thecookiejar.me/www/static/password_manager
|
||||||
run: |
|
run: |
|
||||||
|
set -e
|
||||||
if [ -z "$WEBDAV_USER" ] || [ -z "$WEBDAV_PASS" ]; then
|
if [ -z "$WEBDAV_USER" ] || [ -z "$WEBDAV_PASS" ]; then
|
||||||
echo "::error::WEBDAV_USER / WEBDAV_PASS repo secrets are not set."
|
echo "::error::WEBDAV_USER / WEBDAV_PASS repo secrets are not set."
|
||||||
echo "::error::Add them under Settings -> Actions -> Secrets, then re-run this job."
|
echo "::error::Add them under Settings -> Actions -> Secrets, then re-run this job."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
publish() {
|
||||||
|
local src="$1" dst="$2" ctype="$3"
|
||||||
# DELETE-then-PUT: the serving layer caches uploaded files, so a plain
|
# DELETE-then-PUT: the serving layer caches uploaded files, so a plain
|
||||||
# PUT over an existing file returns 201 but keeps serving the OLD copy.
|
# PUT over an existing file keeps serving the OLD copy.
|
||||||
# Deleting first guarantees the new build is actually served. (Each curl
|
curl -sS -u "$WEBDAV_USER:$WEBDAV_PASS" -X DELETE "$dst" || echo "(DELETE non-zero; may be a new file -- continuing)"
|
||||||
# is a single line -- YAML block scalars don't convert backslash-nl.)
|
curl -sS --fail -u "$WEBDAV_USER:$WEBDAV_PASS" -X PUT -T "$src" -H "Content-Type: $ctype" "$dst"
|
||||||
curl -sS -u "$WEBDAV_USER:$WEBDAV_PASS" -X DELETE "$WEBDAV_URL" || echo "(DELETE returned non-zero; file may not exist yet -- continuing)"
|
echo "Published $src -> $dst"
|
||||||
curl -sS --fail -u "$WEBDAV_USER:$WEBDAV_PASS" -X PUT -T dist/index.html -H 'Content-Type: text/html' "$WEBDAV_URL"
|
}
|
||||||
echo "Published to $WEBDAV_URL"
|
# Ensure the icons/ subfolder exists.
|
||||||
|
curl -sS -u "$WEBDAV_USER:$WEBDAV_PASS" -X MKCOL "$WEBDAV_BASE/icons" -o /dev/null || echo "(icons dir exists or MKCOL unsupported -- continuing)"
|
||||||
|
publish dist/index.html "$WEBDAV_BASE/index.html" 'text/html'
|
||||||
|
publish dist/manifest.webmanifest "$WEBDAV_BASE/manifest.webmanifest" 'application/manifest+json'
|
||||||
|
publish dist/sw.js "$WEBDAV_BASE/sw.js" 'application/javascript; charset=utf-8'
|
||||||
|
publish dist/icons/icon-192.png "$WEBDAV_BASE/icons/icon-192.png" 'image/png'
|
||||||
|
publish dist/icons/icon-512.png "$WEBDAV_BASE/icons/icon-512.png" 'image/png'
|
||||||
|
publish dist/icons/icon-maskable-512.png "$WEBDAV_BASE/icons/icon-maskable-512.png" 'image/png'
|
||||||
|
|
||||||
# Pull the freshly uploaded file back and compare it to our build, so a
|
# Pull the freshly uploaded files back and compare bytes, so a successful
|
||||||
# successful deploy isn't just "curl returned 0" but bytes actually match.
|
# deploy isn't just "curl returned 0" but the served copy actually matches.
|
||||||
- name: Verify deployed file matches build
|
- name: Verify deployed files match build
|
||||||
env:
|
env:
|
||||||
WEBDAV_USER: ${{ secrets.WEBDAV_USER }}
|
WEBDAV_USER: ${{ secrets.WEBDAV_USER }}
|
||||||
WEBDAV_PASS: ${{ secrets.WEBDAV_PASS }}
|
WEBDAV_PASS: ${{ secrets.WEBDAV_PASS }}
|
||||||
WEBDAV_URL: https://files.thecookiejar.me/www/static/password_manager/index.html
|
WEBDAV_BASE: https://files.thecookiejar.me/www/static/password_manager
|
||||||
run: |
|
run: |
|
||||||
curl -sS --fail -u "$WEBDAV_USER:$WEBDAV_PASS" "$WEBDAV_URL" -o /tmp/deployed.html
|
set -e
|
||||||
sha1sum dist/index.html /tmp/deployed.html
|
verify() {
|
||||||
test "$(sha1sum dist/index.html | cut -d' ' -f1)" = \
|
local src="$1" dst="$2"
|
||||||
"$(sha1sum /tmp/deployed.html | cut -d' ' -f1)" \
|
curl -sS --fail -u "$WEBDAV_USER:$WEBDAV_PASS" "$dst" -o "/tmp/deployed_$(basename "$src")"
|
||||||
&& echo "Deploy verified: bytes match" \
|
sha1sum "$src" "/tmp/deployed_$(basename "$src")"
|
||||||
|| { echo "::error::Deployed file does not match build"; exit 1; }
|
test "$(sha1sum "$src" | cut -d' ' -f1)" = \
|
||||||
|
"$(sha1sum "/tmp/deployed_$(basename "$src")" | cut -d' ' -f1)" \
|
||||||
|
|| { echo "::error::Deployed $src does not match build"; exit 1; }
|
||||||
|
echo "Verified $src bytes match"
|
||||||
|
}
|
||||||
|
verify dist/index.html "$WEBDAV_BASE/index.html"
|
||||||
|
verify dist/manifest.webmanifest "$WEBDAV_BASE/manifest.webmanifest"
|
||||||
|
verify dist/sw.js "$WEBDAV_BASE/sw.js"
|
||||||
|
verify dist/icons/icon-192.png "$WEBDAV_BASE/icons/icon-192.png"
|
||||||
|
verify dist/icons/icon-512.png "$WEBDAV_BASE/icons/icon-512.png"
|
||||||
|
verify dist/icons/icon-maskable-512.png "$WEBDAV_BASE/icons/icon-maskable-512.png"
|
||||||
|
echo "All deployed files verified"
|
||||||
|
|||||||
BIN
dist/icons/icon-192.png
vendored
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
dist/icons/icon-512.png
vendored
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
dist/icons/icon-maskable-512.png
vendored
Normal file
|
After Width: | Height: | Size: 26 KiB |
56
dist/index.html
vendored
19
dist/manifest.webmanifest
vendored
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "Password Vault",
|
||||||
|
"short_name": "Vault",
|
||||||
|
"description": "Offline-first password manager. Encrypted locally in your browser; never leaves your device.",
|
||||||
|
"start_url": "./",
|
||||||
|
"scope": "./",
|
||||||
|
"display": "standalone",
|
||||||
|
"display_override": "standalone",
|
||||||
|
"orientation": "any",
|
||||||
|
"background_color": "#0f1117",
|
||||||
|
"theme_color": "#6c63ff",
|
||||||
|
"categories": ["productivity", "utilities", "security"],
|
||||||
|
"lang": "en",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "./icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||||
|
{ "src": "./icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||||
|
{ "src": "./icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||||
|
]
|
||||||
|
}
|
||||||
58
dist/sw.js
vendored
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* 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`)
|
||||||
27
index.html
@ -2,8 +2,33 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<!-- Dev CSP permits the Vite HMR WebSocket + same-origin dev assets. The
|
||||||
|
production build (scripts/inline-assets.js) swaps this for the strict
|
||||||
|
sandbox CSP (connect-src 'none', form/object denied). Clickjacking
|
||||||
|
(frame-ancestors / X-Frame-Options) is an HTTP-header concern and must
|
||||||
|
be set by the serving layer, not in a meta tag. -->
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="
|
||||||
|
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 'self' ws: wss:;
|
||||||
|
form-action 'self';
|
||||||
|
object-src 'none';
|
||||||
|
base-uri 'none';
|
||||||
|
manifest-src 'self'
|
||||||
|
" />
|
||||||
|
<meta name="theme-color" content="#6c63ff" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Vault" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
|
||||||
|
<link rel="manifest" href="./manifest.webmanifest" />
|
||||||
|
<link rel="apple-touch-icon" href="./icons/icon-192.png" />
|
||||||
<title>Password Vault</title>
|
<title>Password Vault</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
BIN
public/icons/icon-192.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
public/icons/icon-512.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
public/icons/icon-maskable-512.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
19
public/manifest.webmanifest
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "Password Vault",
|
||||||
|
"short_name": "Vault",
|
||||||
|
"description": "Offline-first password manager. Encrypted locally in your browser; never leaves your device.",
|
||||||
|
"start_url": "./",
|
||||||
|
"scope": "./",
|
||||||
|
"display": "standalone",
|
||||||
|
"display_override": "standalone",
|
||||||
|
"orientation": "any",
|
||||||
|
"background_color": "#0f1117",
|
||||||
|
"theme_color": "#6c63ff",
|
||||||
|
"categories": ["productivity", "utilities", "security"],
|
||||||
|
"lang": "en",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "./icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||||
|
{ "src": "./icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||||
|
{ "src": "./icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||||
|
]
|
||||||
|
}
|
||||||
19
public/pwa-icon.svg
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#6c63ff"/>
|
||||||
|
<stop offset="1" stop-color="#3b2f8f"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect x="0" y="0" width="512" height="512" rx="112" ry="112" fill="url(#bg)"/>
|
||||||
|
<!-- Padlock body -->
|
||||||
|
<ellipse cx="256" cy="300" rx="150" ry="108" fill="#e4e6f0" opacity="0.95"/>
|
||||||
|
<!-- Padlock shackle -->
|
||||||
|
<path d="M 190 200 L 210 120 Q 222 96 256 92 Q 290 96 302 120 L 322 200"
|
||||||
|
fill="none" stroke="#e4e6f0" stroke-width="40" stroke-linecap="round" opacity="0.95"/>
|
||||||
|
<!-- Keyhole -->
|
||||||
|
<circle cx="256" cy="292" r="30" fill="#0f1117"/>
|
||||||
|
<path d="M 256 322 L 256 352 L 244 352 L 268 352" fill="#0f1117"/>
|
||||||
|
<!-- Satin highlight -->
|
||||||
|
<ellipse cx="256" cy="204" rx="96" ry="14" fill="rgba(255,255,255,0.28)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 910 B |
58
public/sw.js
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* 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`)
|
||||||
@ -30,11 +30,14 @@ if (existsSync(faviconPath)) {
|
|||||||
console.log('[inline-assets] Inlined favicon.svg into index.html')
|
console.log('[inline-assets] Inlined favicon.svg into index.html')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove any other leftover asset files (e.g. icons.svg from Svelte compiler)
|
// Remove any other leftover asset files (e.g. icons.svg from Svelte compiler,
|
||||||
const iconsPath = join(distDir, 'icons.svg')
|
// and the source pwa-icon.svg which is only needed to regenerate the PNGs).
|
||||||
if (existsSync(iconsPath)) {
|
for (const leftover of ['icons.svg', 'pwa-icon.svg']) {
|
||||||
rmSync(iconsPath)
|
const p = join(distDir, leftover)
|
||||||
console.log('[inline-assets] Removed icons.svg')
|
if (existsSync(p)) {
|
||||||
|
rmSync(p)
|
||||||
|
console.log(`[inline-assets] Removed ${leftover}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove assets directory if it exists
|
// Remove assets directory if it exists
|
||||||
@ -44,4 +47,43 @@ if (existsSync(assetsDir)) {
|
|||||||
console.log('[inline-assets] Removed assets/ directory')
|
console.log('[inline-assets] Removed assets/ directory')
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[inline-assets] Done — dist/ contains only index.html')
|
// ---- 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')
|
||||||
|
|||||||
26
src/lib/pwa.js
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* PWA bootstrap for the Password Vault.
|
||||||
|
*
|
||||||
|
* Registers the service worker (relative path, so it works both in dev at /
|
||||||
|
* and in production under /password_manager/). The worker doubles as a network
|
||||||
|
* sandbox (see public/sw.js): once installed, it blocks all cross-origin and
|
||||||
|
* out-of-app requests. CSP in index.html backs it up (WebSockets/form/etc.).
|
||||||
|
*
|
||||||
|
* Registration is best-effort: it never blocks or breaks the app.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export async function registerServiceWorker() {
|
||||||
|
if (!('serviceWorker' in navigator) || !window.isSecureContext) return false
|
||||||
|
try {
|
||||||
|
const reg = await navigator.serviceWorker.register('./sw.js')
|
||||||
|
try {
|
||||||
|
await reg.update()
|
||||||
|
} catch {
|
||||||
|
/* first install may have nothing to update */
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[vault] service worker registration failed:', e)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,10 +1,14 @@
|
|||||||
import { mount } from 'svelte'
|
import { mount } from 'svelte'
|
||||||
import './styles/main.css'
|
import './styles/main.css'
|
||||||
import App from './App.svelte'
|
import App from './App.svelte'
|
||||||
|
import { registerServiceWorker } from './lib/pwa.js'
|
||||||
|
|
||||||
// Report the baked-in commit hash on startup (handy for verifying a deploy).
|
// Report the baked-in commit hash on startup (handy for verifying a deploy).
|
||||||
console.info({ commit_hash: __VAULT_COMMIT__ })
|
console.info({ commit_hash: __VAULT_COMMIT__ })
|
||||||
|
|
||||||
|
// Register the PWA service worker / network sandbox (no-op where unsupported).
|
||||||
|
registerServiceWorker()
|
||||||
|
|
||||||
const app = mount(App, {
|
const app = mount(App, {
|
||||||
target: document.getElementById('app'),
|
target: document.getElementById('app'),
|
||||||
})
|
})
|
||||||
|
|||||||
50
tests/lib/sw-policy.test.js
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||