Add PWA support with a network-sandbox for the installed app
All checks were successful
Test, Build & Deploy / test-and-build (push) Successful in 55s
Test, Build & Deploy / deploy (push) Successful in 26s

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).
This commit is contained in:
hermes-explorigin 2026-09-08 13:44:24 +00:00
parent a722c66e5a
commit 179bc83bfe
18 changed files with 424 additions and 30 deletions

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
dist/icons/icon-512.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

BIN
dist/icons/icon-maskable-512.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

56
dist/index.html vendored

File diff suppressed because one or more lines are too long

19
dist/manifest.webmanifest vendored Normal file
View 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
View 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`)

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
public/icons/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View 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
View 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
View 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`)

View File

@ -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
View 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
}
}

View File

@ -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'),
}) })

View 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)
})
})