From ae7533d9b99e6ff091673e7b655dd4c426af972a Mon Sep 17 00:00:00 2001 From: adji Date: Fri, 29 May 2026 17:16:53 -0300 Subject: [PATCH 01/59] feat: astro integration package, initial import --- README.md | 28 +- eslint.config.js | 3 +- package.json | 2 + packages/astro-integration/README.md | 123 ++++++ packages/astro-integration/package.json | 44 +++ .../src/__tests__/manifest.test.js | 362 ++++++++++++++++++ packages/astro-integration/src/index.js | 114 ++++++ packages/astro-integration/src/manifest.js | 165 ++++++++ packages/astro-integration/src/vite-plugin.js | 24 ++ packages/signer/src/build.js | 27 +- 10 files changed, 883 insertions(+), 9 deletions(-) create mode 100644 packages/astro-integration/README.md create mode 100644 packages/astro-integration/package.json create mode 100644 packages/astro-integration/src/__tests__/manifest.test.js create mode 100644 packages/astro-integration/src/index.js create mode 100644 packages/astro-integration/src/manifest.js create mode 100644 packages/astro-integration/src/vite-plugin.js diff --git a/README.md b/README.md index 18e22d6..ddd6883 100644 --- a/README.md +++ b/README.md @@ -94,10 +94,12 @@ npm run dev ## Repository Structure -This is a monorepo with three packages. Each has its own README with detailed documentation: +This is a monorepo with four packages. Each has its own README with detailed documentation: - [`packages/dappfence`](packages/dappfence/README.md) — core framework architecture, module descriptions, manifest format, and design patterns +- [`packages/astro-integration`](packages/astro-integration/README.md) — Astro integration: script + injection, manifest generation, dynamic content tagging - [`packages/test-app`](packages/test-app/README.md) — test scenarios, dev server configuration, and libfaketime setup for cache expiration testing - [`packages/signer`](packages/signer/) — manifest signing library (`signManifest`, @@ -138,6 +140,8 @@ dappfence/ - `npm run build:prod` - Production build of `@dappfence/core` (minified, obfuscated) - `npm run build:watch` - Watch mode: auto-rebuild core + manifests on source changes - `npm run clean` - Remove all build output from every package +- `npm run publish:local` - Build production bundle and pack `@dappfence/core` as a tarball to + `dist/` for use by local examples - `npm run check` - Run linting and formatting checks - `npm run lint` - Run ESLint with auto-fix - `npm run format` - Format code with Prettier @@ -153,6 +157,28 @@ npm run test:headed -w @dappfence/test-app # E2e tests in headed browser npm run test:debug -w @dappfence/test-app # Debug e2e tests ``` +### Local Examples + +The `examples/` directory contains standalone apps. They reference monorepo packages via `file:` +symlinks so changes are picked up immediately without a pack/install cycle. + +```bash +# Install dependencies in the example (run from the example directory) +cd examples/astro +npm install + +# Build and preview +npm run build +npm run preview +``` + +If you change any `packages/*` source, rebuild the relevant package before re-running the example: + +```bash +# From repo root — rebuild core (required when dappfence.js source changes) +npm run build -w @dappfence/core +``` + ### Development Requirements - Node.js 16.0.0 or higher diff --git a/eslint.config.js b/eslint.config.js index 0b35db2..bf1133e 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -14,11 +14,12 @@ export default [ }, js.configs.recommended, prettier, - // Node scripts (CommonJS) + // Node scripts (CommonJS + ESM) { files: [ 'packages/test-app/src/**/*.js', 'packages/signer/src/**/*.js', + 'packages/astro-integration/src/**/*.js', 'packages/dappfence/vite.config.js', ], languageOptions: { diff --git a/package.json b/package.json index b7b5201..3875080 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "scripts": { "dev": "npm run build && npm run dev -w @dappfence/test-app", "test": "npm run build && npm test --ws --if-present", + "test:coverage": "npm run build && npm run test:coverage --ws --if-present", "build": "npm run build --ws --if-present", "build:prod": "npm run build:prod -w @dappfence/core", "build:watch": "npm run build:watch -w @dappfence/core & npm run build:watch -w @dappfence/test-app", @@ -12,6 +13,7 @@ "lint": "eslint packages/ --fix", "check": "eslint packages/ && prettier --check packages/ *.md", "format": "prettier --write packages/ *.md", + "publish:local": "npm run build:prod -w @dappfence/core && npm pack -w @dappfence/core --pack-destination dist && npm pack -w @dappfence/signer --pack-destination dist && npm pack -w @dappfence/astro --pack-destination dist", "publish:core": "npm run check && npm run build:prod && npm publish -w @dappfence/core --access public", "prepare": "npm run check && npm run build:prod", "ci:install": "npm ci --ignore-scripts && npx playwright install chromium", diff --git a/packages/astro-integration/README.md b/packages/astro-integration/README.md new file mode 100644 index 0000000..f6a0a68 --- /dev/null +++ b/packages/astro-integration/README.md @@ -0,0 +1,123 @@ +# @dappfence/astro + +Astro integration for [DappFence](../../README.md) — automatically injects the security script and +generates a signed integrity manifest at build time. + +## Installation + +```bash +npm install @dappfence/astro @dappfence/core +``` + +`@dappfence/core` provides the `dappfence.js` runtime that gets copied into your build output. + +## Setup + +```js +// astro.config.mjs +import { defineConfig } from 'astro/config'; +import dappfence from '@dappfence/astro'; + +export default defineConfig({ + integrations: [dappfence()], +}); +``` + +The integration reads the signing key from the `DAPPFENCE_SECRET_KEY` environment variable +automatically. If the variable is not set and no `secretKey` option is passed, the build fails with +a clear error. + +Generate a key once and store it in your CI secrets / `.env` file: + +```bash +node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +# → f0570667f495... +``` + +```bash +# .env (never commit this file) +DAPPFENCE_SECRET_KEY=f0570667f495... +``` + +The corresponding Ethereum address (used to verify the manifest signature at runtime) is derived +automatically — you only need to supply the secret key. + +### Key resolution order + +1. `secretKey` option passed to `dappfence({ secretKey: '…' })` — highest priority +2. `DAPPFENCE_SECRET_KEY` environment variable +3. Neither provided → **build error** + +Prefer the environment variable in production, so the key is never committed to source control. The +explicit option is useful for local development fallbacks: + +```js +// astro.config.mjs +const DEV_KEY = 'f0570667f495…'; // local dev only, not secret + +export default defineConfig({ + integrations: [ + dappfence({ + secretKey: DEV_KEY, // overridden by DAPPFENCE_SECRET_KEY if set + }), + ], +}); +``` + +## Options + +| Option | Type | Default | Description | +| --------------------------- | ---------- | ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `secretKey` | `string` | `DAPPFENCE_SECRET_KEY` env var | **Required.** Hex secret key (with or without `0x` prefix) used to sign the manifest. Falls back to the `DAPPFENCE_SECRET_KEY` environment variable; build fails if neither is set. | +| `scriptSrc` | `string` | `'/dappfence.js'` | URL path where `dappfence.js` will be served. | +| `manifestUrl` | `string` | `'/integrity-manifest.json'` | URL path where the manifest will be served. | +| `manifestPath` | `string` | `'integrity-manifest.json'` | Output filename for the manifest relative to the build output dir. | +| `manifestSignatureType` | `string` | `'noble-secp256k1-recovered-eth'` | Signature algorithm written into the manifest. | +| `manifestSignatureIdentity` | `string` | derived from `secretKey` | Expected signer Ethereum address. Auto-derived if `secretKey` is set. | +| `mode` | `string` | `'protected'` | Enforcement mode: `'protected'` blocks requests that fail verification; `'reporting'` logs violations without blocking. | +| `appSW` | `string` | `null` | Path to your app's own service worker, loaded by DappFence via `importScripts()`. | +| `warningUrl` | `string` | `null` | URL shown on the security warning page for tamper alerts. | +| `extensions` | `string[]` | `['.js','.mjs','.css','.html','.htm','.json','.svg']` | File extensions included in the manifest. | +| `exclude` | `string[]` | `[]` | Web paths to exclude from the manifest (e.g. `['/admin']`). | + +## What Happens at Build Time + +Running `astro build` triggers three steps in order: + +1. **`dappfence.js` is copied** from `@dappfence/core` into your output directory at `scriptSrc` + (default `dist/dappfence.js`), so it is served as a first-party file and included in the manifest + hash. + +2. **Script tag is injected** into every HTML file in the output directory: + + ```html + + ``` + +3. **`integrity-manifest.json` is generated** — SHA-256 hashes for every tracked file, signed with + your `secretKey`, written to the output directory. + +The integration is a **no-op in `astro dev`**. Vite transforms files at request time so their bytes +never match a static manifest; DappFence is a production-only security layer. Test against the real +build output with `astro preview`. + +For details on the manifest format, signature scheme, and verification internals see the +[DappFence README](../../README.md). + +## Current Limitations + +- **Static sites only (v1).** This is an initial release. Only files written to disk at build time + can be hashed and verified. SSR (server-side rendering) is not supported yet — pages rendered on + demand are outside the verification boundary. SSR support is planned for a future version. + +- **Dev server is unprotected.** `astro dev` is intentionally skipped. Security testing must be + done against `astro build` output served with `astro preview` or a static file server. + +- **Initial load is trusted.** DappFence follows a bootstrap trust model: the initial HTML and + `dappfence.js` itself are fetched before the service worker is active, so they are not verified + on the very first page load. All later navigations and asset fetches are verified. diff --git a/packages/astro-integration/package.json b/packages/astro-integration/package.json new file mode 100644 index 0000000..add5425 --- /dev/null +++ b/packages/astro-integration/package.json @@ -0,0 +1,44 @@ +{ + "name": "@dappfence/astro", + "version": "0.1.0", + "description": "Astro integration for DappFence — script injection, manifest generation, dynamic content tagging", + "license": "MIT", + "type": "module", + "main": "./src/index.js", + "exports": { + ".": "./src/index.js" + }, + "files": [ + "src", + "!src/__tests__" + ], + "scripts": { + "test": "vitest run", + "test:coverage": "vitest run --coverage" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/coinspect/dappfence.git", + "directory": "packages/astro-integration" + }, + "keywords": [ + "astro", + "astro-integration", + "security", + "service-worker", + "integrity", + "dapp", + "web-security" + ], + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@dappfence/core": "*", + "@dappfence/signer": "*" + }, + "devDependencies": { + "@vitest/coverage-v8": "4.1.4", + "vitest": "4.1.4" + } +} diff --git a/packages/astro-integration/src/__tests__/manifest.test.js b/packages/astro-integration/src/__tests__/manifest.test.js new file mode 100644 index 0000000..f3e6506 --- /dev/null +++ b/packages/astro-integration/src/__tests__/manifest.test.js @@ -0,0 +1,362 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { + buildScriptAttrs, + buildScriptTag, + injectScriptTag, + buildPageSet, + extractDynamicRoutes, + generateManifest, + DEFAULT_EXTENSIONS, +} from '../manifest.js'; + +const MINIMAL = { scriptSrc: '/dappfence.js' }; + +// ── buildScriptAttrs ────────────────────────────────────────────────────────── + +describe('buildScriptAttrs', () => { + it('includes src from scriptSrc', () => { + expect(buildScriptAttrs(MINIMAL).src).toBe('/dappfence.js'); + }); + + it('omits falsy optional attributes', () => { + const attrs = buildScriptAttrs({ ...MINIMAL, appSW: null, warningUrl: null }); + expect(attrs).not.toHaveProperty('data-app-sw'); + expect(attrs).not.toHaveProperty('data-warning-url'); + }); + + it('includes all optional attributes when provided', () => { + const attrs = buildScriptAttrs({ + scriptSrc: '/dappfence.js', + manifestUrl: '/integrity-manifest.json', + manifestSignatureType: 'noble-secp256k1-recovered-eth', + manifestSignatureIdentity: '0xAbC123', + appSW: '/app-sw.js', + warningUrl: '/security-warning', + }); + expect(attrs['data-manifest']).toBe('/integrity-manifest.json'); + expect(attrs['data-manifest-signature-type']).toBe('noble-secp256k1-recovered-eth'); + expect(attrs['data-manifest-signature-identity']).toBe('0xAbC123'); + expect(attrs['data-app-sw']).toBe('/app-sw.js'); + expect(attrs['data-warning-url']).toBe('/security-warning'); + }); +}); + +// ── buildScriptTag ──────────────────────────────────────────────────────────── + +describe('buildScriptTag', () => { + it('produces a valid script element', () => { + const tag = buildScriptTag(MINIMAL); + expect(tag).toMatch(/^`; +} + +export function injectScriptTag(html, scriptAttrs) { + const tag = buildScriptTag(scriptAttrs); + // Guard against double-injection on incremental rebuilds. + if (html.includes(tag)) return html; + return html.replace(/(]*>)/i, `$1\n ${tag}`); +} + +async function walk(base, dir, extensions, excludes) { + const entries = await fs.readdir(dir, { withFileTypes: true }); + const results = await Promise.all( + entries.map(async (entry) => { + const abs = path.join(dir, entry.name); + const web = '/' + path.relative(base, abs).replace(/\\/g, '/'); + if (entry.isDirectory()) { + if (excludes.some((e) => web.startsWith(e))) return []; + return walk(base, abs, extensions, excludes); + } + if (entry.isFile()) { + const ext = path.extname(entry.name).toLowerCase(); + if (extensions.includes(ext) && !excludes.some((e) => web.startsWith(e))) { + return [{ webPath: web, absPath: abs, ext }]; + } + } + return []; + }) + ); + return results.flat(); +} + +/** + * Extract server-rendered route patterns from the Astro routes array. + * Returns route strings like '/blog/[slug]' and '/_server-islands/[name]'. + * + * Astro marks every route with `isPrerendered`. Routes that are not + * prerendered are rendered on demand (SSR pages, server islands, API routes). + * The SW can use these patterns in the future to skip full hash verification + * for requests that match them. + */ +export function extractDynamicRoutes(routes) { + if (!routes?.length) return []; + return routes + .filter((r) => !r.isPrerendered) + .map((r) => r.pattern) + .filter(Boolean); +} + +export function buildPageSet(pages) { + const set = new Set(); + for (const { pathname } of pages) { + const base = pathname.replace(/\/$/, ''); + set.add(base ? `${base}/index.html` : '/index.html'); + if (base) set.add(`${base}.html`); + } + return set; +} + +export async function generateManifest({ + outDir, + pages, + routes, + manifestPath, + extensions, + exclude, + secretKey, + mode, + logger, + scriptAttrs, +}) { + const exts = extensions || DEFAULT_EXTENSIONS; + // Always exclude the manifest file itself to avoid a circular reference. + const excludes = [...(exclude || []), '/' + manifestPath]; + + const dynamicRoutes = extractDynamicRoutes(routes); + if (dynamicRoutes.length) { + logger.info(`DappFence: ${dynamicRoutes.length} dynamic (SSR) routes captured`); + } + + const files = await walk(outDir, outDir, exts, excludes); + logger.info(`DappFence: hashing ${files.length} files`); + + const pageSet = pages?.length ? buildPageSet(pages) : null; + + const fileHashes = {}; + for (const { webPath, absPath, ext } of files) { + let buf = await fs.readFile(absPath); + + const isPage = pageSet ? pageSet.has(webPath) : ext === '.html' || ext === '.htm'; + if (isPage) { + const html = buf.toString('utf8'); + const injected = injectScriptTag(html, scriptAttrs); + if (injected !== html) { + await fs.writeFile(absPath, injected, 'utf8'); + buf = Buffer.from(injected, 'utf8'); + logger.info(`DappFence: injected script tag into ${webPath}`); + } + } + + fileHashes[webPath] = calculateFileHash(buf); + } + + const payload = { + files: fileHashes, + mode, + metadata: { + extensions: exts, + buildTime: new Date().toISOString(), + version: 'latest', + ...(dynamicRoutes.length && { dynamicRoutes }), + }, + }; + + let manifest; + if (secretKey) { + try { + manifest = signManifest(payload, { secretKey }); + logger.info('DappFence: manifest signed'); + } catch (err) { + logger.error(`DappFence: signing failed — ${err.message}`); + manifest = { pay: payload }; + } + } else { + manifest = { pay: payload }; + logger.warn('DappFence: no signing keys provided, manifest is unsigned'); + } + + const out = path.join(outDir, manifestPath); + await fs.writeFile(out, JSON.stringify(manifest, null, 2), 'utf8'); + logger.info(`DappFence: manifest written → ${manifestPath}`); +} diff --git a/packages/astro-integration/src/vite-plugin.js b/packages/astro-integration/src/vite-plugin.js new file mode 100644 index 0000000..e2ff772 --- /dev/null +++ b/packages/astro-integration/src/vite-plugin.js @@ -0,0 +1,24 @@ +/** + * Vite plugin that injects the dappfence.js script tag into every HTML page. + * Runs in both dev server and production build modes. + */ +import { buildScriptAttrs } from './manifest.js'; + +export function createDappFenceVitePlugin(opts) { + return { + name: 'vite-plugin-dappfence', + transformIndexHtml: { + // 'pre' so we run before Astro's own transforms + order: 'pre', + handler() { + return [ + { + tag: 'script', + attrs: buildScriptAttrs(opts), + injectTo: 'head-prepend', + }, + ]; + }, + }, + }; +} diff --git a/packages/signer/src/build.js b/packages/signer/src/build.js index 5a7280d..e9adb27 100644 --- a/packages/signer/src/build.js +++ b/packages/signer/src/build.js @@ -3,7 +3,7 @@ * Pure functions for hashing files and signing manifests. */ const crypto = require('crypto'); -const { sign, ethereumAddress, keccak256 } = require('./crypto'); +const { sign, ethereumAddress, getPublicKey, hexToBytes, keccak256 } = require('./crypto'); /** * Calculate SHA-256 hash of a file buffer or path. @@ -30,15 +30,17 @@ function calculateStringHash(content) { * Sign a manifest payload and return the signed JSON string. * @param {object} manifestData - The manifest payload (e.g. { files: { ... }, metadata: { ... } }) * @param {object} keys - * @param {Uint8Array} keys.publicKey - * @param {Uint8Array} keys.secretKey + * @param {string|Uint8Array} keys.secretKey - Hex string (with or without 0x) or raw bytes * @returns {{ pay: object, sig: string, identity: string, signatureType: string }} */ -function signManifest(manifestData, { publicKey, secretKey }) { - const identity = ethereumAddress(publicKey); +function signManifest(manifestData, { secretKey }) { + const skBytes = + typeof secretKey === 'string' ? hexToBytes(secretKey.replace(/^0x/, '')) : secretKey; + const pkBytes = getPublicKey(skBytes); + const identity = ethereumAddress(pkBytes); const msg = new TextEncoder('utf-8').encode(JSON.stringify(manifestData, null, 2)); const msgHash = keccak256(msg); - const sig = sign(msgHash, secretKey); + const sig = sign(msgHash, skBytes); return { pay: manifestData, sig, @@ -47,4 +49,15 @@ function signManifest(manifestData, { publicKey, secretKey }) { }; } -module.exports = { calculateFileHash, calculateStringHash, signManifest }; +/** + * Derive the Ethereum signer identity from a secret key hex string. + * @param {string} secretKeyHex - 64-char hex, with or without 0x prefix + * @returns {string} Ethereum address like "0x..." + */ +function deriveIdentity(secretKeyHex) { + const sk = hexToBytes(secretKeyHex.replace(/^0x/, '')); + const pk = getPublicKey(sk); + return ethereumAddress(pk); +} + +module.exports = { calculateFileHash, calculateStringHash, signManifest, deriveIdentity }; From bc31d092d55f74772188ecb79fdd955e609e650d Mon Sep 17 00:00:00 2001 From: adji Date: Fri, 29 May 2026 17:59:59 -0300 Subject: [PATCH 02/59] refactor: remove dead vite-plugin from astro integration Astro's SSG pipeline bypasses Vite's transformIndexHtml, so the plugin never fired in production. Script injection is handled in astro:build:done via injectScriptTag. Dev mode intentionally has no DappFence presence. --- packages/astro-integration/src/index.js | 15 +----------- packages/astro-integration/src/vite-plugin.js | 24 ------------------- 2 files changed, 1 insertion(+), 38 deletions(-) delete mode 100644 packages/astro-integration/src/vite-plugin.js diff --git a/packages/astro-integration/src/index.js b/packages/astro-integration/src/index.js index c31a034..5583863 100644 --- a/packages/astro-integration/src/index.js +++ b/packages/astro-integration/src/index.js @@ -18,7 +18,6 @@ import { createRequire } from 'node:module'; import { promises as fs } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { createDappFenceVitePlugin } from './vite-plugin.js'; import { generateManifest } from './manifest.js'; const _require = createRequire(import.meta.url); @@ -54,14 +53,7 @@ export default function dappfence(options = {}) { return { name: '@dappfence/astro', hooks: { - // Runs once at startup (dev and build). - // Registers the Vite plugin so that Vite's transformIndexHtml pipeline - // can inject the script tag during Vite-controlled builds. In practice - // Astro bypasses this for its own SSG output, so the real injection - // happens in astro:build:done (prod). DappFence is intentionally a - // no-op in dev — Vite transforms files at request time, so hash - // verification cannot work against a static manifest. - 'astro:config:setup'({ updateConfig, logger }) { + 'astro:config:setup'({ logger }) { if (!opts.secretKey) { logger.error( 'DappFence: secretKey is required. ' + @@ -69,11 +61,6 @@ export default function dappfence(options = {}) { ); throw new Error('[@dappfence/astro] secretKey is required'); } - updateConfig({ - vite: { - plugins: [createDappFenceVitePlugin(opts)], - }, - }); }, // Fires after Astro resolves all routes (dev and build). diff --git a/packages/astro-integration/src/vite-plugin.js b/packages/astro-integration/src/vite-plugin.js deleted file mode 100644 index e2ff772..0000000 --- a/packages/astro-integration/src/vite-plugin.js +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Vite plugin that injects the dappfence.js script tag into every HTML page. - * Runs in both dev server and production build modes. - */ -import { buildScriptAttrs } from './manifest.js'; - -export function createDappFenceVitePlugin(opts) { - return { - name: 'vite-plugin-dappfence', - transformIndexHtml: { - // 'pre' so we run before Astro's own transforms - order: 'pre', - handler() { - return [ - { - tag: 'script', - attrs: buildScriptAttrs(opts), - injectTo: 'head-prepend', - }, - ]; - }, - }, - }; -} From b8d0ec92d9f96bacaa901f1f47928c7ec5344111 Mon Sep 17 00:00:00 2001 From: adji Date: Fri, 29 May 2026 18:27:51 -0300 Subject: [PATCH 03/59] chore: update package-lock.json with astro integration --- package-lock.json | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/package-lock.json b/package-lock.json index ab66797..6d75cd4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -78,6 +78,10 @@ "node": ">=18" } }, + "node_modules/@dappfence/astro": { + "resolved": "packages/astro-integration", + "link": true + }, "node_modules/@dappfence/core": { "resolved": "packages/dappfence", "link": true @@ -4330,6 +4334,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "packages/astro-integration": { + "name": "@dappfence/astro", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@dappfence/core": "*", + "@dappfence/signer": "*" + }, + "devDependencies": { + "@vitest/coverage-v8": "4.1.4", + "vitest": "4.1.4" + } + }, "packages/dappfence": { "name": "@dappfence/core", "version": "0.1.0", From 339d68ffda9efc05cd99945ee02ff4354282efcf Mon Sep 17 00:00:00 2001 From: adji Date: Fri, 29 May 2026 08:00:50 -0300 Subject: [PATCH 04/59] refactor: replace isNavigation flag with request.mode checks isNavigation was threaded explicitly through addMarkToRequest, resolveManifest, and createBlockResponse even though it's always derivable from request.mode === 'navigate'. Remove it everywhere and derive it at each call site. --- packages/dappfence/src/sw/fetch-handler.js | 30 +++++++++---------- .../src/sw/manifest/manifest-service.js | 7 +++-- packages/dappfence/src/sw/response.js | 15 ++++------ 3 files changed, 24 insertions(+), 28 deletions(-) diff --git a/packages/dappfence/src/sw/fetch-handler.js b/packages/dappfence/src/sw/fetch-handler.js index d3c0d26..96308ec 100644 --- a/packages/dappfence/src/sw/fetch-handler.js +++ b/packages/dappfence/src/sw/fetch-handler.js @@ -33,9 +33,9 @@ export function createSecurityFetchHandler({ * Add DappFence tracking markers to the request. * Pure function — takes originUrl as a string so it can be tested without swContext. */ - function addMarkToRequest(event, request, isNavigation, originUrl = locationOrigin) { + function addMarkToRequest(event, request) { const requestUrl = new URL(request.url); - const isSameOrigin = requestUrl.origin === originUrl; + const isSameOrigin = requestUrl.origin === locationOrigin; if (!isSameOrigin) { logger.log(`[SW-X-ORIGIN] Cross-origin (no tracking): ${request.url}`); @@ -50,7 +50,7 @@ export function createSecurityFetchHandler({ let modifiedRequest; // Handle navigation requests differently (they can't be fully cloned) - if (isNavigation) { + if (request.mode === 'navigate') { logger.log( `[DFSW-NAVIGATE] Navigation request (URL tracking only): ${request.url}` ); @@ -145,12 +145,12 @@ export function createSecurityFetchHandler({ * assets) or hashes and compares. Only true violations reach * recordSecurityViolation; SKIPPED and MATCH pass through. */ - async function verifyAssetIntegrity(ctx, request, response) { + async function verifyAssetIntegrity(ctx, request, response, clientId) { logger.log('Verifying security-critical asset:', request.url); // Clone so the original body is still available to forward to the page; // verifyFile consumes the clone via arrayBuffer(). - const verificationResult = await ctx.verifyFile(request.url, response.clone()); + const verificationResult = await ctx.verifyFile(request, response.clone(), clientId); if (verificationResult.status.isViolation) { return await appStore.recordSecurityViolation({ ...verificationResult, @@ -165,12 +165,12 @@ export function createSecurityFetchHandler({ const originalRequest = event.request; try { const url = new URL(originalRequest.url); - const isNavigation = originalRequest.mode === 'navigate'; - const clientId = isNavigation ? event.resultingClientId : event.clientId; + const clientId = + originalRequest.mode === 'navigate' ? event.resultingClientId : event.clientId; // Log all fetch requests for debugging logger.log( - `%cFetch: ${originalRequest.method} ${originalRequest.url} ${isNavigation ? 'isNavigation' : ''} clientId: ${clientId} `, + `%cFetch: ${originalRequest.method} ${originalRequest.url} mode: ${originalRequest.mode} clientId: ${clientId} `, 'color:cyan' ); @@ -187,21 +187,21 @@ export function createSecurityFetchHandler({ } } - // Resolve the manifest context once per request — mode and verifyFile // share the single IndexedDB lookup done here. - const ctx = await manifestService.resolveManifest({ clientId, isNavigation }); + // Resolve the manifest context once per request — mode and verifyFile + const ctx = await manifestService.resolveManifest(); logger.log(`Client mode: ${clientId} ${ctx.mode}`); // Site-wide block gate only fires in protected mode. In other modes we // still let the request flow so the child SW's response is returned // untouched. if (ctx.mode === MODE.PROTECTED && (await activeBlocksStore.isBlocked())) { - return createBlockResponse(isNavigation, originalRequest.url, locationHref); + return createBlockResponse(originalRequest, locationHref); } // Add tracking markers to request BEFORE any handlers to see it const markedRequest = isFeatureEnabled('mark_request') - ? addMarkToRequest(event, originalRequest, isNavigation) + ? addMarkToRequest(event, originalRequest) : originalRequest; // Delegate to child SW and capture its response @@ -214,14 +214,14 @@ export function createSecurityFetchHandler({ return response; } - const mustBlock = await verifyAssetIntegrity(ctx, markedRequest, response); + const mustBlock = await verifyAssetIntegrity(ctx, markedRequest, response, clientId); if (ctx.mode === MODE.PROTECTED && mustBlock) { // Navigation requests get the warning inline via createBlockResponse; // so broadcasting to the client would double-notify. - if (!isNavigation) { + if (markedRequest.mode !== 'navigate') { await onSecurityViolation(); } - return createBlockResponse(isNavigation, markedRequest.url, locationHref); + return createBlockResponse(markedRequest, locationHref); } return response; } catch (error) { diff --git a/packages/dappfence/src/sw/manifest/manifest-service.js b/packages/dappfence/src/sw/manifest/manifest-service.js index 7729c78..2a49cf6 100644 --- a/packages/dappfence/src/sw/manifest/manifest-service.js +++ b/packages/dappfence/src/sw/manifest/manifest-service.js @@ -183,7 +183,7 @@ export const createManifestService = ({ swContext, appStore, config }) => { * outside the fetch pipeline such as importScripts. */ - const resolveManifest = async ({ clientId, isNavigation } = {}) => { + const resolveManifest = async () => { // The latest stored manifest drives policy; on a cold start we fetch one. // On fetch failure the result has no `manifest` field, so policy // falls through to defaults via policyFromManifest's optional @@ -211,8 +211,9 @@ export const createManifestService = ({ swContext, appStore, config }) => { ); return { mode, - verifyFile: (url, response) => { - const fileKey = getFileKey(url, swContext.getLocationHref()); + verifyFile: (request, response, clientId) => { + const isNavigation = request.mode === 'navigate'; + const fileKey = getFileKey(request.url, swContext.getLocationHref()); if (!shouldVerifyAsset(fileKey, isNavigation, response, extensions, contentTypes)) { logger.log(`⏭️ Skipping verification: ${fileKey}`); return { status: VERIFICATION_STATUS.SKIPPED, fileKey }; diff --git a/packages/dappfence/src/sw/response.js b/packages/dappfence/src/sw/response.js index 875d4b3..0ed2745 100644 --- a/packages/dappfence/src/sw/response.js +++ b/packages/dappfence/src/sw/response.js @@ -102,21 +102,16 @@ const isServiceWorkerPath = (requestUrl, locationHref) => { }; /** - * Creates an appropriate block response based on context + * Creates an appropriate block response based on context. * - * Determines the correct type of security block response to return based on - * whether the blocked asset is the service worker script itself, a navigation - * request, or a regular subresource request. - * - * @param {boolean} isNavigation - Whether this is a navigation request - * @param {string} requestUrl - The URL of the blocked request (absolute or relative) + * @param {Request} request - The blocked request * @param {string} locationHref - The service worker's location.href */ -export function createBlockResponse(isNavigation, requestUrl, locationHref) { - if (isNavigation) { +export function createBlockResponse(request, locationHref) { + if (request.mode === 'navigate') { return createRedirectResponse(API.SECURITY_WARNING); } - if (isServiceWorkerPath(requestUrl, locationHref)) { + if (isServiceWorkerPath(request.url, locationHref)) { return createJavascriptRedirectResponse(); } return createSecurityWarningResponse(); From ff9e2939cbb51c519d4474f782d7c3e48a3be357 Mon Sep 17 00:00:00 2001 From: adji Date: Tue, 2 Jun 2026 09:17:43 -0300 Subject: [PATCH 05/59] test: fix unit tests --- .../dappfence/src/sw/__tests__/response.test.js | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/packages/dappfence/src/sw/__tests__/response.test.js b/packages/dappfence/src/sw/__tests__/response.test.js index 03adc78..70225d0 100644 --- a/packages/dappfence/src/sw/__tests__/response.test.js +++ b/packages/dappfence/src/sw/__tests__/response.test.js @@ -12,8 +12,7 @@ vi.mock('../../core/utils.js', () => ({ describe('createBlockResponse', () => { it('returns JS redirect when request targets the SW script', () => { const response = createBlockResponse( - false, - 'https://example.com/sw.js', + { mode: 'no-cors', url: 'https://example.com/sw.js' }, 'https://example.com/sw.js' ); expect(response.headers.get('Content-Type')).toContain('javascript'); @@ -21,8 +20,7 @@ describe('createBlockResponse', () => { it('returns 302 redirect to the warning page for navigation requests', () => { const response = createBlockResponse( - true, - 'https://example.com/app.js', + { mode: 'navigate', url: 'https://example.com/app.js' }, 'https://example.com/sw.js' ); expect(response.status).toBe(302); @@ -31,8 +29,7 @@ describe('createBlockResponse', () => { it('returns plain text warning for non-navigation subresource requests', () => { const response = createBlockResponse( - false, - 'https://example.com/app.js', + { mode: 'no-cors', url: 'https://example.com/app.js' }, 'https://example.com/sw.js' ); expect(response.headers.get('Content-Type')).toContain('text/plain'); @@ -41,8 +38,7 @@ describe('createBlockResponse', () => { it('does not treat a cross-origin same-pathname URL as the SW script', () => { const response = createBlockResponse( - false, - 'https://evil.com/sw.js', + { mode: 'no-cors', url: 'https://evil.com/sw.js' }, 'https://example.com/sw.js' ); expect(response.headers.get('Content-Type')).toContain('text/plain'); @@ -72,8 +68,7 @@ describe('security-warning template', () => { describe('createBlockResponse edge cases', () => { it('handles invalid locationHref gracefully in SW path check', () => { const response = createBlockResponse( - false, - 'https://example.com/app.js', + { mode: 'no-cors', url: 'https://example.com/app.js' }, 'not-a-valid-url' ); expect(response.status).toBe(403); From 507c4ea461517394d6d70ad4187676d53db770c2 Mon Sep 17 00:00:00 2001 From: adji Date: Fri, 29 May 2026 14:12:16 -0300 Subject: [PATCH 06/59] test: expand unit coverageD, inject IndexedDB and add tests for fetch/lifecycle/services/sw-main New test files cover fetch-handler, indexeddb (with injected mock IDB), lifecycle-handlers, services factory, and sw-main initialization. Existing suites extended for manifest-service, operations, appsw-hooks, active-blocks-store, and client security-handler. --- package.json | 1 + .../client/__tests__/security-handler.test.js | 103 ++++ .../src/core/__tests__/crypto.test.js | 155 ++++- .../src/core/__tests__/monkey-patch.test.js | 170 +++++- .../sw/__tests__/active-blocks-store.test.js | 148 ++++- .../src/sw/__tests__/appsw-hooks.test.js | 185 +++++- .../src/sw/__tests__/fetch-handler.test.js | 420 ++++++++++++++ .../src/sw/__tests__/indexeddb.test.js | 536 ++++++++++++++++++ .../sw/__tests__/lifecycle-handlers.test.js | 193 +++++++ .../src/sw/__tests__/manifest-service.test.js | 428 +++++++++++++- .../src/sw/__tests__/manifest-store.test.js | 10 + .../src/sw/__tests__/message-broker.test.js | 25 + .../src/sw/__tests__/operations.test.js | 147 ++++- .../__tests__/security-events-store.test.js | 12 + .../src/sw/__tests__/services.test.js | 113 ++++ .../src/sw/__tests__/storage-index.test.js | 43 ++ .../src/sw/__tests__/sw-main.test.js | 101 ++++ packages/dappfence/src/sw/services.js | 2 +- .../dappfence/src/sw/storage/indexeddb.js | 28 +- packages/dappfence/vite.config.js | 6 + 20 files changed, 2799 insertions(+), 27 deletions(-) create mode 100644 packages/dappfence/src/client/__tests__/security-handler.test.js create mode 100644 packages/dappfence/src/sw/__tests__/fetch-handler.test.js create mode 100644 packages/dappfence/src/sw/__tests__/indexeddb.test.js create mode 100644 packages/dappfence/src/sw/__tests__/lifecycle-handlers.test.js create mode 100644 packages/dappfence/src/sw/__tests__/services.test.js create mode 100644 packages/dappfence/src/sw/__tests__/sw-main.test.js diff --git a/package.json b/package.json index 3875080..551b4c4 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "dev": "npm run build && npm run dev -w @dappfence/test-app", "test": "npm run build && npm test --ws --if-present", "test:coverage": "npm run build && npm run test:coverage --ws --if-present", + "test:unit": "npm test -w @dappfence/core", "build": "npm run build --ws --if-present", "build:prod": "npm run build:prod -w @dappfence/core", "build:watch": "npm run build:watch -w @dappfence/core & npm run build:watch -w @dappfence/test-app", diff --git a/packages/dappfence/src/client/__tests__/security-handler.test.js b/packages/dappfence/src/client/__tests__/security-handler.test.js new file mode 100644 index 0000000..8eb4f3d --- /dev/null +++ b/packages/dappfence/src/client/__tests__/security-handler.test.js @@ -0,0 +1,103 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { notifyServiceWorkerReady } from '../security-handler.js'; +import { MSG } from '../../core/constants.js'; + +function installNavigatorMock(swProps = {}) { + const mock = { serviceWorker: { addEventListener: vi.fn(), controller: null, ...swProps } }; + Object.defineProperty(globalThis, 'navigator', { + value: mock, + writable: true, + configurable: true, + }); +} + +function installWindowMock() { + Object.defineProperty(globalThis, 'window', { + value: { location: { replace: vi.fn() } }, + writable: true, + configurable: true, + }); +} + +beforeEach(() => { + installNavigatorMock(); + installWindowMock(); +}); + +describe('setupSecurityMessageListener', () => { + let setupSecurityMessageListener; + + beforeEach(async () => { + vi.resetModules(); + ({ setupSecurityMessageListener } = await import('../security-handler.js')); + installNavigatorMock(); + installWindowMock(); + }); + + it('registers a message event listener on navigator.serviceWorker', () => { + setupSecurityMessageListener(); + expect(navigator.serviceWorker.addEventListener).toHaveBeenCalledWith( + 'message', + expect.any(Function) + ); + }); +}); + +describe('handleSecurityMessage', () => { + let setupSecurityMessageListener; + + beforeEach(async () => { + vi.resetModules(); + ({ setupSecurityMessageListener } = await import('../security-handler.js')); + installNavigatorMock(); + installWindowMock(); + }); + + function getHandler() { + setupSecurityMessageListener(); + const calls = navigator.serviceWorker.addEventListener.mock.calls; + return calls[calls.length - 1][1]; + } + + it('calls window.location.replace with warningUrl on SECURITY_BLOCK message', () => { + const handler = getHandler(); + handler({ data: { type: MSG.SECURITY_BLOCK, warningUrl: '/sw-api/security-warning' } }); + expect(window.location.replace).toHaveBeenCalledWith('/sw-api/security-warning'); + }); + + it('does not call replace a second time (idempotent via redirectAttempted)', () => { + const handler = getHandler(); + handler({ data: { type: MSG.SECURITY_BLOCK, warningUrl: '/sw-api/security-warning' } }); + handler({ data: { type: MSG.SECURITY_BLOCK, warningUrl: '/sw-api/security-warning' } }); + expect(window.location.replace).toHaveBeenCalledTimes(1); + }); + + it('does not call replace for a different message type', () => { + const handler = getHandler(); + handler({ data: { type: 'SOME_OTHER_TYPE', warningUrl: '/sw-api/security-warning' } }); + expect(window.location.replace).not.toHaveBeenCalled(); + }); +}); + +describe('notifyServiceWorkerReady', () => { + it('does nothing when there is no SW controller', () => { + globalThis.navigator.serviceWorker.controller = null; + expect(() => notifyServiceWorkerReady()).not.toThrow(); + }); + + it('does nothing when navigator.serviceWorker is absent', () => { + globalThis.navigator = {}; + expect(() => notifyServiceWorkerReady()).not.toThrow(); + }); + + it('posts CLIENT_READY message when a controller is present', () => { + const postMessage = vi.fn(); + globalThis.navigator.serviceWorker.controller = { postMessage }; + + notifyServiceWorkerReady(); + + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: MSG.CLIENT_READY }) + ); + }); +}); diff --git a/packages/dappfence/src/core/__tests__/crypto.test.js b/packages/dappfence/src/core/__tests__/crypto.test.js index 16c013e..29d86f9 100644 --- a/packages/dappfence/src/core/__tests__/crypto.test.js +++ b/packages/dappfence/src/core/__tests__/crypto.test.js @@ -1,5 +1,19 @@ -import { describe, it, expect } from 'vitest'; -import { calculateHash } from '../crypto.js'; +import { describe, it, expect, beforeAll } from 'vitest'; +import { + calculateHash, + ethereumAddress, + recoverEthereumAddress, + recoverPersonalSign, +} from '../crypto.js'; +import { sign, etc, Point, hashes, recoverPublicKey } from '@noble/secp256k1'; +import { keccak_256 } from '@noble/hashes/sha3.js'; +import { sha256 } from '@noble/hashes/sha2.js'; +import { hmac } from '@noble/hashes/hmac.js'; + +beforeAll(() => { + hashes.hmacSha256 = (key, msg) => hmac(sha256, key, msg); + hashes.sha256 = sha256; +}); describe('calculateHash', () => { it('returns an SRI string for given input', async () => { @@ -35,3 +49,140 @@ describe('calculateHash', () => { expect(hash).toBe('sha256-ungWv48Bz+pBQUDeXa4iI7ADYaOWF3qctBD/YfIAFa0='); }); }); + +describe('ethereumAddress', () => { + it('returns a 0x-prefixed 42-character lowercase hex address for generator point G', () => { + const addr = ethereumAddress(Point.BASE.toBytes(true)); + expect(addr).toMatch(/^0x[0-9a-f]{40}$/); + expect(addr).toHaveLength(42); + }); + + it('returns different addresses for different public keys', () => { + const privKey1 = new Uint8Array(32).fill(0); + privKey1[31] = 1; + const privKey2 = new Uint8Array(32).fill(0); + privKey2[31] = 2; + + const msgHash = keccak_256(new TextEncoder().encode('msg')); + const sig1 = sign(msgHash, privKey1, { prehash: false, format: 'recovered' }); + const sig2 = sign(msgHash, privKey2, { prehash: false, format: 'recovered' }); + + const pub1 = recoverPublicKey(sig1, msgHash, { prehash: false }); + const pub2 = recoverPublicKey(sig2, msgHash, { prehash: false }); + + expect(ethereumAddress(pub1)).not.toBe(ethereumAddress(pub2)); + }); +}); + +describe('recoverEthereumAddress', () => { + it('returns a 0x-prefixed 42-character lowercase hex address', () => { + const privKey = new Uint8Array(32).fill(0); + privKey[31] = 1; + + const msg = new TextEncoder().encode('hello dappfence'); + const msgHash = keccak_256(msg); + const sigBytes = sign(msgHash, privKey, { prehash: false, format: 'recovered' }); + const sigHex = etc.bytesToHex(sigBytes); + + const recovered = recoverEthereumAddress(msg, sigHex); + + expect(recovered).toMatch(/^0x[0-9a-f]{40}$/); + }); + + it('returns the same address as ethereumAddress for the corresponding public key', () => { + const privKey = new Uint8Array(32).fill(0); + privKey[31] = 3; + + const msg = new TextEncoder().encode('test message'); + const msgHash = keccak_256(msg); + const sigBytes = sign(msgHash, privKey, { prehash: false, format: 'recovered' }); + const sigHex = etc.bytesToHex(sigBytes); + + const pubKey = recoverPublicKey(sigBytes, msgHash, { prehash: false }); + const expected = ethereumAddress(pubKey); + + const recovered = recoverEthereumAddress(msg, sigHex); + + expect(recovered).toBe(expected); + }); + + it('returns different addresses for different signers', () => { + const privKey1 = new Uint8Array(32).fill(0); + privKey1[31] = 1; + const privKey2 = new Uint8Array(32).fill(0); + privKey2[31] = 2; + + const msg = new TextEncoder().encode('same message'); + const msgHash = keccak_256(msg); + + const sig1 = sign(msgHash, privKey1, { prehash: false, format: 'recovered' }); + const sig2 = sign(msgHash, privKey2, { prehash: false, format: 'recovered' }); + + const addr1 = recoverEthereumAddress(msg, etc.bytesToHex(sig1)); + const addr2 = recoverEthereumAddress(msg, etc.bytesToHex(sig2)); + + expect(addr1).not.toBe(addr2); + }); +}); + +describe('recoverPersonalSign', () => { + it('returns a 0x-prefixed 42-character lowercase hex address', () => { + const privKey = new Uint8Array(32).fill(0); + privKey[31] = 5; + + const msg = new TextEncoder().encode('hello personal sign'); + const msgHash = keccak_256(msg); + const prefix = '\x19Ethereum Signed Message:\n'; + const prefixAndLen = new TextEncoder().encode(prefix + msgHash.length); + const messageHash = keccak_256(etc.concatBytes(prefixAndLen, msgHash)); + + const sigBytes = sign(messageHash, privKey, { prehash: false, format: 'recovered' }); + const sigHex = etc.bytesToHex(sigBytes); + + const recovered = recoverPersonalSign(msg, sigHex); + + expect(recovered).toMatch(/^0x[0-9a-f]{40}$/); + }); + + it('returns same address as ethereumAddress for the corresponding public key', () => { + const privKey = new Uint8Array(32).fill(0); + privKey[31] = 7; + + const msg = new TextEncoder().encode('personal sign test'); + const msgHash = keccak_256(msg); + const prefix = '\x19Ethereum Signed Message:\n'; + const prefixAndLen = new TextEncoder().encode(prefix + msgHash.length); + const messageHash = keccak_256(etc.concatBytes(prefixAndLen, msgHash)); + + const sigBytes = sign(messageHash, privKey, { prehash: false, format: 'recovered' }); + const sigHex = etc.bytesToHex(sigBytes); + + const pubKey = recoverPublicKey(sigBytes, messageHash, { prehash: false }); + const expected = ethereumAddress(pubKey); + + const recovered = recoverPersonalSign(msg, sigHex); + + expect(recovered).toBe(expected); + }); + + it('returns different addresses for different signers', () => { + const privKey1 = new Uint8Array(32).fill(0); + privKey1[31] = 1; + const privKey2 = new Uint8Array(32).fill(0); + privKey2[31] = 2; + + const msg = new TextEncoder().encode('same message'); + const msgHash = keccak_256(msg); + const prefix = '\x19Ethereum Signed Message:\n'; + const prefixAndLen = new TextEncoder().encode(prefix + msgHash.length); + const messageHash = keccak_256(etc.concatBytes(prefixAndLen, msgHash)); + + const sig1 = sign(messageHash, privKey1, { prehash: false, format: 'recovered' }); + const sig2 = sign(messageHash, privKey2, { prehash: false, format: 'recovered' }); + + const addr1 = recoverPersonalSign(msg, etc.bytesToHex(sig1)); + const addr2 = recoverPersonalSign(msg, etc.bytesToHex(sig2)); + + expect(addr1).not.toBe(addr2); + }); +}); diff --git a/packages/dappfence/src/core/__tests__/monkey-patch.test.js b/packages/dappfence/src/core/__tests__/monkey-patch.test.js index 1873e72..aa29dc9 100644 --- a/packages/dappfence/src/core/__tests__/monkey-patch.test.js +++ b/packages/dappfence/src/core/__tests__/monkey-patch.test.js @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { monkeyPatch, secureMonkeyPatch, verifyPatchIntegrity } from '../monkey-patch.js'; describe('monkeyPatch', () => { @@ -39,6 +39,25 @@ describe('monkeyPatch', () => { target.fn('a', 'b', 'c'); expect(args).toEqual(['a', 'b', 'c']); }); + + it('ctx.apply invokes the original with a specific thisArg', () => { + const obj = { + value: 10, + fn(x) { + return this.value + x; + }, + }; + const altThis = { value: 99 }; + let capturedResult; + monkeyPatch(obj, 'fn', (ctx, x) => { + capturedResult = ctx.apply(altThis, [x]); + return capturedResult; + }); + + const result = obj.fn(1); + expect(result).toBe(100); + expect(capturedResult).toBe(100); + }); }); describe('secureMonkeyPatch', () => { @@ -67,6 +86,155 @@ describe('secureMonkeyPatch', () => { expect(target.fn(10)).toBe(11); }); + + it('verify returns false when a fake descriptor is constructed', () => { + const fakePatchedFn = () => {}; + const fakeTarget = { fn: fakePatchedFn }; + Object.defineProperty(fakeTarget, 'fn', { + value: fakePatchedFn, + writable: false, + configurable: false, + enumerable: true, + }); + const differentFn = () => {}; + const patch = { + success: true, + target: fakeTarget, + methodName: 'fn', + verify: () => { + const desc = Object.getOwnPropertyDescriptor(fakeTarget, 'fn'); + return ( + desc && + desc.value === differentFn && + desc.writable === false && + desc.configurable === false + ); + }, + }; + + expect(patch.verify()).toBe(false); + }); + + it('ctx.apply in secureMonkeyPatch invokes original with a specific thisArg', () => { + const obj = { + value: 5, + fn(x) { + return this.value + x; + }, + }; + const altThis = { value: 50 }; + let capturedResult; + secureMonkeyPatch(obj, 'fn', (ctx, x) => { + capturedResult = ctx.apply(altThis, [x]); + return capturedResult; + }); + + const result = obj.fn(3); + expect(result).toBe(53); + expect(capturedResult).toBe(53); + }); + + it('returns success=false when Object.defineProperty throws (non-configurable property)', () => { + const target = {}; + Object.defineProperty(target, 'frozen', { + value: () => 'original', + writable: false, + configurable: false, + enumerable: true, + }); + + const result = secureMonkeyPatch(target, 'frozen', (_ctx) => 'patched'); + + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + }); + + it('verify catch path returns false when target getOwnPropertyDescriptor throws', () => { + let verifyCallCount = 0; + const proxyTarget = new Proxy( + { fn: () => 'original' }, + { + defineProperty(t, prop, descriptor) { + return Object.defineProperty(t, prop, descriptor); + }, + getOwnPropertyDescriptor(t, prop) { + if (prop === 'fn' && verifyCallCount > 0) { + throw new Error('descriptor access denied'); + } + return Object.getOwnPropertyDescriptor(t, prop); + }, + } + ); + + const patchResult = secureMonkeyPatch(proxyTarget, 'fn', (_ctx) => 'patched'); + expect(patchResult.success).toBe(true); + + verifyCallCount++; + const verifyResult = patchResult.verify(); + expect(verifyResult).toBe(false); + }); + + it('verify returns false and logs tampering when descriptor value differs', () => { + let verifyCallCount = 0; + const proxyTarget = new Proxy( + { fn: () => 'original' }, + { + defineProperty(t, prop, descriptor) { + return Object.defineProperty(t, prop, descriptor); + }, + getOwnPropertyDescriptor(t, prop) { + if (prop === 'fn' && verifyCallCount > 0) { + return { + value: () => 'tampered', + writable: false, + configurable: false, + enumerable: true, + }; + } + return Object.getOwnPropertyDescriptor(t, prop); + }, + } + ); + + const patchResult = secureMonkeyPatch(proxyTarget, 'fn', (_ctx) => 'patched'); + expect(patchResult.success).toBe(true); + + verifyCallCount++; + const verifyResult = patchResult.verify(); + expect(verifyResult).toBe(false); + }); +}); + +describe('secureMonkeyPatch - line 92 tampering detection', () => { + it('line 92: console.error is called when isIntact is false due to tampered descriptor', () => { + const target = { fn: () => 'original' }; + const patchResult = secureMonkeyPatch(target, 'fn', (_ctx) => 'patched'); + expect(patchResult.success).toBe(true); + + const origDescriptor = Object.getOwnPropertyDescriptor; + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const spy = vi + .spyOn(Object, 'getOwnPropertyDescriptor') + .mockImplementation(function (obj, prop) { + if (obj === target && prop === 'fn') { + return { + value: () => 'tampered', + writable: false, + configurable: false, + enumerable: true, + }; + } + return origDescriptor.call(this, obj, prop); + }); + + const result = patchResult.verify(); + + expect(result).toBe(false); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('TAMPERING DETECTED')); + + spy.mockRestore(); + errorSpy.mockRestore(); + }); }); describe('verifyPatchIntegrity', () => { diff --git a/packages/dappfence/src/sw/__tests__/active-blocks-store.test.js b/packages/dappfence/src/sw/__tests__/active-blocks-store.test.js index b42eb74..3285b3d 100644 --- a/packages/dappfence/src/sw/__tests__/active-blocks-store.test.js +++ b/packages/dappfence/src/sw/__tests__/active-blocks-store.test.js @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { createActiveBlocksStore } from '../storage/security-stores.js'; +import { createActiveBlocksStore, generateBlockId } from '../storage/security-stores.js'; function createInMemoryDatabase() { const store = new Map(); @@ -246,5 +246,151 @@ describe('createActiveBlocksStore', () => { await store.clearBlockCondition(); expect(await store.isBlocked()).toBe(false); }); + + it('does not throw when database.set rejects', async () => { + const brokenDb = { + get: async () => [], + set: async () => { + throw new Error('set failed'); + }, + withTx: async (fn) => + fn({ + get: async () => undefined, + set: async () => {}, + }), + }; + const brokenStore = createActiveBlocksStore(brokenDb); + await expect(brokenStore.clearBlockCondition()).resolves.not.toThrow(); + }); + }); + + describe('getSecurityBlock error path', () => { + it('does not throw when database.get rejects', async () => { + const brokenDb = { + get: async () => { + throw new Error('get failed'); + }, + set: async () => {}, + withTx: async (fn) => + fn({ + get: async () => undefined, + set: async () => {}, + }), + }; + const brokenStore = createActiveBlocksStore(brokenDb); + const result = await brokenStore.getSecurityBlock('block_id'); + expect(result).toBeUndefined(); + }); + }); + + describe('getActiveBlocks error path', () => { + it('returns empty array when database.get rejects', async () => { + const brokenDb = { + get: async () => { + throw new Error('get failed'); + }, + set: async () => {}, + withTx: async (fn) => + fn({ + get: async () => undefined, + set: async () => {}, + }), + }; + const brokenStore = createActiveBlocksStore(brokenDb); + const result = await brokenStore.getActiveBlocks(); + expect(result).toEqual([]); + }); + + it('line 135: getActiveBlocks handles missing blocks key gracefully', async () => { + const store = new Map(); + store.set('active-block-ids', ['block_fake_id_here']); + const db = { + get: async (key) => store.get(key), + set: async (key, value) => store.set(key, value), + withTx: async (fn) => + fn({ + get: async (key) => store.get(key), + set: async (key, value) => store.set(key, value), + }), + }; + const s = createActiveBlocksStore(db); + const result = await s.getActiveBlocks(); + expect(result).toEqual([]); + }); + }); + + describe('recordSecurityBlock occurrenceCount edge cases', () => { + it('line 85: occurrenceCount || 0 branch handles existing block with occurrenceCount=0', async () => { + const blockData = { + status: 'MISMATCH', + fileKey: '/app.js', + expectedHash: 'expected123', + actualHash: 'actual456', + }; + const blockId = await generateBlockId(blockData); + const store = new Map(); + store.set('blocks', { + [blockId]: { + ...blockData, + id: blockId, + occurrenceCount: 0, + timestamp: new Date().toISOString(), + lastSeen: new Date().toISOString(), + }, + }); + store.set('active-block-ids', [blockId]); + const db = { + get: async (key) => store.get(key), + set: async (key, value) => store.set(key, value), + withTx: async (fn) => + fn({ + get: async (key) => store.get(key), + set: async (key, value) => store.set(key, value), + }), + }; + const s = createActiveBlocksStore(db); + const mustBlock = await s.recordSecurityBlock(blockData); + expect(mustBlock).toBe(false); + const blocks = await s.getActiveBlocks(); + expect(blocks[0].occurrenceCount).toBe(1); + }); + }); + + describe('isBlocked error path', () => { + it('returns false when database.get rejects', async () => { + const brokenDb = { + get: async () => { + throw new Error('get failed'); + }, + set: async () => {}, + withTx: async (fn) => + fn({ + get: async () => undefined, + set: async () => {}, + }), + }; + const brokenStore = createActiveBlocksStore(brokenDb); + const result = await brokenStore.isBlocked(); + expect(result).toBe(false); + }); + }); + + describe('getAllBlocks error path', () => { + it('returns empty array when database.get rejects', async () => { + const brokenDb = { + get: async () => { + throw new Error('get failed'); + }, + set: async () => {}, + withTx: async (fn) => + fn({ + get: async () => undefined, + set: async () => {}, + }), + }; + const brokenStore = createActiveBlocksStore(brokenDb); + const result = await brokenStore.getAllBlocks(); + expect(result).toEqual([]); + }); }); }); diff --git a/packages/dappfence/src/sw/__tests__/appsw-hooks.test.js b/packages/dappfence/src/sw/__tests__/appsw-hooks.test.js index f91b5de..8464012 100644 --- a/packages/dappfence/src/sw/__tests__/appsw-hooks.test.js +++ b/packages/dappfence/src/sw/__tests__/appsw-hooks.test.js @@ -1,5 +1,5 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { verifyImportedScript } from '../appsw-hooks.js'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { verifyImportedScript, createHookService } from '../appsw-hooks.js'; import { ASSET_TYPE, VERIFICATION_STATUS } from '../../core/constants.js'; describe('verifyImportedScript', () => { @@ -69,3 +69,184 @@ describe('verifyImportedScript', () => { expect(core.appStore.recordSecurityViolation).not.toHaveBeenCalled(); }); }); + +describe('createHookService (with importScripts)', () => { + let originalImportScripts; + + beforeEach(() => { + originalImportScripts = globalThis.importScripts; + globalThis.importScripts = vi.fn(); + }); + + afterEach(() => { + if (originalImportScripts === undefined) { + delete globalThis.importScripts; + } else { + globalThis.importScripts = originalImportScripts; + } + }); + + it('installHooks patches importScripts when it is defined', () => { + const onVerifyScript = vi.fn().mockResolvedValue(undefined); + const scope = { + addEventListener: vi.fn(), + importScripts: vi.fn(), + }; + const svc = createHookService(onVerifyScript, scope); + svc.installHooks(); + expect(scope.importScripts).not.toBe(globalThis.importScripts); + }); + + it('patched importScripts calls onVerifyScript for each script path', async () => { + const onVerifyScript = vi.fn().mockResolvedValue(undefined); + const originalImportScriptsFn = vi.fn(); + const scope = { + addEventListener: vi.fn(), + importScripts: originalImportScriptsFn, + }; + const svc = createHookService(onVerifyScript, scope); + svc.installHooks(); + + scope.importScripts('/app-sw.js', '/vendor.js'); + + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(onVerifyScript).toHaveBeenCalledWith('/app-sw.js'); + expect(onVerifyScript).toHaveBeenCalledWith('/vendor.js'); + expect(originalImportScriptsFn).toHaveBeenCalledWith('/app-sw.js', '/vendor.js'); + }); + + it('patched importScripts logs warning when called after installEventDone', async () => { + const onVerifyScript = vi.fn().mockResolvedValue(undefined); + const scope = { + addEventListener: vi.fn(), + importScripts: vi.fn(), + }; + const svc = createHookService(onVerifyScript, scope); + svc.installHooks(); + svc.installEventDone(); + + expect(() => scope.importScripts('/after-install.js')).not.toThrow(); + }); + + it('patched importScripts logs error when onVerifyScript rejects', async () => { + const onVerifyScript = vi.fn().mockRejectedValue(new Error('verification failed')); + const scope = { + addEventListener: vi.fn(), + importScripts: vi.fn(), + }; + const svc = createHookService(onVerifyScript, scope); + svc.installHooks(); + + scope.importScripts('/bad-script.js'); + + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(onVerifyScript).toHaveBeenCalledWith('/bad-script.js'); + }); +}); + +describe('createHookService', () => { + let originalAddEventListener; + let onVerifyScript; + let service; + let swScope; + + beforeEach(() => { + originalAddEventListener = vi.fn(); + swScope = { addEventListener: originalAddEventListener }; + onVerifyScript = vi.fn().mockResolvedValue(undefined); + service = createHookService(onVerifyScript, swScope); + }); + + it('installHooks patches swScope.addEventListener', () => { + service.installHooks(); + expect(swScope.addEventListener).not.toBe(originalAddEventListener); + }); + + it('addEventListener throws when called before installHooks', () => { + expect(() => { + service.addEventListener('fetch', vi.fn()); + }).toThrow('[DappFence SW] Error Service Worker hooks not installed'); + }); + + it('addEventListener after installHooks calls the original addEventListener', () => { + service.installHooks(); + const handler = vi.fn(); + service.addEventListener('fetch', handler); + expect(originalAddEventListener).toHaveBeenCalledWith('fetch', expect.any(Function)); + }); + + it('addDefaultEventListeners calls the original addEventListener 15 times', () => { + service.installHooks(); + service.addDefaultEventListeners(); + expect(originalAddEventListener).toHaveBeenCalledTimes(15); + }); + + it('installEventDone is a function and does not throw when called', () => { + expect(typeof service.installEventDone).toBe('function'); + expect(() => service.installEventDone()).not.toThrow(); + }); + + it('a listener appended via patched swScope.addEventListener is invoked when callChildHandlers fires', () => { + service.installHooks(); + + const childListener = vi.fn(); + + swScope.addEventListener('fetch', childListener); + + service.addEventListener('fetch', (event, callChildHandlers) => { + callChildHandlers(event); + }); + + const wrappedHandler = originalAddEventListener.mock.calls.find( + (c) => c[0] === 'fetch' + )?.[1]; + + expect(wrappedHandler).toBeDefined(); + const fakeEvent = { type: 'fetch' }; + wrappedHandler(fakeEvent); + + expect(childListener).toHaveBeenCalledWith(fakeEvent); + }); + + it('callChildHandlers catches errors thrown by a child listener and continues', () => { + service.installHooks(); + + const throwingListener = vi.fn(() => { + throw new Error('listener crashed'); + }); + const safeListener = vi.fn(); + + swScope.addEventListener('fetch', throwingListener); + swScope.addEventListener('fetch', safeListener); + + service.addEventListener('fetch', (_event, callChildHandlers) => { + callChildHandlers(_event); + }); + + const wrappedHandler = originalAddEventListener.mock.calls.find( + (c) => c[0] === 'fetch' + )?.[1]; + expect(wrappedHandler).toBeDefined(); + const fakeEvent = { type: 'fetch' }; + expect(() => wrappedHandler(fakeEvent)).not.toThrow(); + expect(safeListener).toHaveBeenCalled(); + }); + + it('addDefaultEventListeners: handler fires callChildHandlers for each event type', () => { + service.installHooks(); + service.addDefaultEventListeners(); + + const wrappedSyncHandler = originalAddEventListener.mock.calls.find( + (c) => c[0] === 'sync' + )?.[1]; + expect(wrappedSyncHandler).toBeDefined(); + + const childHandler = vi.fn(); + swScope.addEventListener('sync', childHandler); + + const fakeEvent = { type: 'sync' }; + wrappedSyncHandler(fakeEvent); + + expect(childHandler).toHaveBeenCalledWith(fakeEvent); + }); +}); diff --git a/packages/dappfence/src/sw/__tests__/fetch-handler.test.js b/packages/dappfence/src/sw/__tests__/fetch-handler.test.js new file mode 100644 index 0000000..29a9290 --- /dev/null +++ b/packages/dappfence/src/sw/__tests__/fetch-handler.test.js @@ -0,0 +1,420 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createSecurityFetchHandler } from '../fetch-handler.js'; +import { MODE, VERIFICATION_STATUS } from '../../core/constants.js'; + +vi.mock('../response.js', () => ({ + createBlockResponse: vi.fn(() => new Response('blocked', { status: 403 })), +})); + +vi.mock('../../core/utils.js', () => ({ + isFeatureEnabled: vi.fn((flag) => globalThis.__FEATURES__?.[flag] === true), +})); + +import { createBlockResponse } from '../response.js'; + +const ORIGIN = 'https://example.com'; + +function makeRequest(url, { mode = 'no-cors', method = 'GET' } = {}) { + const req = new Request(url, { method }); + Object.defineProperty(req, 'mode', { value: mode, configurable: true }); + return req; +} + +function makeFetchEvent(request, { clientId = 'client-1', resultingClientId = '' } = {}) { + const event = { + request, + clientId, + resultingClientId, + respondWith: vi.fn(), + }; + return event; +} + +function setup({ + mode = MODE.REPORTING, + isBlocked = false, + verifyFileStatus = VERIFICATION_STATUS.SKIPPED, + apiResponse = undefined, + fetchResponse = new Response('ok', { status: 200 }), +} = {}) { + const swContext = { + getLocationOrigin: vi.fn(() => ORIGIN), + getLocationHref: vi.fn(() => `${ORIGIN}/sw.js`), + fetch: vi.fn(() => Promise.resolve(fetchResponse)), + }; + + const manifestService = { + resolveManifest: vi.fn(() => + Promise.resolve({ + mode, + verifyFile: vi.fn(() => Promise.resolve({ status: verifyFileStatus })), + }) + ), + }; + + const appStore = { + activeBlocksStore: { + isBlocked: vi.fn(() => Promise.resolve(isBlocked)), + }, + recordSecurityViolation: vi.fn(() => Promise.resolve(true)), + }; + + const onSecurityViolation = vi.fn(() => Promise.resolve()); + const handleApiEndpoint = vi.fn(() => Promise.resolve(apiResponse)); + + const handler = createSecurityFetchHandler({ + swContext, + manifestService, + onSecurityViolation, + appStore, + handleApiEndpoint, + }); + + return { + handler, + swContext, + manifestService, + appStore, + onSecurityViolation, + handleApiEndpoint, + }; +} + +beforeEach(() => { + globalThis.__FEATURES__ = { mark_request: false }; + vi.clearAllMocks(); +}); + +describe('createSecurityFetchHandler', () => { + describe('API endpoint routing', () => { + it('calls handleApiEndpoint for /sw-api/ paths and returns its response', async () => { + const apiRes = new Response('api ok', { status: 200 }); + const { handler, handleApiEndpoint } = setup({ apiResponse: apiRes }); + const request = makeRequest(`${ORIGIN}/sw-api/status`); + const event = makeFetchEvent(request); + const callChildHandlers = vi.fn(); + + const result = await handler(event, callChildHandlers); + + expect(handleApiEndpoint).toHaveBeenCalledWith('/sw-api/status', request); + expect(result).toBe(apiRes); + }); + + it('continues pipeline when handleApiEndpoint returns undefined', async () => { + const { handler, handleApiEndpoint, swContext } = setup({ apiResponse: undefined }); + const request = makeRequest(`${ORIGIN}/sw-api/probe`); + const event = makeFetchEvent(request); + const callChildHandlers = vi.fn(); + + await handler(event, callChildHandlers); + + expect(handleApiEndpoint).toHaveBeenCalled(); + expect(swContext.fetch).toHaveBeenCalled(); + }); + }); + + describe('block gate', () => { + it('returns block response in PROTECTED mode when site is blocked', async () => { + const { handler } = setup({ mode: MODE.PROTECTED, isBlocked: true }); + const request = makeRequest(`${ORIGIN}/app.js`); + const event = makeFetchEvent(request); + + const result = await handler(event, vi.fn()); + + expect(createBlockResponse).toHaveBeenCalled(); + expect(result.status).toBe(403); + }); + + it('does not block in REPORTING mode even when site is blocked', async () => { + const { handler } = setup({ mode: MODE.REPORTING, isBlocked: true }); + const request = makeRequest(`${ORIGIN}/app.js`); + const event = makeFetchEvent(request); + + await handler(event, vi.fn()); + + expect(createBlockResponse).not.toHaveBeenCalled(); + }); + }); + + describe('mark_request feature', () => { + it('adds x-dappfence header to same-origin subresource when mark_request is enabled', async () => { + globalThis.__FEATURES__ = { mark_request: true }; + const { handler, swContext } = setup({ + fetchResponse: new Response('ok', { status: 200 }), + }); + + const request = makeRequest(`${ORIGIN}/app.js`); + const event = makeFetchEvent(request); + const callChildHandlers = vi.fn(); + + await handler(event, callChildHandlers); + + const fetchedRequest = swContext.fetch.mock.calls[0]?.[0]; + if (fetchedRequest instanceof Request) { + expect(fetchedRequest.headers.get('x-dappfence')).toBe('processed'); + } else { + expect(event.request.headers.get('x-dappfence')).toBe('processed'); + } + }); + + it('does not add x-dappfence header to cross-origin requests', async () => { + globalThis.__FEATURES__ = { mark_request: true }; + const { handler, swContext } = setup({ + fetchResponse: new Response('ok', { status: 200 }), + }); + + const request = makeRequest('https://cdn.example.net/lib.js'); + const event = makeFetchEvent(request); + + await handler(event, vi.fn()); + + const fetchedRequest = swContext.fetch.mock.calls[0]?.[0]; + if (fetchedRequest instanceof Request) { + expect(fetchedRequest.headers.get('x-dappfence')).toBeNull(); + } + }); + + it('skips marking when mark_request feature is disabled', async () => { + globalThis.__FEATURES__ = { mark_request: false }; + const { handler } = setup({ + fetchResponse: new Response('ok', { status: 200 }), + }); + + const request = makeRequest(`${ORIGIN}/app.js`); + const event = makeFetchEvent(request); + + await handler(event, vi.fn()); + + expect(event.request.headers.get('x-dappfence')).toBeNull(); + }); + }); + + describe('app SW delegation', () => { + it('falls back to swContext.fetch when no handler calls respondWith', async () => { + const fetchResp = new Response('from network', { status: 200 }); + const { handler, swContext } = setup({ fetchResponse: fetchResp }); + const request = makeRequest(`${ORIGIN}/app.js`); + const event = makeFetchEvent(request); + const callChildHandlers = vi.fn(); + + const result = await handler(event, callChildHandlers); + + expect(swContext.fetch).toHaveBeenCalled(); + expect(result).toBe(fetchResp); + }); + + it('returns app handler response when respondWith is called', async () => { + const { handler, swContext } = setup({ + fetchResponse: new Response('network', { status: 200 }), + }); + const request = makeRequest(`${ORIGIN}/app.js`); + const event = makeFetchEvent(request); + const appResp = new Response('from app sw', { status: 200 }); + + const callChildHandlers = vi.fn((ev) => { + ev.respondWith(Promise.resolve(appResp)); + }); + + const result = await handler(event, callChildHandlers); + + expect(swContext.fetch).not.toHaveBeenCalled(); + expect(result).toBe(appResp); + }); + + it('falls back to swContext.fetch when respondWith promise rejects', async () => { + const fallback = new Response('fallback', { status: 200 }); + const { handler, swContext } = setup({ fetchResponse: fallback }); + const request = makeRequest(`${ORIGIN}/app.js`); + const event = makeFetchEvent(request); + + const callChildHandlers = vi.fn((ev) => { + ev.respondWith(Promise.reject(new Error('app sw crashed'))); + }); + + const result = await handler(event, callChildHandlers); + + expect(swContext.fetch).toHaveBeenCalled(); + expect(result).toBe(fallback); + }); + }); + + describe('asset verification', () => { + it('passes through response when verification returns MATCH', async () => { + const okResp = new Response('ok', { status: 200 }); + const { handler } = setup({ + verifyFileStatus: VERIFICATION_STATUS.MATCH, + fetchResponse: okResp, + }); + const request = makeRequest(`${ORIGIN}/app.js`); + const event = makeFetchEvent(request); + + const result = await handler(event, vi.fn()); + + expect(result).toBe(okResp); + }); + + it('calls recordSecurityViolation on MISMATCH', async () => { + const { handler, appStore } = setup({ + mode: MODE.REPORTING, + verifyFileStatus: VERIFICATION_STATUS.MISMATCH, + fetchResponse: new Response('ok', { status: 200 }), + }); + const request = makeRequest(`${ORIGIN}/app.js`); + const event = makeFetchEvent(request); + + await handler(event, vi.fn()); + + expect(appStore.recordSecurityViolation).toHaveBeenCalledWith( + expect.objectContaining({ url: `${ORIGIN}/app.js` }) + ); + }); + + it('does not verify when response is non-ok (404)', async () => { + const { handler, appStore } = setup({ + verifyFileStatus: VERIFICATION_STATUS.MISMATCH, + fetchResponse: new Response('not found', { status: 404 }), + }); + const request = makeRequest(`${ORIGIN}/app.js`); + const event = makeFetchEvent(request); + + await handler(event, vi.fn()); + + expect(appStore.recordSecurityViolation).not.toHaveBeenCalled(); + }); + + it('does not verify when response is SKIPPED (isViolation false)', async () => { + const { handler, appStore } = setup({ + verifyFileStatus: VERIFICATION_STATUS.SKIPPED, + fetchResponse: new Response('ok', { status: 200 }), + }); + const request = makeRequest(`${ORIGIN}/app.js`); + const event = makeFetchEvent(request); + + await handler(event, vi.fn()); + + expect(appStore.recordSecurityViolation).not.toHaveBeenCalled(); + }); + }); + + describe('PROTECTED mode violation handling', () => { + it('calls onSecurityViolation and blocks non-navigation on MISMATCH in PROTECTED mode', async () => { + const { handler, onSecurityViolation } = setup({ + mode: MODE.PROTECTED, + verifyFileStatus: VERIFICATION_STATUS.MISMATCH, + fetchResponse: new Response('ok', { status: 200 }), + }); + const request = makeRequest(`${ORIGIN}/app.js`); + const event = makeFetchEvent(request); + + const result = await handler(event, vi.fn()); + + expect(onSecurityViolation).toHaveBeenCalled(); + expect(createBlockResponse).toHaveBeenCalled(); + expect(result.status).toBe(403); + }); + + it('does not call onSecurityViolation for navigate MISMATCH in PROTECTED mode', async () => { + const { handler, onSecurityViolation } = setup({ + mode: MODE.PROTECTED, + verifyFileStatus: VERIFICATION_STATUS.MISMATCH, + fetchResponse: new Response('ok', { status: 200 }), + }); + const request = makeRequest(`${ORIGIN}/page.html`, { mode: 'navigate' }); + const event = makeFetchEvent(request, { clientId: '', resultingClientId: 'client-1' }); + + const result = await handler(event, vi.fn()); + + expect(onSecurityViolation).not.toHaveBeenCalled(); + expect(createBlockResponse).toHaveBeenCalled(); + expect(result.status).toBe(403); + }); + + it('does not call onSecurityViolation or block in REPORTING mode on MISMATCH', async () => { + const { handler, onSecurityViolation } = setup({ + mode: MODE.REPORTING, + verifyFileStatus: VERIFICATION_STATUS.MISMATCH, + fetchResponse: new Response('ok', { status: 200 }), + }); + const request = makeRequest(`${ORIGIN}/app.js`); + const event = makeFetchEvent(request); + + await handler(event, vi.fn()); + + expect(onSecurityViolation).not.toHaveBeenCalled(); + expect(createBlockResponse).not.toHaveBeenCalled(); + }); + }); + + describe('error handling', () => { + it('falls back to swContext.fetch when resolveManifest rejects', async () => { + const fallback = new Response('fallback', { status: 200 }); + const { handler, swContext, manifestService } = setup({ fetchResponse: fallback }); + manifestService.resolveManifest.mockRejectedValue(new Error('manifest failed')); + + const request = makeRequest(`${ORIGIN}/app.js`); + const event = makeFetchEvent(request); + + const result = await handler(event, vi.fn()); + + expect(swContext.fetch).toHaveBeenCalled(); + expect(result).toBe(fallback); + }); + + it('returns undefined when both resolveManifest and fallback fetch fail', async () => { + const { handler, swContext, manifestService } = setup(); + manifestService.resolveManifest.mockRejectedValue(new Error('manifest failed')); + swContext.fetch.mockRejectedValue(new Error('network failed')); + + const request = makeRequest(`${ORIGIN}/app.js`); + const event = makeFetchEvent(request); + + const result = await handler(event, vi.fn()); + + expect(result).toBeUndefined(); + }); + }); + + describe('navigation mark_request', () => { + it('adds x-dappfence header to navigate requests when mark_request is enabled', async () => { + globalThis.__FEATURES__ = { mark_request: true }; + const { handler, swContext } = setup({ + fetchResponse: new Response('ok', { status: 200 }), + }); + + const request = makeRequest(`${ORIGIN}/page.html`, { mode: 'navigate' }); + const event = makeFetchEvent(request, { clientId: '', resultingClientId: 'nav-1' }); + const callChildHandlers = vi.fn(); + + const result = await handler(event, callChildHandlers); + + expect(swContext.fetch).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + }); + + describe('addMarkToRequest error path', () => { + it('falls back to original request when mark_request throws due to bad headers', async () => { + globalThis.__FEATURES__ = { mark_request: true }; + const { handler, swContext } = setup({ + fetchResponse: new Response('ok', { status: 200 }), + }); + + const badRequest = new Proxy(new Request(`${ORIGIN}/app.js`), { + get(target, prop) { + if (prop === 'headers') { + throw new Error('headers inaccessible'); + } + return typeof target[prop] === 'function' + ? target[prop].bind(target) + : target[prop]; + }, + }); + + const event = makeFetchEvent(badRequest); + const callChildHandlers = vi.fn(); + + await expect(handler(event, callChildHandlers)).resolves.not.toThrow(); + expect(swContext.fetch).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/dappfence/src/sw/__tests__/indexeddb.test.js b/packages/dappfence/src/sw/__tests__/indexeddb.test.js new file mode 100644 index 0000000..f6a70b1 --- /dev/null +++ b/packages/dappfence/src/sw/__tests__/indexeddb.test.js @@ -0,0 +1,536 @@ +import { describe, it, expect } from 'vitest'; +import { createDatabase } from '../storage/indexeddb.js'; + +function makeMockIDB() { + const store = new Map(); + + const makeTransaction = () => { + const tx = { + objectStore: () => ({ + get: (key) => { + const req = {}; + Promise.resolve().then(() => { + req.result = store.get(key); + req.onsuccess?.(); + }); + return req; + }, + put: (value, key) => { + const req = {}; + Promise.resolve().then(() => { + store.set(key, value); + req.onsuccess?.(); + }); + return req; + }, + delete: (key) => { + const req = {}; + Promise.resolve().then(() => { + store.delete(key); + req.onsuccess?.(); + }); + return req; + }, + }), + commit: () => {}, + }; + Object.defineProperty(tx, 'oncomplete', { + set(fn) { + Promise.resolve().then(() => fn?.()); + }, + configurable: true, + }); + return tx; + }; + + const makeDb = () => ({ + objectStoreNames: { contains: () => true }, + createObjectStore: () => {}, + transaction: () => makeTransaction(), + }); + + return { + open: () => { + const req = {}; + Promise.resolve().then(() => { + const db = makeDb(); + req.result = db; + req.onupgradeneeded?.({ target: { result: db } }); + req.onsuccess?.(); + }); + return req; + }, + _store: store, + }; +} + +function makeMockIDBWithOpenError() { + return { + open: () => { + const req = {}; + Promise.resolve().then(() => { + req.error = new Error('IDB open failed'); + req.onerror?.(); + }); + return req; + }, + }; +} + +function makeMockIDBWithGetError() { + const makeTransaction = () => { + const tx = { + objectStore: () => ({ + get: () => { + const req = {}; + Promise.resolve().then(() => { + req.error = new Error('get failed'); + req.onerror?.(); + }); + return req; + }, + put: (value, key) => { + const store = new Map(); + const req = {}; + Promise.resolve().then(() => { + store.set(key, value); + req.onsuccess?.(); + }); + return req; + }, + }), + commit: () => {}, + }; + Object.defineProperty(tx, 'oncomplete', { + set(fn) { + Promise.resolve().then(() => fn?.()); + }, + configurable: true, + }); + return tx; + }; + + const makeDb = () => ({ + objectStoreNames: { contains: () => true }, + createObjectStore: () => {}, + transaction: () => makeTransaction(), + }); + + return { + open: () => { + const req = {}; + Promise.resolve().then(() => { + const db = makeDb(); + req.result = db; + req.onupgradeneeded?.({ target: { result: db } }); + req.onsuccess?.(); + }); + return req; + }, + }; +} + +describe('createDatabase', () => { + describe('get', () => { + it('returns stored value for an existing key', async () => { + const idb = makeMockIDB(); + const db = createDatabase(idb); + await db.set('myKey', 'myValue'); + const result = await db.get('myKey'); + expect(result).toBe('myValue'); + }); + + it('returns undefined for a missing key', async () => { + const idb = makeMockIDB(); + const db = createDatabase(idb); + const result = await db.get('nonexistent'); + expect(result).toBeUndefined(); + }); + }); + + describe('set', () => { + it('stores a value and allows retrieval', async () => { + const idb = makeMockIDB(); + const db = createDatabase(idb); + await db.set('k', { some: 'data' }); + const result = await db.get('k'); + expect(result).toEqual({ some: 'data' }); + }); + }); + + describe('delete', () => { + it('removes a key from the store', async () => { + const idb = makeMockIDB(); + const db = createDatabase(idb); + await db.set('toDelete', 'value'); + await db.delete('toDelete'); + const result = await db.get('toDelete'); + expect(result).toBeUndefined(); + }); + }); + + describe('withTx', () => { + it('can read and write in one transaction', async () => { + const idb = makeMockIDB(); + const db = createDatabase(idb); + await db.set('initial', 42); + + await db.withTx(async (tx) => { + const val = await tx.get('initial'); + await tx.set('derived', val * 2); + }); + + const result = await db.get('derived'); + expect(result).toBe(84); + }); + + it('returns the return value of the callback function', async () => { + const idb = makeMockIDB(); + const db = createDatabase(idb); + const result = await db.withTx(async () => 'txResult'); + expect(result).toBe('txResult'); + }); + }); + + describe('createObjectStore', () => { + it('calls createObjectStore when data store does not exist', async () => { + const store = new Map(); + let createObjectStoreCalled = false; + + const makeTransaction = () => { + const tx = { + objectStore: () => ({ + get: (key) => { + const req = {}; + Promise.resolve().then(() => { + req.result = store.get(key); + req.onsuccess?.(); + }); + return req; + }, + put: (value, key) => { + const req = {}; + Promise.resolve().then(() => { + store.set(key, value); + req.onsuccess?.(); + }); + return req; + }, + }), + commit: () => {}, + }; + Object.defineProperty(tx, 'oncomplete', { + set(fn) { + Promise.resolve().then(() => fn?.()); + }, + configurable: true, + }); + return tx; + }; + + const db = { + objectStoreNames: { contains: () => false }, + createObjectStore: () => { + createObjectStoreCalled = true; + }, + transaction: () => makeTransaction(), + }; + + const idb = { + open: () => { + const req = {}; + Promise.resolve().then(() => { + req.result = db; + req.onupgradeneeded?.({ target: { result: db } }); + req.onsuccess?.(); + }); + return req; + }, + }; + + const database = createDatabase(idb); + await database.set('key', 'value'); + expect(createObjectStoreCalled).toBe(true); + }); + }); + + describe('error paths', () => { + it('rejects when idb.open fires onerror', async () => { + const idb = makeMockIDBWithOpenError(); + const db = createDatabase(idb); + await expect(db.get('any')).rejects.toThrow('IDB open failed'); + }); + + it('rejects withTx when store.get fires onerror', async () => { + const idb = makeMockIDBWithGetError(); + const db = createDatabase(idb); + await expect( + db.withTx(async (tx) => { + await tx.get('someKey'); + }) + ).rejects.toThrow('get failed'); + }); + + it('rejects get when store.get fires onerror', async () => { + const makeTransaction = () => { + const tx = { + objectStore: () => ({ + get: () => { + const req = {}; + Promise.resolve().then(() => { + req.error = new Error('get store error'); + req.onerror?.(); + }); + return req; + }, + }), + commit: () => {}, + }; + Object.defineProperty(tx, 'oncomplete', { + set(fn) { + Promise.resolve().then(() => fn?.()); + }, + configurable: true, + }); + return tx; + }; + const makeDb = () => ({ + objectStoreNames: { contains: () => true }, + createObjectStore: () => {}, + transaction: () => makeTransaction(), + }); + const idb = { + open: () => { + const req = {}; + Promise.resolve().then(() => { + const db = makeDb(); + req.result = db; + req.onupgradeneeded?.({ target: { result: db } }); + req.onsuccess?.(); + }); + return req; + }, + }; + const db = createDatabase(idb); + await expect(db.get('key')).rejects.toThrow('get store error'); + }); + + it('rejects set when store.put fires onerror', async () => { + const makeTransaction = () => { + const tx = { + objectStore: () => ({ + put: () => { + const req = {}; + Promise.resolve().then(() => { + req.error = new Error('put error'); + req.onerror?.(); + }); + return req; + }, + }), + commit: () => {}, + }; + Object.defineProperty(tx, 'oncomplete', { + set(fn) { + Promise.resolve().then(() => fn?.()); + }, + configurable: true, + }); + return tx; + }; + const makeDb = () => ({ + objectStoreNames: { contains: () => true }, + createObjectStore: () => {}, + transaction: () => makeTransaction(), + }); + const idb = { + open: () => { + const req = {}; + Promise.resolve().then(() => { + const db = makeDb(); + req.result = db; + req.onupgradeneeded?.({ target: { result: db } }); + req.onsuccess?.(); + }); + return req; + }, + }; + const db = createDatabase(idb); + await expect(db.set('key', 'val')).rejects.toThrow('put error'); + }); + + it('rejects delete when store.delete fires onerror', async () => { + const makeTransaction = () => { + const tx = { + objectStore: () => ({ + delete: () => { + const req = {}; + Promise.resolve().then(() => { + req.error = new Error('delete error'); + req.onerror?.(); + }); + return req; + }, + }), + commit: () => {}, + }; + Object.defineProperty(tx, 'oncomplete', { + set(fn) { + Promise.resolve().then(() => fn?.()); + }, + configurable: true, + }); + return tx; + }; + const makeDb = () => ({ + objectStoreNames: { contains: () => true }, + createObjectStore: () => {}, + transaction: () => makeTransaction(), + }); + const idb = { + open: () => { + const req = {}; + Promise.resolve().then(() => { + const db = makeDb(); + req.result = db; + req.onupgradeneeded?.({ target: { result: db } }); + req.onsuccess?.(); + }); + return req; + }, + }; + const db = createDatabase(idb); + await expect(db.delete('key')).rejects.toThrow('delete error'); + }); + + it('rejects withTx when store.put fires onerror', async () => { + const makeTransaction = () => { + const tx = { + objectStore: () => ({ + get: (_key) => { + const req = {}; + Promise.resolve().then(() => { + req.result = undefined; + req.onsuccess?.(); + }); + return req; + }, + put: () => { + const req = {}; + Promise.resolve().then(() => { + req.error = new Error('tx put error'); + req.onerror?.(); + }); + return req; + }, + }), + commit: () => {}, + }; + Object.defineProperty(tx, 'oncomplete', { + set(fn) { + Promise.resolve().then(() => fn?.()); + }, + configurable: true, + }); + return tx; + }; + const makeDb = () => ({ + objectStoreNames: { contains: () => true }, + createObjectStore: () => {}, + transaction: () => makeTransaction(), + }); + const idb = { + open: () => { + const req = {}; + Promise.resolve().then(() => { + const db = makeDb(); + req.result = db; + req.onupgradeneeded?.({ target: { result: db } }); + req.onsuccess?.(); + }); + return req; + }, + }; + const db = createDatabase(idb); + await expect( + db.withTx(async (tx) => { + await tx.set('key', 'val'); + }) + ).rejects.toThrow('tx put error'); + }); + + it('rejects withTx when transaction fires onerror', async () => { + let triggerTxError; + const makeTransaction = () => { + const tx = { + objectStore: () => ({ + get: (_key) => { + const req = {}; + Promise.resolve().then(() => { + req.result = undefined; + req.onsuccess?.(); + }); + return req; + }, + put: (_value, _key) => { + const req = {}; + Promise.resolve().then(() => { + req.onsuccess?.(); + }); + return req; + }, + }), + commit: () => {}, + get onerror() { + return undefined; + }, + }; + Object.defineProperty(tx, 'oncomplete', { + set() {}, + configurable: true, + }); + Object.defineProperty(tx, 'onerror', { + set(fn) { + triggerTxError = () => { + tx._txErr = new Error('tx error'); + fn?.(); + }; + }, + get() { + return undefined; + }, + configurable: true, + }); + Object.defineProperty(tx, 'error', { + get() { + return tx._txErr; + }, + configurable: true, + }); + return tx; + }; + const makeDb = () => ({ + objectStoreNames: { contains: () => true }, + createObjectStore: () => {}, + transaction: () => makeTransaction(), + }); + const idb = { + open: () => { + const req = {}; + Promise.resolve().then(() => { + const db = makeDb(); + req.result = db; + req.onupgradeneeded?.({ target: { result: db } }); + req.onsuccess?.(); + }); + return req; + }, + }; + const db = createDatabase(idb); + const promise = db.withTx(async () => 'done'); + await new Promise((resolve) => setTimeout(resolve, 10)); + triggerTxError?.(); + await expect(promise).rejects.toThrow('tx error'); + }); + }); +}); diff --git a/packages/dappfence/src/sw/__tests__/lifecycle-handlers.test.js b/packages/dappfence/src/sw/__tests__/lifecycle-handlers.test.js new file mode 100644 index 0000000..64fbff6 --- /dev/null +++ b/packages/dappfence/src/sw/__tests__/lifecycle-handlers.test.js @@ -0,0 +1,193 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createInstallHandler, createActivateHandler } from '../lifecycle-handlers.js'; +import { VERIFICATION_STATUS } from '../../core/constants.js'; + +beforeEach(() => { + globalThis.importScripts = vi.fn(); +}); + +afterEach(() => { + delete globalThis.importScripts; +}); + +function makeInstallDeps({ + skipWaiting = vi.fn(() => Promise.resolve()), + appSW = null, + manifestUrl = 'https://example.com/manifest.json', + fetchAndStoreManifestResult = { + status: VERIFICATION_STATUS.MATCH, + appVersion: 'v1', + manifest: { mode: 'reporting' }, + }, + onInstallDone = vi.fn(), + recordSecurityViolation = vi.fn(() => Promise.resolve()), +} = {}) { + return { + swContext: { skipWaiting }, + config: { appSW, manifestUrl }, + manifestService: { + fetchAndStoreManifest: vi.fn(() => Promise.resolve(fetchAndStoreManifestResult)), + }, + onInstallDone, + appStore: { + recordSecurityViolation, + }, + }; +} + +function makeActivateDeps({ + claimClients = vi.fn(() => Promise.resolve()), + onSecurityViolation = vi.fn(() => Promise.resolve()), + isBlocked = false, +} = {}) { + return { + swContext: { claimClients }, + onSecurityViolation, + appStore: { + activeBlocksStore: { + isBlocked: vi.fn(() => Promise.resolve(isBlocked)), + }, + }, + }; +} + +describe('createInstallHandler', () => { + it('calls skipWaiting', async () => { + const deps = makeInstallDeps(); + const handler = createInstallHandler(deps); + await handler({}, vi.fn()); + expect(deps.swContext.skipWaiting).toHaveBeenCalledTimes(1); + }); + + it('calls fetchAndStoreManifest', async () => { + const deps = makeInstallDeps(); + const handler = createInstallHandler(deps); + await handler({}, vi.fn()); + expect(deps.manifestService.fetchAndStoreManifest).toHaveBeenCalledTimes(1); + }); + + it('records security violation when manifest verification fails', async () => { + const manifestUrl = 'https://example.com/manifest.json'; + const deps = makeInstallDeps({ + manifestUrl, + fetchAndStoreManifestResult: { status: VERIFICATION_STATUS.MISMATCH }, + }); + const handler = createInstallHandler(deps); + await handler({}, vi.fn()); + expect(deps.appStore.recordSecurityViolation).toHaveBeenCalledWith( + expect.objectContaining({ url: manifestUrl }) + ); + }); + + it('does not record security violation on manifest success', async () => { + const deps = makeInstallDeps({ + fetchAndStoreManifestResult: { + status: VERIFICATION_STATUS.MATCH, + appVersion: 'v1', + manifest: { mode: 'reporting' }, + }, + }); + const handler = createInstallHandler(deps); + await handler({}, vi.fn()); + expect(deps.appStore.recordSecurityViolation).not.toHaveBeenCalled(); + }); + + it('calls importScripts when config.appSW is set', async () => { + const deps = makeInstallDeps({ appSW: 'https://example.com/app-sw.js' }); + const handler = createInstallHandler(deps); + await handler({}, vi.fn()); + expect(globalThis.importScripts).toHaveBeenCalledWith('https://example.com/app-sw.js'); + }); + + it('does not call importScripts when config.appSW is not set', async () => { + const deps = makeInstallDeps({ appSW: null }); + const handler = createInstallHandler(deps); + await handler({}, vi.fn()); + expect(globalThis.importScripts).not.toHaveBeenCalled(); + }); + + it('still resolves without throwing when importScripts throws', async () => { + globalThis.importScripts = vi.fn(() => { + throw new Error('importScripts failed'); + }); + const deps = makeInstallDeps({ appSW: 'https://example.com/app-sw.js' }); + const handler = createInstallHandler(deps); + await expect(handler({}, vi.fn())).resolves.toBeUndefined(); + }); + + it('calls onInstallDone even when importScripts throws', async () => { + globalThis.importScripts = vi.fn(() => { + throw new Error('importScripts failed'); + }); + const deps = makeInstallDeps({ appSW: 'https://example.com/app-sw.js' }); + const handler = createInstallHandler(deps); + await handler({}, vi.fn()); + expect(deps.onInstallDone).toHaveBeenCalledTimes(1); + }); + + it('calls callChildHandlers', async () => { + const deps = makeInstallDeps(); + const handler = createInstallHandler(deps); + const event = {}; + const callChildHandlers = vi.fn(); + await handler(event, callChildHandlers); + expect(callChildHandlers).toHaveBeenCalledWith(event); + }); + + it('calls onInstallDone', async () => { + const deps = makeInstallDeps(); + const handler = createInstallHandler(deps); + await handler({}, vi.fn()); + expect(deps.onInstallDone).toHaveBeenCalledTimes(1); + }); +}); + +describe('createActivateHandler', () => { + it('calls claimClients', async () => { + const deps = makeActivateDeps(); + const handler = createActivateHandler(deps); + await handler({}, vi.fn()); + expect(deps.swContext.claimClients).toHaveBeenCalledTimes(1); + }); + + it('calls onSecurityViolation when site is blocked', async () => { + const deps = makeActivateDeps({ isBlocked: true }); + const handler = createActivateHandler(deps); + await handler({}, vi.fn()); + expect(deps.onSecurityViolation).toHaveBeenCalledTimes(1); + }); + + it('does not call onSecurityViolation when site is not blocked', async () => { + const deps = makeActivateDeps({ isBlocked: false }); + const handler = createActivateHandler(deps); + await handler({}, vi.fn()); + expect(deps.onSecurityViolation).not.toHaveBeenCalled(); + }); + + it('calls callChildHandlers', async () => { + const deps = makeActivateDeps(); + const handler = createActivateHandler(deps); + const event = {}; + const callChildHandlers = vi.fn(); + await handler(event, callChildHandlers); + expect(callChildHandlers).toHaveBeenCalledWith(event); + }); + + it('still resolves without throwing when claimClients rejects', async () => { + const deps = makeActivateDeps({ + claimClients: vi.fn(() => Promise.reject(new Error('claim failed'))), + }); + const handler = createActivateHandler(deps); + await expect(handler({}, vi.fn())).resolves.toBeUndefined(); + }); +}); + +describe('createInstallHandler skipWaiting rejection', () => { + it('still resolves without throwing when skipWaiting rejects', async () => { + const deps = makeInstallDeps({ + skipWaiting: vi.fn(() => Promise.reject(new Error('skipWaiting failed'))), + }); + const handler = createInstallHandler(deps); + await expect(handler({}, vi.fn())).resolves.toBeUndefined(); + }); +}); diff --git a/packages/dappfence/src/sw/__tests__/manifest-service.test.js b/packages/dappfence/src/sw/__tests__/manifest-service.test.js index 1c4a6ba..ca1a02f 100644 --- a/packages/dappfence/src/sw/__tests__/manifest-service.test.js +++ b/packages/dappfence/src/sw/__tests__/manifest-service.test.js @@ -1,11 +1,31 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { createManifestService } from '../manifest/manifest-service.js'; -import { calculateHash } from '../../core/crypto.js'; -import { VERIFICATION_STATUS } from '../../core/constants.js'; +import { calculateHash, ethereumAddress } from '../../core/crypto.js'; +import { VERIFICATION_STATUS, MODE } from '../../core/constants.js'; +import { sign, etc, hashes } from '@noble/secp256k1'; +import { keccak_256 } from '@noble/hashes/sha3.js'; +import { sha256 } from '@noble/hashes/sha2.js'; +import { hmac } from '@noble/hashes/hmac.js'; +import { recoverPublicKey } from '@noble/secp256k1'; const baseHref = 'https://app.example.com/sw.js'; -const setup = ({ fetch, manifestEntry } = {}) => { +const testPrivKey = new Uint8Array(32).fill(0); +testPrivKey[31] = 77; + +function makeSignedManifest(payload) { + hashes.hmacSha256 = (key, msg) => hmac(sha256, key, msg); + hashes.sha256 = sha256; + const payloadBytes = new TextEncoder().encode(JSON.stringify(payload, null, 2)); + const msgHash = keccak_256(payloadBytes); + const sigBytes = sign(msgHash, testPrivKey, { prehash: false, format: 'recovered' }); + const sigHex = etc.bytesToHex(sigBytes); + const pubKey = recoverPublicKey(sigBytes, msgHash, { prehash: false }); + const address = ethereumAddress(pubKey); + return { sig: sigHex, address }; +} + +const setup = ({ fetch, manifestEntry, config } = {}) => { const swContext = { fetch: fetch ?? vi.fn(), getLocationHref: vi.fn().mockReturnValue(baseHref), @@ -20,9 +40,9 @@ const setup = ({ fetch, manifestEntry } = {}) => { const manifestService = createManifestService({ swContext, appStore, - config: {}, + config: config ?? {}, }); - return { swContext, appStore, manifestService }; + return { swContext, appStore, manifestService, trustedManifestStore }; }; describe('manifestService.verifyLocation', () => { @@ -33,6 +53,17 @@ describe('manifestService.verifyLocation', () => { delete globalThis.__FEATURES__; }); + it('line 100: does not add sw-verification header when mark_request is disabled', async () => { + globalThis.__FEATURES__ = { mark_request: false }; + const { manifestService, swContext } = setup({ + fetch: vi.fn().mockResolvedValue({ ok: false, status: 500, statusText: 'Error' }), + }); + + await manifestService.verifyLocation('/lib.js'); + + expect(swContext.fetch).toHaveBeenCalledWith('/lib.js', {}); + }); + it('marks the fetch with the sw-verification header when mark_request is enabled', async () => { const { manifestService, swContext } = setup({ fetch: vi.fn().mockResolvedValue({ ok: false, status: 500, statusText: 'Error' }), @@ -110,3 +141,390 @@ describe('manifestService.verifyLocation', () => { expect(result.status).toBe(VERIFICATION_STATUS.CONFIG_ERROR); }); }); + +describe('resolveManifest', () => { + beforeEach(() => { + globalThis.__FEATURES__ = {}; + }); + afterEach(() => { + delete globalThis.__FEATURES__; + }); + + it('cached path: returns mode and verifyFile when getLatest returns a manifest', async () => { + const { manifestService, trustedManifestStore } = setup(); + trustedManifestStore.getLatest.mockResolvedValue({ + appVersion: 'v1', + manifest: { mode: MODE.REPORTING, files: { '/app.js': 'hash1' }, metadata: {} }, + }); + + const ctx = await manifestService.resolveManifest(); + + expect(ctx.mode).toBe(MODE.REPORTING); + expect(typeof ctx.verifyFile).toBe('function'); + }); + + it('mode falls back to REPORTING when manifest has no mode field', async () => { + const { manifestService, trustedManifestStore } = setup(); + trustedManifestStore.getLatest.mockResolvedValue({ + appVersion: 'v1', + manifest: { files: {}, metadata: {} }, + }); + + const ctx = await manifestService.resolveManifest(); + + expect(ctx.mode).toBe(MODE.REPORTING); + expect(typeof ctx.verifyFile).toBe('function'); + }); + + it('verifyFile skips non-matching asset extensions', async () => { + const { manifestService, trustedManifestStore } = setup(); + trustedManifestStore.getLatest.mockResolvedValue({ + appVersion: 'v1', + manifest: { mode: MODE.REPORTING, files: {}, metadata: {} }, + }); + + const ctx = await manifestService.resolveManifest(); + const fakeResponse = new Response('', { + status: 200, + headers: { 'content-type': 'text/plain' }, + }); + const result = ctx.verifyFile('/app.txt', fakeResponse); + + expect(result).toEqual({ status: VERIFICATION_STATUS.SKIPPED, fileKey: '/app.txt' }); + }); + + it('loadManifestFromUrl succeeds with a valid signed manifest', async () => { + const payload = { files: { '/app.js': 'sha256-abc' }, mode: 'reporting' }; + const { sig, address } = makeSignedManifest(payload); + + const mockManifest = { pay: payload, sig }; + const storedManifest = { + appVersion: 'v-signed', + manifest: { files: { '/app.js': 'sha256-abc' }, mode: 'reporting' }, + }; + + const { manifestService, appStore } = setup({ + config: { + manifestUrl: '/manifest.json', + manifestSignatureType: 'noble-secp256k1-recovered-eth', + manifestSignatureIdentity: address, + }, + fetch: vi.fn().mockResolvedValue({ + ok: true, + json: async () => mockManifest, + }), + }); + + appStore.trustedManifestStore.addLatest = vi.fn().mockResolvedValue(storedManifest); + + const result = await manifestService.fetchAndStoreManifest(); + + expect(result.status).toBe(VERIFICATION_STATUS.MATCH); + expect(result.appVersion).toBe('v-signed'); + }); + + it('loadManifestFromUrl returns MISMATCH when signature verification fails', async () => { + const payload = { files: {}, mode: 'reporting' }; + const { sig } = makeSignedManifest(payload); + + const mockManifest = { pay: payload, sig }; + + const { manifestService } = setup({ + config: { + manifestUrl: '/manifest.json', + manifestSignatureType: 'noble-secp256k1-recovered-eth', + manifestSignatureIdentity: '0xwrongaddress', + }, + fetch: vi.fn().mockResolvedValue({ + ok: true, + json: async () => mockManifest, + }), + }); + + const result = await manifestService.fetchAndStoreManifest(); + + expect(result.status).toBe(VERIFICATION_STATUS.MISMATCH); + expect(result.assetType).toBe('manifest'); + }); + + it('loadManifestFromUrl returns ERROR when response.json() throws', async () => { + const { manifestService } = setup({ + config: { + manifestUrl: '/manifest.json', + manifestSignatureType: 'noble-secp256k1-recovered-eth', + manifestSignatureIdentity: '0xABC', + }, + fetch: vi.fn().mockResolvedValue({ + ok: true, + json: async () => { + throw new Error('invalid json'); + }, + }), + }); + + const result = await manifestService.fetchAndStoreManifest(); + + expect(result.status).toBe(VERIFICATION_STATUS.ERROR); + }); + + it('loadManifestFromUrl returns ERROR on non-ok fetch response', async () => { + const { manifestService } = setup({ + config: { + manifestUrl: '/manifest.json', + manifestSignatureType: 'noble-secp256k1-recovered-eth', + manifestSignatureIdentity: '0xABC', + }, + fetch: vi.fn().mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Server Error', + }), + }); + + const result = await manifestService.fetchAndStoreManifest(); + + expect(result.status).toBe(VERIFICATION_STATUS.ERROR); + expect(result.fileKey).toBe('/manifest.json'); + }); + + it('cold-start path: getLatest returns null, fetchAndStoreManifest returns valid manifest', async () => { + const payload = { files: { '/app.js': 'sha256-abc' }, mode: 'reporting' }; + const { sig, address } = makeSignedManifest(payload); + const mockManifest = { pay: payload, sig }; + const storedManifest = { + appVersion: 'cold-v1', + manifest: { files: { '/app.js': 'sha256-abc' }, mode: 'reporting' }, + }; + + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => mockManifest, + }); + + const { manifestService, appStore, trustedManifestStore } = setup({ + config: { + manifestUrl: '/manifest.json', + manifestSignatureType: 'noble-secp256k1-recovered-eth', + manifestSignatureIdentity: address, + }, + fetch: fetchMock, + }); + trustedManifestStore.getLatest.mockResolvedValue(null); + appStore.trustedManifestStore.addLatest = vi.fn().mockResolvedValue(storedManifest); + + const ctx = await manifestService.resolveManifest(); + + expect(ctx.mode).toBe('reporting'); + expect(typeof ctx.verifyFile).toBe('function'); + }); + + it('verifyResponse cold-start path: sets manifestInfo from fetchAndStoreManifest', async () => { + const payload = { files: { '/app.js': 'sha256-abc' }, mode: 'reporting' }; + const { sig, address } = makeSignedManifest(payload); + const mockManifest = { pay: payload, sig }; + const storedManifest = { + appVersion: 'cold-v2', + manifest: { files: { '/app.js': 'sha256-abc' }, mode: 'reporting' }, + }; + + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => mockManifest, + }); + + const { manifestService, appStore, trustedManifestStore } = setup({ + config: { + manifestUrl: '/manifest.json', + manifestSignatureType: 'noble-secp256k1-recovered-eth', + manifestSignatureIdentity: address, + }, + fetch: fetchMock, + }); + + trustedManifestStore.getLatest.mockResolvedValue({ + appVersion: 'v-cache', + manifest: { mode: MODE.REPORTING, files: {}, metadata: {} }, + }); + trustedManifestStore.findByHash.mockResolvedValue(null); + appStore.trustedManifestStore.addLatest = vi.fn().mockResolvedValue(storedManifest); + + const ctx = await manifestService.resolveManifest({ clientId: 'test-client' }); + const body = new TextEncoder().encode('content').buffer; + const jsResponse = new Response(body, { + status: 200, + headers: { 'content-type': 'text/javascript' }, + }); + const result = await ctx.verifyFile('/app.js', jsResponse); + expect(result).toBeDefined(); + }); + + it('verifyFile calls verifyResponse for a .js asset (line 220 path)', async () => { + const body = new TextEncoder().encode('console.log("app")').buffer; + const fileHash = await calculateHash(body); + const { manifestService, trustedManifestStore } = setup({ + fetch: vi.fn().mockResolvedValue( + new Response(body, { + status: 200, + headers: { 'content-type': 'text/javascript' }, + }) + ), + manifestEntry: { + appVersion: 'v1', + manifest: { files: { '/app.js': fileHash } }, + }, + }); + trustedManifestStore.getLatest.mockResolvedValue({ + appVersion: 'v1', + manifest: { mode: MODE.REPORTING, files: { '/app.js': fileHash }, metadata: {} }, + }); + trustedManifestStore.findByHash.mockResolvedValue({ + appVersion: 'v1', + manifest: { files: { '/app.js': fileHash } }, + }); + + const ctx = await manifestService.resolveManifest({ clientId: 'client-1' }); + const jsResponse = new Response(body, { + status: 200, + headers: { 'content-type': 'text/javascript' }, + }); + const result = await ctx.verifyFile('/app.js', jsResponse); + + expect(result.status).toBe(VERIFICATION_STATUS.MATCH); + }); + + it('line 138: clientId + !isNavigation path uses pinned manifest on second call', async () => { + const body = new TextEncoder().encode('console.log("pinned")').buffer; + const fileHash = await calculateHash(body); + const manifestEntry = { + appVersion: 'v-pinned', + manifest: { files: { '/app.js': fileHash } }, + }; + const { manifestService, trustedManifestStore, appStore } = setup({ + manifestEntry, + }); + trustedManifestStore.getLatest.mockResolvedValue({ + appVersion: 'v-pinned', + manifest: { mode: MODE.REPORTING, files: { '/app.js': fileHash }, metadata: {} }, + }); + trustedManifestStore.findByHash.mockResolvedValue(manifestEntry); + + const ctx = await manifestService.resolveManifest({ + clientId: 'client-pin', + isNavigation: false, + }); + const jsResponse = () => + new Response(body, { status: 200, headers: { 'content-type': 'text/javascript' } }); + + const result1 = await ctx.verifyFile('/app.js', jsResponse()); + expect(result1.status).toBe(VERIFICATION_STATUS.MATCH); + + trustedManifestStore.findByHash.mockClear(); + const result2 = await ctx.verifyFile('/app.js', jsResponse()); + expect(result2.status).toBe(VERIFICATION_STATUS.MATCH); + expect(appStore.verificationResultsStore.add).toHaveBeenCalled(); + }); + + it('lines 155-166: verifyResponse isNavigation=true path covers navigation log branch', async () => { + const body = new TextEncoder().encode('').buffer; + const fileHash = await calculateHash(body); + const manifestEntry = { + appVersion: 'v-nav', + manifest: { files: { '/index.html': fileHash } }, + }; + const { manifestService, trustedManifestStore } = setup({ + manifestEntry, + }); + trustedManifestStore.getLatest.mockResolvedValue({ + appVersion: 'v-nav', + manifest: { mode: MODE.REPORTING, files: { '/index.html': fileHash }, metadata: {} }, + }); + trustedManifestStore.findByHash.mockResolvedValue(manifestEntry); + + const ctx = await manifestService.resolveManifest({ + clientId: 'nav-client', + isNavigation: true, + }); + const htmlResponse = new Response(body, { + status: 200, + headers: { 'content-type': 'text/html' }, + }); + + const result = await ctx.verifyFile('/', htmlResponse); + expect(result).toBeDefined(); + }); + + it('line 155-166: clientId pin is stored after findByHash resolves manifest', async () => { + const body = new TextEncoder().encode('console.log("store-pin")').buffer; + const fileHash = await calculateHash(body); + const manifestEntry = { + appVersion: 'v-store-pin', + manifest: { files: { '/app.js': fileHash } }, + }; + const { manifestService, trustedManifestStore } = setup({ + manifestEntry, + }); + trustedManifestStore.getLatest.mockResolvedValue({ + appVersion: 'v-store-pin', + manifest: { mode: MODE.REPORTING, files: { '/app.js': fileHash }, metadata: {} }, + }); + trustedManifestStore.findByHash.mockResolvedValue(manifestEntry); + + const ctx = await manifestService.resolveManifest({ + clientId: 'client-store', + isNavigation: false, + }); + const jsResponse = () => + new Response(body, { status: 200, headers: { 'content-type': 'text/javascript' } }); + + await ctx.verifyFile('/app.js', jsResponse()); + const callsAfterFirst = trustedManifestStore.findByHash.mock.calls.length; + + await ctx.verifyFile('/app.js', jsResponse()); + expect(trustedManifestStore.findByHash.mock.calls.length).toBe(callsAfterFirst); + }); + + it('line 166: verifyResponse with cross-origin fileKey covers globe icon branch', async () => { + const body = new TextEncoder().encode('console.log("cdn")').buffer; + const fileHash = await calculateHash(body); + const externalUrl = 'https://cdn.example.com/lib.js'; + const manifestEntry = { + appVersion: 'v-cdn', + manifest: { files: { [externalUrl]: fileHash } }, + }; + const { manifestService, trustedManifestStore } = setup({ + manifestEntry, + }); + trustedManifestStore.getLatest.mockResolvedValue({ + appVersion: 'v-cdn', + manifest: { + mode: MODE.REPORTING, + files: { [externalUrl]: fileHash }, + metadata: { extensions: ['.js'], contentTypes: [] }, + }, + }); + trustedManifestStore.findByHash.mockResolvedValue(manifestEntry); + + const ctx = await manifestService.resolveManifest({}); + const jsResponse = new Response(body, { + status: 200, + headers: { 'content-type': 'text/javascript' }, + }); + + const result = await ctx.verifyFile(externalUrl, jsResponse); + expect(result.status).toBe(VERIFICATION_STATUS.MATCH); + }); + + it('line 204: mode falls back to PROTECTED when feature flag is set and manifest has no mode', async () => { + globalThis.__FEATURES__ = { 'default-to-protected-mode': true }; + const { manifestService, trustedManifestStore } = setup({ config: {} }); + trustedManifestStore.getLatest.mockResolvedValue({ + appVersion: 'v-nomode', + manifest: { files: {} }, + }); + + const ctx = await manifestService.resolveManifest(); + + expect(ctx.mode).toBe(MODE.PROTECTED); + delete globalThis.__FEATURES__; + }); +}); diff --git a/packages/dappfence/src/sw/__tests__/manifest-store.test.js b/packages/dappfence/src/sw/__tests__/manifest-store.test.js index 4ad0812..02e1c1f 100644 --- a/packages/dappfence/src/sw/__tests__/manifest-store.test.js +++ b/packages/dappfence/src/sw/__tests__/manifest-store.test.js @@ -176,6 +176,16 @@ describe('createManifestStore', () => { const reopened = createManifestStore(sameBackend); expect(await reopened.trustedManifestStore.findByHash('hash-a')).toEqual(persisted); }); + + it('line 69: findByHash does not throw when manifest.files is undefined', async () => { + const db = createInMemoryStorage(); + await db.set('trusted-manifest', [ + { appVersion: 'v-no-files', manifest: { mode: 'reporting' } }, + ]); + const store = createManifestStore(db); + const found = await store.trustedManifestStore.findByHash('nonexistent-hash'); + expect(found).toBeNull(); + }); }); describe('verificationResults', () => { diff --git a/packages/dappfence/src/sw/__tests__/message-broker.test.js b/packages/dappfence/src/sw/__tests__/message-broker.test.js index fcfe7b2..f4e9d6e 100644 --- a/packages/dappfence/src/sw/__tests__/message-broker.test.js +++ b/packages/dappfence/src/sw/__tests__/message-broker.test.js @@ -82,6 +82,31 @@ describe('createMessageBroker', () => { swContext.getClient = async () => undefined; await expect(broker.handleClientReady('c1')).resolves.not.toThrow(); }); + + it('handles getClient rejecting without throwing', async () => { + swContext._addClient('c1'); + await broker.broadcastSecurityViolation(); + + swContext.getClient = async () => { + throw new Error('clients.get() failed'); + }; + await expect(broker.handleClientReady('c1')).resolves.not.toThrow(); + }); + }); + + describe('broadcastSecurityViolation error paths', () => { + it('handles matchAllClients rejecting without throwing', async () => { + swContext.matchAllClients = async () => { + throw new Error('matchAllClients failed'); + }; + await expect(broker.broadcastSecurityViolation()).resolves.not.toThrow(); + }); + + it('handles client.postMessage rejecting without throwing', async () => { + const client = swContext._addClient('c1'); + client.postMessage.mockRejectedValue(new Error('postMessage failed')); + await expect(broker.broadcastSecurityViolation()).resolves.not.toThrow(); + }); }); }); diff --git a/packages/dappfence/src/sw/__tests__/operations.test.js b/packages/dappfence/src/sw/__tests__/operations.test.js index 97055e6..ec2f98f 100644 --- a/packages/dappfence/src/sw/__tests__/operations.test.js +++ b/packages/dappfence/src/sw/__tests__/operations.test.js @@ -1,12 +1,23 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeAll } from 'vitest'; import { verifyFilePath, normalizeManifestData, getFileKey, verifyManifestSignature, + shouldVerifyAsset, } from '../manifest/operations.js'; import { createSingleFlight } from '../../core/utils.js'; import { VERIFICATION_STATUS } from '../../core/constants.js'; +import { sign, etc, hashes, recoverPublicKey } from '@noble/secp256k1'; +import { keccak_256 } from '@noble/hashes/sha3.js'; +import { sha256 } from '@noble/hashes/sha2.js'; +import { hmac } from '@noble/hashes/hmac.js'; +import { ethereumAddress } from '../../core/crypto.js'; + +beforeAll(() => { + hashes.hmacSha256 = (key, msg) => hmac(sha256, key, msg); + hashes.sha256 = sha256; +}); describe('verifyFilePath', () => { const manifest = { @@ -67,6 +78,12 @@ describe('verifyFilePath', () => { const result = verifyFilePath(manifest, '/', 'idx111', false); expect(result.status).toBe(VERIFICATION_STATUS.NOT_FOUND_IN_MANIFEST); }); + + it('line 36: navigation with no index.html in manifest returns NOT_FOUND_IN_MANIFEST', () => { + const noIndexManifest = { files: { '/app.js': 'abc123' } }; + const result = verifyFilePath(noIndexManifest, '/missing-dir', 'somehash', true); + expect(result.status).toBe(VERIFICATION_STATUS.NOT_FOUND_IN_MANIFEST); + }); }); describe('normalizeManifestData', () => { @@ -119,6 +136,13 @@ describe('normalizeManifestData', () => { expect(result.files['/ok.js']).toBe('hash'); }); + it('line 112: legacy flat format with object entry with null hash is skipped', () => { + const input = { '/app.js': { hash: null }, '/ok.js': { hash: 'sha256-abc' } }; + const result = normalizeManifestData(input); + expect(result.files['/app.js']).toBeUndefined(); + expect(result.files['/ok.js']).toBe('sha256-abc'); + }); + it('preserves top-level fields (mode, metadata, future fields) in enhanced format', () => { const input = { files: { '/app.js': 'abc' }, @@ -159,13 +183,134 @@ describe('getFileKey', () => { it('returns absolute URL as-is on parse failure', () => { expect(getFileKey('https://cdn.com/lib.js', 'bad-base')).toBe('https://cdn.com/lib.js'); }); + + it('line 142: catch fallback prepends / for bare relative URL with invalid base', () => { + expect(getFileKey('app.js', 'not-a-valid-url')).toBe('/app.js'); + }); + + it('line 142: catch fallback returns url as-is when it already starts with /', () => { + expect(getFileKey('/already-absolute', 'not-a-valid-url')).toBe('/already-absolute'); + }); +}); + +describe('shouldVerifyAsset', () => { + it('returns true for navigation requests', () => { + const resp = new Response('', { headers: { 'content-type': 'text/plain' } }); + expect(shouldVerifyAsset('/some/path', true, resp, ['.js'], [])).toBe(true); + }); + + it('returns true when extension matches', () => { + const resp = new Response('', { headers: { 'content-type': 'text/plain' } }); + expect(shouldVerifyAsset('/app.js', false, resp, ['.js'], [])).toBe(true); + }); + + it('line 179: returns true when content-type matches and no extension match', () => { + const resp = new Response('', { headers: { 'content-type': 'application/javascript' } }); + expect(shouldVerifyAsset('/api/bundle', false, resp, [], ['application/javascript'])).toBe( + true + ); + }); + + it('line 186: returns false when no extension match and no content-type match', () => { + const resp = new Response('', { headers: { 'content-type': 'text/plain' } }); + expect( + shouldVerifyAsset('/api/data', false, resp, ['.js'], ['application/javascript']) + ).toBe(false); + }); + + it('returns false when response has no content-type header and no extension match', () => { + const resp = new Response(''); + expect(shouldVerifyAsset('/no-ext', false, resp, ['.js'], ['application/javascript'])).toBe( + false + ); + }); + + it('line 179: uses empty string when response is null (no content-type available)', () => { + expect(shouldVerifyAsset('/no-ext', false, null, ['.js'], ['application/javascript'])).toBe( + false + ); + }); + + it('line 186: mime is empty (none) when response has no content-type header', () => { + const resp = { headers: { get: () => null } }; + expect(shouldVerifyAsset('/no-ext', false, resp, ['.js'], ['application/javascript'])).toBe( + false + ); + }); }); describe('verifyManifestSignature', () => { + const privKey = new Uint8Array(32).fill(0); + privKey[31] = 42; + + const payload = { files: { '/app.js': 'sha256-abc' }, mode: 'reporting' }; + const payloadBytes = new TextEncoder().encode(JSON.stringify(payload, null, 2)); + it('returns UNSUPPORTED_SIGNATURE for unknown signature types', () => { const result = verifyManifestSignature('unknown-type', '0xABC', { pay: {}, sig: 'sig' }); expect(result.status).toBe(VERIFICATION_STATUS.UNSUPPORTED_SIGNATURE); }); + + it('noble-secp256k1-recovered-eth MATCH: recovered address equals expected', () => { + const msgHash = keccak_256(payloadBytes); + const sigBytes = sign(msgHash, privKey, { prehash: false, format: 'recovered' }); + const sigHex = etc.bytesToHex(sigBytes); + const pubKey = recoverPublicKey(sigBytes, msgHash, { prehash: false }); + const expectedAddress = ethereumAddress(pubKey); + + const result = verifyManifestSignature('noble-secp256k1-recovered-eth', expectedAddress, { + pay: payload, + sig: sigHex, + }); + + expect(result.status).toBe(VERIFICATION_STATUS.MATCH); + expect(result.payload).toEqual(payload); + }); + + it('noble-secp256k1-recovered-eth MISMATCH: wrong expected address returns MISMATCH', () => { + const msgHash = keccak_256(payloadBytes); + const sigBytes = sign(msgHash, privKey, { prehash: false, format: 'recovered' }); + const sigHex = etc.bytesToHex(sigBytes); + + const result = verifyManifestSignature( + 'noble-secp256k1-recovered-eth', + '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef', + { pay: payload, sig: sigHex } + ); + + expect(result.status).toBe(VERIFICATION_STATUS.MISMATCH); + expect(result.expectedHash).toBe('0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'); + expect(result.actualHash).toBeDefined(); + }); + + it('noble-secp256k1-recovered-eth ERROR: malformed sig hex throws and returns ERROR', () => { + const result = verifyManifestSignature('noble-secp256k1-recovered-eth', '0xABC', { + pay: payload, + sig: 'not-valid-hex!!!', + }); + + expect(result.status).toBe(VERIFICATION_STATUS.ERROR); + }); + + it('personal-sign-alt MATCH: recovered address equals expected', () => { + const msgHash = keccak_256(payloadBytes); + const prefix = '\x19Ethereum Signed Message:\n'; + const prefixAndLen = new TextEncoder().encode(prefix + msgHash.length); + const messageHash = keccak_256(etc.concatBytes(prefixAndLen, msgHash)); + + const sigBytes = sign(messageHash, privKey, { prehash: false, format: 'recovered' }); + const sigHex = etc.bytesToHex(sigBytes); + const pubKey = recoverPublicKey(sigBytes, messageHash, { prehash: false }); + const expectedAddress = ethereumAddress(pubKey); + + const result = verifyManifestSignature('personal-sign-alt', expectedAddress, { + pay: payload, + sig: sigHex, + }); + + expect(result.status).toBe(VERIFICATION_STATUS.MATCH); + expect(result.payload).toEqual(payload); + }); }); describe('createSingleFlight', () => { diff --git a/packages/dappfence/src/sw/__tests__/security-events-store.test.js b/packages/dappfence/src/sw/__tests__/security-events-store.test.js index 072ac34..ffae5ea 100644 --- a/packages/dappfence/src/sw/__tests__/security-events-store.test.js +++ b/packages/dappfence/src/sw/__tests__/security-events-store.test.js @@ -77,5 +77,17 @@ describe('createSecurityEventsStore', () => { const all = await s.getSecurityEvents(2000); expect(all.length).toBeLessThanOrEqual(1000); }); + + it('returns empty array when database.get rejects', async () => { + const brokenDb = { + get: async () => { + throw new Error('db error'); + }, + set: async () => {}, + }; + const s = createSecurityEventsStore(brokenDb); + const result = await s.getSecurityEvents(); + expect(result).toEqual([]); + }); }); }); diff --git a/packages/dappfence/src/sw/__tests__/services.test.js b/packages/dappfence/src/sw/__tests__/services.test.js new file mode 100644 index 0000000..94b0166 --- /dev/null +++ b/packages/dappfence/src/sw/__tests__/services.test.js @@ -0,0 +1,113 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +vi.mock('../response.js', () => ({ + createBlockResponse: vi.fn(() => new Response('blocked', { status: 403 })), + createSecurityWarningPage: vi.fn(() => new Response('warning', { status: 200 })), +})); + +vi.mock('../../core/utils.js', () => ({ + isFeatureEnabled: vi.fn((flag) => globalThis.__FEATURES__?.[flag] === true), + createSingleFlight: vi.fn(() => { + let pending = null; + return (fn) => { + if (!pending) { + pending = fn().finally(() => (pending = null)); + } + return pending; + }; + }), + hasConfigManifest: vi.fn(() => false), +})); + +import { createServices } from '../services.js'; + +function makeSwGlobal() { + return { + location: { + href: 'https://app.example.com/sw.js', + origin: 'https://app.example.com', + search: '', + toString() { + return this.href; + }, + }, + clients: { + matchAll: vi.fn().mockResolvedValue([]), + get: vi.fn().mockResolvedValue(null), + claim: vi.fn().mockResolvedValue(undefined), + }, + skipWaiting: vi.fn().mockResolvedValue(undefined), + navigator: { userAgent: 'test-agent' }, + fetch: vi.fn(), + indexedDB: { + open: vi.fn().mockReturnValue({ + onupgradeneeded: null, + onsuccess: null, + onerror: null, + }), + }, + addEventListener: vi.fn(), + importScripts: vi.fn(), + }; +} + +describe('createServices', () => { + beforeEach(() => { + globalThis.__FEATURES__ = {}; + if (typeof globalThis.self === 'undefined') { + globalThis.self = globalThis; + } + }); + afterEach(() => { + delete globalThis.__FEATURES__; + }); + it('returns all expected service keys', () => { + const swGlobal = makeSwGlobal(); + const services = createServices(swGlobal); + expect(services).toHaveProperty('hookService'); + expect(services).toHaveProperty('fetchHandler'); + expect(services).toHaveProperty('installHandler'); + expect(services).toHaveProperty('activateHandler'); + expect(services).toHaveProperty('messageHandler'); + }); + + it('hookService has installHooks, addEventListener, addDefaultEventListeners', () => { + const swGlobal = makeSwGlobal(); + const { hookService } = createServices(swGlobal); + expect(typeof hookService.installHooks).toBe('function'); + expect(typeof hookService.addEventListener).toBe('function'); + expect(typeof hookService.addDefaultEventListeners).toBe('function'); + }); + + it('fetchHandler is a function', () => { + const swGlobal = makeSwGlobal(); + const { fetchHandler } = createServices(swGlobal); + expect(typeof fetchHandler).toBe('function'); + }); + + it('installHandler is a function', () => { + const swGlobal = makeSwGlobal(); + const { installHandler } = createServices(swGlobal); + expect(typeof installHandler).toBe('function'); + }); + + it('activateHandler is a function', () => { + const swGlobal = makeSwGlobal(); + const { activateHandler } = createServices(swGlobal); + expect(typeof activateHandler).toBe('function'); + }); + + it('messageHandler is a function', () => { + const swGlobal = makeSwGlobal(); + const { messageHandler } = createServices(swGlobal); + expect(typeof messageHandler).toBe('function'); + }); + + it('reads manifestUrl from search params or uses default', () => { + const swGlobal = makeSwGlobal(); + swGlobal.location.href = 'https://app.example.com/sw.js?manifestUrl=/custom-manifest.json'; + swGlobal.location.search = '?manifestUrl=/custom-manifest.json'; + const services = createServices(swGlobal); + expect(services).toHaveProperty('fetchHandler'); + }); +}); diff --git a/packages/dappfence/src/sw/__tests__/storage-index.test.js b/packages/dappfence/src/sw/__tests__/storage-index.test.js index c853b1b..ef06fcd 100644 --- a/packages/dappfence/src/sw/__tests__/storage-index.test.js +++ b/packages/dappfence/src/sw/__tests__/storage-index.test.js @@ -148,6 +148,26 @@ describe('recordSecurityViolation', () => { expect(mustBlock).toBe(true); }); + it('lines 59-60: handles VERIFICATION_STATUS.ERROR without throwing', async () => { + const appStore = createStore(); + const mustBlock = await appStore.recordSecurityViolation({ + ...mismatchDetails, + status: VERIFICATION_STATUS.ERROR, + }); + expect(mustBlock).toBe(true); + }); + + it('lines 59-60: else branch with undefined url and fileKey uses N/A fallbacks', async () => { + const appStore = createStore(); + const mustBlock = await appStore.recordSecurityViolation({ + status: VERIFICATION_STATUS.ERROR, + url: undefined, + fileKey: undefined, + assetType: 'asset', + }); + expect(mustBlock).toBe(true); + }); + it('does not crash if event logging fails', async () => { const db = createInMemoryDatabase(); const originalSet = db.set; @@ -161,4 +181,27 @@ describe('recordSecurityViolation', () => { const mustBlock = await appStore.recordSecurityViolation(mismatchDetails); expect(mustBlock).toBe(true); }); + + it('fail-safes to mustBlock=true when outer recordSecurityBlock call throws', async () => { + const db = createInMemoryDatabase(); + const appStore = createAppStore(db); + const details = { + status: null, + fileKey: '/bad.js', + url: 'https://example.com/bad.js', + assetType: 'asset', + }; + const mustBlock = await appStore.recordSecurityViolation(details); + expect(mustBlock).toBe(true); + }); + + it('handles securityEventsStore.logSecurityEvent throwing directly', async () => { + const db = createInMemoryDatabase(); + const appStore = createAppStore(db); + appStore.securityEventsStore.logSecurityEvent = async () => { + throw new Error('direct logSecurityEvent failure'); + }; + const mustBlock = await appStore.recordSecurityViolation(mismatchDetails); + expect(mustBlock).toBe(true); + }); }); diff --git a/packages/dappfence/src/sw/__tests__/sw-main.test.js b/packages/dappfence/src/sw/__tests__/sw-main.test.js new file mode 100644 index 0000000..3f98cee --- /dev/null +++ b/packages/dappfence/src/sw/__tests__/sw-main.test.js @@ -0,0 +1,101 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +describe('initializeServiceWorker', () => { + let initializeServiceWorker; + let createServices; + let mockServices; + + beforeEach(async () => { + vi.resetModules(); + globalThis.__FEATURES__ = {}; + if (typeof globalThis.self === 'undefined') { + globalThis.self = globalThis; + } + vi.doMock('../services.js', () => ({ + createServices: vi.fn(() => { + mockServices = { + hookService: { + installHooks: vi.fn(), + addEventListener: vi.fn(), + addDefaultEventListeners: vi.fn(), + }, + fetchHandler: vi.fn(), + installHandler: vi.fn(), + activateHandler: vi.fn(), + messageHandler: vi.fn(), + }; + return mockServices; + }), + })); + ({ initializeServiceWorker } = await import('../main.js')); + ({ createServices } = await import('../services.js')); + }); + + it('calls hookService.installHooks once', () => { + initializeServiceWorker(); + expect(mockServices.hookService.installHooks).toHaveBeenCalledTimes(1); + }); + + it('registers 4 event listeners via hookService.addEventListener', () => { + initializeServiceWorker(); + expect(mockServices.hookService.addEventListener).toHaveBeenCalledTimes(4); + }); + + it('registers fetch, install, activate, message event types', () => { + initializeServiceWorker(); + const types = mockServices.hookService.addEventListener.mock.calls.map((c) => c[0]); + expect(types).toContain('fetch'); + expect(types).toContain('install'); + expect(types).toContain('activate'); + expect(types).toContain('message'); + }); + + it('calls hookService.addDefaultEventListeners once', () => { + initializeServiceWorker(); + expect(mockServices.hookService.addDefaultEventListeners).toHaveBeenCalledTimes(1); + }); + + it('calls createServices', () => { + initializeServiceWorker(); + expect(createServices).toHaveBeenCalledTimes(1); + }); + + it('fetch handler callback calls event.respondWith with fetchHandler result', async () => { + initializeServiceWorker(); + const fetchCall = mockServices.hookService.addEventListener.mock.calls.find( + (c) => c[0] === 'fetch' + ); + const fetchCallback = fetchCall[1]; + const respondWith = vi.fn(); + const callChildHandlers = vi.fn(); + mockServices.fetchHandler.mockResolvedValue(new Response('ok')); + await fetchCallback({ respondWith }, callChildHandlers); + expect(respondWith).toHaveBeenCalled(); + }); + + it('install handler callback calls event.waitUntil with installHandler result', async () => { + initializeServiceWorker(); + const installCall = mockServices.hookService.addEventListener.mock.calls.find( + (c) => c[0] === 'install' + ); + const installCallback = installCall[1]; + const waitUntil = vi.fn(); + const callChildHandlers = vi.fn(); + mockServices.installHandler.mockResolvedValue(undefined); + await installCallback({ waitUntil }, callChildHandlers); + expect(waitUntil).toHaveBeenCalled(); + }); + + it('activate handler callback calls event.waitUntil with activateHandler result', async () => { + initializeServiceWorker(); + const activateCall = mockServices.hookService.addEventListener.mock.calls.find( + (c) => c[0] === 'activate' + ); + const activateCallback = activateCall[1]; + const waitUntil = vi.fn(); + const callChildHandlers = vi.fn(); + mockServices.activateHandler.mockResolvedValue(undefined); + await activateCallback({ waitUntil }, callChildHandlers); + expect(waitUntil).toHaveBeenCalled(); + }); +}); diff --git a/packages/dappfence/src/sw/services.js b/packages/dappfence/src/sw/services.js index 03d07a9..653b5dd 100644 --- a/packages/dappfence/src/sw/services.js +++ b/packages/dappfence/src/sw/services.js @@ -36,7 +36,7 @@ export function createServices(swGlobal) { const config = parseConfig(swContext); logger.log('Configuration:', config); - const appStore = createAppStore(createDatabase(), { + const appStore = createAppStore(createDatabase(swGlobal.indexedDB), { userAgent: swContext.getUserAgent(), origin: swContext.getLocationOrigin(), }); diff --git a/packages/dappfence/src/sw/storage/indexeddb.js b/packages/dappfence/src/sw/storage/indexeddb.js index 6596c20..15ce0e4 100644 --- a/packages/dappfence/src/sw/storage/indexeddb.js +++ b/packages/dappfence/src/sw/storage/indexeddb.js @@ -6,21 +6,21 @@ * making it injectable and testable with in-memory backends. */ -function openDatabase() { - return new Promise((resolve, reject) => { - const request = indexedDB.open('AppSecurity', 1); - request.onerror = () => reject(request.error); - request.onsuccess = () => resolve(request.result); - request.onupgradeneeded = (e) => { - const db = e.target.result; - if (!db.objectStoreNames.contains('data')) { - db.createObjectStore('data'); - } - }; - }); -} +export function createDatabase(idb) { + function openDatabase() { + return new Promise((resolve, reject) => { + const request = idb.open('AppSecurity', 1); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result); + request.onupgradeneeded = (e) => { + const db = e.target.result; + if (!db.objectStoreNames.contains('data')) { + db.createObjectStore('data'); + } + }; + }); + } -export function createDatabase() { async function get(key) { const db = await openDatabase(); return new Promise((resolve, reject) => { diff --git a/packages/dappfence/vite.config.js b/packages/dappfence/vite.config.js index db6e95d..37e7106 100644 --- a/packages/dappfence/vite.config.js +++ b/packages/dappfence/vite.config.js @@ -78,6 +78,12 @@ export default defineConfig(({ mode }) => ({ root: __dirname, include: ['src/**/*.test.js'], onConsoleLog: () => false, // Silence production logger output during tests + coverage: { + provider: 'v8', + all: true, + include: ['src/**/*.js'], + exclude: ['src/**/*.test.js'], + }, }, // Plugins (none needed - using built-in ?raw imports) From 6a02bb4c74864bbbb3696856cab8e6f0a2789190 Mon Sep 17 00:00:00 2001 From: adji Date: Fri, 29 May 2026 15:05:25 -0300 Subject: [PATCH 07/59] refactor: make dev-server a reusable startServer() factory Converts dev-server.js from a standalone CLI script to a module that exports startServer(opts) so external packages can embed the test server without spawning a child process. Also adds exports map to @dappfence/test-app so callers can import via `require('@dappfence/test-app/server')`, and adds dev:http and test:e2e convenience scripts to the root package.json. --- package.json | 4 +- packages/test-app/package.json | 4 + packages/test-app/src/dev-server.js | 750 ++++++++++++++-------------- 3 files changed, 384 insertions(+), 374 deletions(-) diff --git a/package.json b/package.json index 551b4c4..96d9794 100644 --- a/package.json +++ b/package.json @@ -4,15 +4,17 @@ "type": "module", "scripts": { "dev": "npm run build && npm run dev -w @dappfence/test-app", + "dev:http": "npm run build && npm run dev:http -w @dappfence/test-app", "test": "npm run build && npm test --ws --if-present", "test:coverage": "npm run build && npm run test:coverage --ws --if-present", "test:unit": "npm test -w @dappfence/core", + "test:e2e": "npm run build && npm test -w @dappfence/test-app", "build": "npm run build --ws --if-present", "build:prod": "npm run build:prod -w @dappfence/core", "build:watch": "npm run build:watch -w @dappfence/core & npm run build:watch -w @dappfence/test-app", "clean": "npm run clean --ws --if-present", "lint": "eslint packages/ --fix", - "check": "eslint packages/ && prettier --check packages/ *.md", + "check": "prettier --check \"*.md\" packages/ && eslint packages/", "format": "prettier --write packages/ *.md", "publish:local": "npm run build:prod -w @dappfence/core && npm pack -w @dappfence/core --pack-destination dist && npm pack -w @dappfence/signer --pack-destination dist && npm pack -w @dappfence/astro --pack-destination dist", "publish:core": "npm run check && npm run build:prod && npm publish -w @dappfence/core --access public", diff --git a/packages/test-app/package.json b/packages/test-app/package.json index 8ff0535..0e4a6bd 100644 --- a/packages/test-app/package.json +++ b/packages/test-app/package.json @@ -5,6 +5,10 @@ "description": "Development server and e2e tests for DappFence", "type": "commonjs", "main": "src/dev-server.js", + "exports": { + ".": "./src/dev-server.js", + "./server": "./src/dev-server.js" + }, "scripts": { "clean": "rm -rf dist test-results", "build": "node src/build.js --quiet", diff --git a/packages/test-app/src/dev-server.js b/packages/test-app/src/dev-server.js index 0a8c133..691cfcc 100644 --- a/packages/test-app/src/dev-server.js +++ b/packages/test-app/src/dev-server.js @@ -7,16 +7,11 @@ const crypto = require('crypto'); const { spawn } = require('child_process'); const { connect } = require('node:net'); -const PROJECT_ROOT = path.resolve(__dirname, '..', 'dist'); const ASSET_ROOT = path.resolve(__dirname, '..', 'assets'); const DAPPFENCE_DIST = require.resolve('@dappfence/core'); -const pIndex = process.argv.indexOf('-p'); -const port = pIndex > 0 && pIndex < process.argv.length ? parseInt(process.argv[pIndex + 1]) : 3333; -const dIndex = process.argv.indexOf('-d'); -const defaultApp = dIndex > 0 && dIndex < process.argv.length && process.argv[dIndex + 1]; +// --- Pure utilities --- -// MIME types mapping const MIME_TYPES = { '.html': 'text/html', '.js': 'application/javascript', @@ -41,42 +36,9 @@ function calculateSRIHash(content) { return `sha256-${digest}`; } -// function truncateHash(sriHash, length = 12) { -// if (!sriHash || !sriHash.startsWith('sha256-')) return 'no-hash'; -// return sriHash.substring(7, 7 + length); // Skip 'sha256-' prefix -// } - function getTimestamp() { return new Date().toISOString().split('T')[1].slice(0, -1); // HH:MM:SS.mmm format } -const INTERCEPT_FORMULAS = { - default: (data, testParams, filePath) => { - const p = filePath.trim().toLowerCase(); - if (p.endsWith('.json')) { - const json = JSON.parse(data); - json.pay = { ...json.pay, 'integrity-manifest.json': 'modified' }; - return JSON.stringify(json); - } else if (p.endsWith('.html')) { - return '\n' + data; - } else if (p.endsWith('.js')) { - return '// modified\n' + data; - } - return ' ' + data; - }, - empty: () => { - return ''; - }, - replace: (data, testParams, filePath, pattern, args) => { - const replacement = path.join(PROJECT_ROOT, testParams.app, args); - if (fs.existsSync(replacement) && fs.statSync(replacement).isFile()) { - return fs.readFileSync(replacement, 'utf8'); - } - console.log( - `[${getTimestamp()}] \x1b[31m[REPLACE] skipping, file not found ${replacement}\x1b[0m` - ); - return data; - }, -}; function checkPattern(pattern, val) { try { @@ -85,7 +47,7 @@ function checkPattern(pattern, val) { /* empty */ } try { - const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&'); // Escape regex specials + const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&'); const regexString = escaped .replace(/\*\*/g, 'XXXX') // ** matches anything including / .replace(/\*/g, '([^/]*)') // * matches anything except / @@ -98,64 +60,6 @@ function checkPattern(pattern, val) { return pattern === val; } -// This will be updated by the test-config API -const testParameters = { - // port/testKey: data - 1: { - appName: 'project-name', - appVersion: 'latest', - testTitle: 'example-test', - testId: '111-222', - responseHeaders: [ - { - match: '*', - headers: { 'Cache-Control': 'max-age=3600' }, // 1 hour - }, - ], - saveResponses: false, - testResponse: [], - }, -}; -function getExtraResponseHeaders(testParams) { - const params = testParameters[testParams.testKey]; - if (params || process.argv.includes('--no-cache')) { - if (params && params.responseHeaders) { - const rulesByTest = params.responseHeaders; - if (rulesByTest && Array.isArray(rulesByTest)) { - for (const rule of rulesByTest) { - if (rule.match && rule.headers) { - const regex = new RegExp('^' + rule.match.replace(/\*/g, '.*') + '$'); - if (regex.test(testParams.requestPath)) { - return rule.headers; - } - } - } - } - } - // This is a dev server; our default is to avoid caching - return { - 'Cache-Control': 'no-cache, no-store, must-revalidate', - Pragma: 'no-cache', - Expires: '0', - }; - } - // For third-party assets or when testing manually with `defaultApp` (no testKey configuration). - // Use aggressive caching to simulate production CDN behavior. - const hours = 60 * 60; // Convert hours to seconds - const cacheTimeout = 48 * hours; // 48 hours: exceeds the 24-hour service worker auto-update threshold - return { 'Cache-Control': `public, max-age=${cacheTimeout}, immutable` }; -} - -function saveTestResponse(testParams, result, filePath = '', extraHeaders = {}, intercept = null) { - const params = testParameters[testParams.testKey]; - if (params && params.saveResponses) { - if (!params.testResponse) { - params.testResponse = []; - } - params.testResponse.push({ ...testParams, filePath, result, extraHeaders, intercept }); - } -} - async function readJSON(req) { return new Promise((resolve, reject) => { let body = ''; @@ -175,143 +79,10 @@ async function readJSON(req) { }); } -function serveConfigTestApi(req, res, testParams) { - readJSON(req) - .then((params) => { - if (!params.appName || !params.appVersion) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - error: 'Missing required fields: appName and appVersion', - }) - ); - return; - } - let intercept = []; - if (params.intercept) { - intercept = Array.isArray(params.intercept) ? params.intercept : [params.intercept]; - intercept = intercept.map((i) => ({ - ...i, - formula: - i && i.formula && INTERCEPT_FORMULAS[i.formula] ? i.formula : 'default', - })); - } - testParameters[testParams.testKey] = { - ...testParameters[testParams.testKey], - ...params, - intercept, - }; - // logRequestToConsole( - // req, - // testParams, - // `\x1b[36m[TEST-CONFIG] ${testParams.testKey} : ${JSON.stringify(params)}\x1b[0m` - // ); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ success: true })); - }) - .catch(() => { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Invalid JSON payload' })); - }); -} - -function serveApi(res, req, testParams) { - const params = testParameters[testParams.testKey]; - // Handle POST requests to update test parameters - if (req.method === 'DELETE') { - console.log(''); - console.log( - `[${getTimestamp()}] \x1b[36m[TEST-CONFIG] Deleting test parameters for key ${testParams.testKey}\x1b[0m` - ); - testParameters[testParams.testKey] = {}; - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ success: true })); - } else if (req.method === 'POST' && testParams.requestPath === '/api/log') { - readJSON(req, res) - .then((json) => { - console.log(''); - console.log(`[${getTimestamp()}] \x1b[35m[CLIENT-LOG]\x1b[0m`, json); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ success: true })); - }) - .catch(() => { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Invalid JSON payload' })); - }); - } else if (req.method === 'POST' && testParams.requestPath === '/api/test-config') { - serveConfigTestApi(req, res, testParams); - } else if (req.method === 'GET' && testParams.requestPath === '/api/test-responses') { - res.writeHead(200, { - 'Content-Type': 'application/json', - 'Cache-Control': 'no-cache, no-store, must-revalidate', - Pragma: 'no-cache', - Expires: '0', - }); - res.end(JSON.stringify((params && params.testResponse) || [])); - } else { - res.writeHead(500, { 'Content-Type': 'application/test' }); - res.end('Internal server error'); - } -} - -function serveFile(filePath, res, req, testParams) { - fs.readFile(filePath, (err, data) => { - if (err) { - saveTestResponse(testParams, 'error reading file', filePath); - logRequestToConsole( - req, - testParams, - `\x1b[31m ❌ ERROR ${filePath}, ${err.toString()}\x1b[0m` - ); - res.writeHead(404, { 'Content-Type': 'text/plain' }); - res.end('File not found'); - return; - } - - // Calculate and log SRI hash for debugging - const sriHash = calculateSRIHash(data); - const mimeType = getMimeType(filePath); - const extraHeaders = getExtraResponseHeaders(testParams); - const relativeFilePath = path.relative(PROJECT_ROOT, filePath); - - const params = testParameters[testParams.testKey]; - const intercept = - params && - params.intercept && - params.intercept.find( - (i) => i.pattern && checkPattern(i.pattern, testParams.requestPath) - ); - const resData = intercept - ? INTERCEPT_FORMULAS[intercept.formula]( - data, - testParams, - filePath, - intercept.pattern, - intercept.args - ) - : data; - - saveTestResponse(testParams, 'ok', filePath, extraHeaders, intercept); - logRequestToConsole( - req, - testParams, - `🔑 ${relativeFilePath}: ${sriHash} (${data.length} bytes) ${extraHeaders['Cache-Control'] || 'no-cache-header'}${intercept ? ` applied ${intercept.formula}` : ''}` - ); - res.sendDate = false; - res.writeHead(200, { - 'Content-Type': mimeType, - ...extraHeaders, - }); - res.end(resData); - }); -} - function logRequestToConsole(req, testParams, result) { const isServiceWorkerRequest = req.headers['service-worker'] === 'script' || req.headers['sec-fetch-dest'] === 'serviceworker'; - - // Check request type and DappFence tracking (header or URL param) const hasDappFenceHeader = 'x-dappfence' in req.headers; const isCacheCheck = req.headers['if-modified-since'] || req.headers['if-none-match']; @@ -319,15 +90,14 @@ function logRequestToConsole(req, testParams, result) { let colorCode = ''; if (isServiceWorkerRequest) { indicator = '[SW-REG]'; - colorCode = '\x1b[36m'; // Cyan + colorCode = '\x1b[36m'; } else if (hasDappFenceHeader) { - indicator = '[DFSW-HDR]'; // Only header - colorCode = '\x1b[33m'; // Yellow + indicator = '[DFSW-HDR]'; + colorCode = '\x1b[33m'; } else { - indicator = '[BYPASSED]'; // No SW tracking - direct browser request - colorCode = '\x1b[31m'; // Red + indicator = '[BYPASSED]'; + colorCode = '\x1b[31m'; } - // Add cache check indicator const cacheIndicator = isServiceWorkerRequest ? '🔧' : isCacheCheck ? '💾' : ''; console.log(); @@ -345,159 +115,393 @@ function logRequestToConsole(req, testParams, result) { console.log('\t', result); } -function getTestParameters(req) { - // Extract the destination port from request headers to use as a test key. - // The port is extracted from the 'Host' header (for same-origin requests) - // or from the 'Origin' header (for cross-origin requests to external assets). - const host = req.headers.host; - const origin = URL.parse(req.headers.origin) || URL.parse(`http://${host}`); - const testKey = origin && origin.port; - const params = testParameters[testKey]; - - const baseUrl = new URL(req.url, host ? `http://${host}` : 'http://localhost:' + port); - // defaultApp (passed by argument) take precedence - if (defaultApp) { +// --- Server factory --- + +/** + * Start the DappFence dev/test server. + * + * @param {object} opts + * @param {number} [opts.port=3333] - Port to listen on. + * @param {string} [opts.root] - Root directory for app files. Defaults to + * the test-app's own dist/ directory. + * @param {string} [opts.defaultApp] - Default app directory name (e.g. 'simple-app_latest'). + * Takes precedence over per-request /api/test-config. + * @param {boolean} [opts.noCache=false] - Disable all caching headers. + * @param {boolean} [opts.dev=false] - Serve dappfence.js from @dappfence/core dist + * instead of the app directory. + * @param {boolean} [opts.withBrowser=false]- Open a browser tab after startup. + * @returns {Promise} - Resolves once the server is listening. + */ +function startServer({ + port = 3333, + root, + defaultApp, + noCache = false, + dev = false, + withBrowser = false, +} = {}) { + const PROJECT_ROOT = root + ? path.resolve(process.cwd(), root) + : path.resolve(__dirname, '..', 'dist'); + + // Per-server mutable state — keyed by the request port (test isolation). + const testParameters = { + 1: { + appName: 'project-name', + appVersion: 'latest', + testTitle: 'example-test', + testId: '111-222', + responseHeaders: [{ match: '*', headers: { 'Cache-Control': 'max-age=3600' } }], + saveResponses: false, + testResponse: [], + }, + }; + + const INTERCEPT_FORMULAS = { + default: (data, testParams, filePath) => { + const p = filePath.trim().toLowerCase(); + if (p.endsWith('.json')) { + const json = JSON.parse(data); + json.pay = { ...json.pay, 'integrity-manifest.json': 'modified' }; + return JSON.stringify(json); + } else if (p.endsWith('.html')) { + return '\n' + data; + } else if (p.endsWith('.js')) { + return '// modified\n' + data; + } + return ' ' + data; + }, + empty: () => '', + replace: (data, testParams, filePath, pattern, args) => { + const replacement = path.join(PROJECT_ROOT, testParams.app, args); + if (fs.existsSync(replacement) && fs.statSync(replacement).isFile()) { + return fs.readFileSync(replacement, 'utf8'); + } + console.log( + `[${getTimestamp()}] \x1b[31m[REPLACE] skipping, file not found ${replacement}\x1b[0m` + ); + return data; + }, + }; + + function getExtraResponseHeaders(testParams) { + const params = testParameters[testParams.testKey]; + if (params || noCache) { + if (params && params.responseHeaders) { + for (const rule of params.responseHeaders) { + if (rule.match && rule.headers) { + const regex = new RegExp('^' + rule.match.replace(/\*/g, '.*') + '$'); + if (regex.test(testParams.requestPath)) { + return rule.headers; + } + } + } + } + return { + 'Cache-Control': 'no-cache, no-store, must-revalidate', + Pragma: 'no-cache', + Expires: '0', + }; + } + const cacheTimeout = 48 * 60 * 60; // 48 hours + return { 'Cache-Control': `public, max-age=${cacheTimeout}, immutable` }; + } + + function saveTestResponse( + testParams, + result, + filePath = '', + extraHeaders = {}, + intercept = null + ) { + const params = testParameters[testParams.testKey]; + if (params && params.saveResponses) { + if (!params.testResponse) { + params.testResponse = []; + } + params.testResponse.push({ ...testParams, filePath, result, extraHeaders, intercept }); + } + } + + function serveConfigTestApi(req, res, testParams) { + readJSON(req) + .then((params) => { + if (!params.appName || !params.appVersion) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ error: 'Missing required fields: appName and appVersion' }) + ); + return; + } + let intercept = []; + if (params.intercept) { + intercept = Array.isArray(params.intercept) + ? params.intercept + : [params.intercept]; + intercept = intercept.map((i) => ({ + ...i, + formula: + i && i.formula && INTERCEPT_FORMULAS[i.formula] ? i.formula : 'default', + })); + } + testParameters[testParams.testKey] = { + ...testParameters[testParams.testKey], + ...params, + intercept, + }; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: true })); + }) + .catch(() => { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON payload' })); + }); + } + + function serveApi(res, req, testParams) { + const params = testParameters[testParams.testKey]; + if (req.method === 'DELETE') { + console.log(''); + console.log( + `[${getTimestamp()}] \x1b[36m[TEST-CONFIG] Deleting test parameters for key ${testParams.testKey}\x1b[0m` + ); + testParameters[testParams.testKey] = {}; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: true })); + } else if (req.method === 'POST' && testParams.requestPath === '/api/log') { + readJSON(req, res) + .then((json) => { + console.log(''); + console.log(`[${getTimestamp()}] \x1b[35m[CLIENT-LOG]\x1b[0m`, json); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: true })); + }) + .catch(() => { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON payload' })); + }); + } else if (req.method === 'POST' && testParams.requestPath === '/api/test-config') { + serveConfigTestApi(req, res, testParams); + } else if (req.method === 'GET' && testParams.requestPath === '/api/test-responses') { + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache, no-store, must-revalidate', + Pragma: 'no-cache', + Expires: '0', + }); + res.end(JSON.stringify((params && params.testResponse) || [])); + } else { + res.writeHead(500, { 'Content-Type': 'application/test' }); + res.end('Internal server error'); + } + } + + function serveFile(filePath, res, req, testParams) { + fs.readFile(filePath, (err, data) => { + if (err) { + saveTestResponse(testParams, 'error reading file', filePath); + logRequestToConsole( + req, + testParams, + `\x1b[31m ❌ ERROR ${filePath}, ${err.toString()}\x1b[0m` + ); + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('File not found'); + return; + } + + const sriHash = calculateSRIHash(data); + const mimeType = getMimeType(filePath); + const extraHeaders = getExtraResponseHeaders(testParams); + const relativeFilePath = path.relative(PROJECT_ROOT, filePath); + + const params = testParameters[testParams.testKey]; + const intercept = + params && + params.intercept && + params.intercept.find( + (i) => i.pattern && checkPattern(i.pattern, testParams.requestPath) + ); + const resData = intercept + ? INTERCEPT_FORMULAS[intercept.formula]( + data, + testParams, + filePath, + intercept.pattern, + intercept.args + ) + : data; + + saveTestResponse(testParams, 'ok', filePath, extraHeaders, intercept); + logRequestToConsole( + req, + testParams, + `🔑 ${relativeFilePath}: ${sriHash} (${data.length} bytes) ${extraHeaders['Cache-Control'] || 'no-cache-header'}${intercept ? ` applied ${intercept.formula}` : ''}` + ); + res.sendDate = false; + res.writeHead(200, { 'Content-Type': mimeType, ...extraHeaders }); + res.end(resData); + }); + } + + function getTestParameters(req) { + const host = req.headers.host; + const origin = URL.parse(req.headers.origin) || URL.parse(`http://${host}`); + const testKey = origin && origin.port; + const params = testParameters[testKey]; + + const baseUrl = new URL(req.url, host ? `http://${host}` : 'http://localhost:' + port); + if (defaultApp) { + return { + testKey, + app: defaultApp, + appName: defaultApp.split('_')[0], + appVersion: defaultApp.split('_')[1] || 'latest', + testTitle: 'default', + testId: 'default', + url: baseUrl.toString(), + requestPath: baseUrl.pathname, + }; + } + const { appName, appVersion, testTitle, testId } = params || {}; return { testKey, - app: defaultApp, - appName: defaultApp.split('_')[0], - appVersion: defaultApp.split('_')[1] || 'latest', - testTitle: 'default', - testId: 'default', + app: appName + '_' + (appVersion || 'latest'), + appName, + appVersion, + testTitle, + testId, url: baseUrl.toString(), requestPath: baseUrl.pathname, }; } - const { appName, appVersion, testTitle, testId } = params || {}; - return { - testKey, - app: appName + '_' + (appVersion || 'latest'), - appName, - appVersion, - testTitle, - testId, - url: baseUrl.toString(), - requestPath: baseUrl.pathname, - }; -} -const server = http.createServer((req, res) => { - const testParams = getTestParameters(req); - // Handle OPTIONS requests for CORS preflight - const CORS_HEADERS = [ - 'Access-Control-Allow-Origin', - 'Access-Control-Allow-Methods', - 'Access-Control-Allow-Headers', - ].reduce((acc, header) => ({ ...acc, [header]: '*' }), {}); - if (req.method === 'OPTIONS') { - res.writeHead(204, CORS_HEADERS); - res.end(); - return; - } - // Add CORS headers to all responses - Object.keys(CORS_HEADERS).forEach((header) => { - res.setHeader(header, CORS_HEADERS[header]); - }); + const server = http.createServer((req, res) => { + const testParams = getTestParameters(req); + const CORS_HEADERS = [ + 'Access-Control-Allow-Origin', + 'Access-Control-Allow-Methods', + 'Access-Control-Allow-Headers', + ].reduce((acc, header) => ({ ...acc, [header]: '*' }), {}); + + if (req.method === 'OPTIONS') { + res.writeHead(204, CORS_HEADERS); + res.end(); + return; + } + Object.keys(CORS_HEADERS).forEach((header) => { + res.setHeader(header, CORS_HEADERS[header]); + }); - if (testParams.requestPath.startsWith('/api/')) { - return serveApi(res, req, testParams); - } + if (testParams.requestPath.startsWith('/api/')) { + return serveApi(res, req, testParams); + } - // Handle special routes for development when I cannot control the request headers - if (process.argv.includes('--dev') && testParams.requestPath.endsWith('/dappfence.js')) { - // In development, serve the framework from dist/ so we don't need to copy - return serveFile(DAPPFENCE_DIST, res, req, testParams); // Always log hash for dappfence.js - } + if (dev && testParams.requestPath.endsWith('/dappfence.js')) { + return serveFile(DAPPFENCE_DIST, res, req, testParams); + } - if (testParams.app) { - const htmlRoot = path.join(PROJECT_ROOT, testParams.app); - for (const p of ['', '.html', '/index.html']) { - const filePath = path.join(htmlRoot, testParams.requestPath + p); - if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) { - return serveFile(filePath, res, req, testParams); + if (testParams.app) { + const htmlRoot = path.join(PROJECT_ROOT, testParams.app); + for (const p of ['', '.html', '/index.html']) { + const filePath = path.join(htmlRoot, testParams.requestPath + p); + if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) { + return serveFile(filePath, res, req, testParams); + } } } - } - // As a last attempt, try `assets` path (`jquery` for example) - const filePath = path.join(ASSET_ROOT, testParams.requestPath); - if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) { - return serveFile(filePath, res, req, testParams); - } + const filePath = path.join(ASSET_ROOT, testParams.requestPath); + if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) { + return serveFile(filePath, res, req, testParams); + } - saveTestResponse(testParams, 'file not found'); - logRequestToConsole( - req, - testParams, - `\x1b[31m ❌ NOT FOUND ${req.method} ${testParams.url}\x1b[0m` - ); - res.writeHead(404, { 'Content-Type': 'text/plain' }); - res.end('File not found'); -}); - -// Listen for the 'connect' event to handle proxy tunneling requests -server.on('connect', (req, socket) => { - // console.log( - // `[${getTimestamp()}] \x1b[32m[PROXY] Client requested CONNECT to: ${req.url} via ${req.headers.host}\x1b[0m` - // ); - // console.log(`[${getTimestamp()}] \x1b[32m[PROXY] Proxying to: localhost:${port}\x1b[0m`); - const remote = connect(port, 'localhost', () => { - // Tell the client that the connection is established - socket.write('HTTP/1.1 200 Connection Established\r\n\r\n'); - // Pipe data between the client socket and the remote server socket - remote.pipe(socket); - socket.pipe(remote); - }); - remote.on('error', (e) => { - console.log( - `[${getTimestamp()}] \x1b[31m[PROXY] Remote connection error: ${e.message}\x1b[0m` + saveTestResponse(testParams, 'file not found'); + logRequestToConsole( + req, + testParams, + `\x1b[31m ❌ NOT FOUND ${req.method} ${testParams.url}\x1b[0m` ); - socket.end(); + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('File not found'); }); - socket.on('error', (e) => { - console.log( - `[${getTimestamp()}] \x1b[31m[PROXY] Client socket error: ${e.message}\x1b[0m` - ); - remote.end(); + + server.on('connect', (req, socket) => { + const remote = connect(port, 'localhost', () => { + socket.write('HTTP/1.1 200 Connection Established\r\n\r\n'); + remote.pipe(socket); + socket.pipe(remote); + }); + remote.on('error', (e) => { + console.log( + `[${getTimestamp()}] \x1b[31m[PROXY] Remote connection error: ${e.message}\x1b[0m` + ); + socket.end(); + }); + socket.on('error', (e) => { + console.log( + `[${getTimestamp()}] \x1b[31m[PROXY] Client socket error: ${e.message}\x1b[0m` + ); + remote.end(); + }); }); -}); -// Prevent MaxListenersExceededWarning -server.setMaxListeners(Infinity); -server.listen(port, () => { - console.log(`🚀 DappFence Dev Server running at http://localhost:${port}`); - console.log(`📁 Serving test default app: ${defaultApp}`); - console.log(''); - console.log('Press Ctrl+C to stop'); - if (process.argv.includes('--with-browser')) { - // Try to open a browser (cross-platform) - gracefully handle errors - try { - const open = - process.platform === 'win32' - ? 'start' - : process.platform === 'darwin' - ? 'open' - : 'xdg-open'; - - const child = spawn(open, [`http://localhost:${port}`], { - stdio: 'ignore', - detached: true, - }); - child.on('error', (_err) => { - // Silently ignore browser-opening errors - console.log(`💡 Open http://localhost:${port} in your browser`); + server.setMaxListeners(Infinity); + + return new Promise((resolve) => { + server.listen(port, () => { + console.log(`🚀 DappFence Dev Server running at http://localhost:${port}`); + if (defaultApp) console.log(`📁 Serving default app: ${defaultApp}`); + console.log(''); + if (withBrowser) { + try { + const open = + process.platform === 'win32' + ? 'start' + : process.platform === 'darwin' + ? 'open' + : 'xdg-open'; + const child = spawn(open, [`http://localhost:${port}`], { + stdio: 'ignore', + detached: true, + }); + child.on('error', () => { + console.log(`💡 Open http://localhost:${port} in your browser`); + }); + } catch (_err) { + console.log(`💡 Open http://localhost:${port} in your browser`); + } + } + resolve(server); + }); + }); +} + +module.exports = { startServer }; + +// --- CLI entry point --- + +if (require.main === module) { + const rootArg = process.argv.find((a) => a.startsWith('--root=')); + const pIndex = process.argv.indexOf('-p'); + const dIndex = process.argv.indexOf('-d'); + + startServer({ + port: pIndex > 0 ? parseInt(process.argv[pIndex + 1]) : 3333, + root: rootArg ? rootArg.slice('--root='.length) : undefined, + defaultApp: + dIndex > 0 && dIndex < process.argv.length - 1 ? process.argv[dIndex + 1] : undefined, + noCache: process.argv.includes('--no-cache'), + dev: process.argv.includes('--dev'), + withBrowser: process.argv.includes('--with-browser'), + }).then((server) => { + console.log('Press Ctrl+C to stop'); + process.on('SIGINT', () => { + console.log('\n👋 Shutting down dev server...'); + server.close(() => { + console.log('✅ Dev server stopped'); + process.exit(0); }); - } catch (_err) { - console.log(`💡 Open http://localhost:${port} in your browser`); - } - } -}); - -// Graceful shutdown -process.on('SIGINT', () => { - console.log('\n👋 Shutting down dev server...'); - server.close(() => { - console.log('✅ Dev server stopped'); - process.exit(0); + }); }); -}); +} From cb70879235328f62ca1488e87b4e2b4bfd2a358b Mon Sep 17 00:00:00 2001 From: adji Date: Mon, 1 Jun 2026 15:39:21 -0300 Subject: [PATCH 08/59] feat: add @dappfence/next and @dappfence/manifest-tools packages - Rename @dappfence/signer to @dappfence/manifest-tools - Add shared manifest pipeline: walk, generateManifest, buildScriptAttrs, buildScriptTag, injectScriptTag (src/manifest.js) - Add verifyManifest() using correct recoverSigner - Add dappfence-manifest CLI: hash / verify / sign subcommands - Add unit tests and README - Rename packages/astro-integration/ to packages/astro/ - Slim src/manifest.js to Astro-specific helpers only (extractDynamicRoutes, buildPageSet) - Delegate shared pipeline to @dappfence/manifest-tools/manifest - Add @dappfence/next - withDappfence(opts)(nextConfig) webpack plugin wrapper - SSR mode: copies dappfence.js to public/, hashes .next/static/ - Static export: writes .next/dappfence-config.json for postbuild CLI - dappfence-next CLI for static export postbuild - Unit tests and README - Simplify root package.json file - Broaden eslint.config.js node rule to packages/**/*.js --- eslint.config.js | 10 +- package-lock.json | 57 +++++- package.json | 6 +- .../{astro-integration => astro}/README.md | 0 .../{astro-integration => astro}/package.json | 11 +- .../src/__tests__/manifest.test.js | 0 .../{astro-integration => astro}/src/index.js | 2 +- packages/astro/src/manifest.js | 44 +++++ packages/dappfence/package.json | 7 +- .../{signer => manifest-tools}/.gitignore | 0 packages/manifest-tools/README.md | 150 +++++++++++++++ packages/manifest-tools/package.json | 41 ++++ .../src/__tests__/manifest.test.js | 168 +++++++++++++++++ .../{signer => manifest-tools}/src/build.js | 37 +++- .../src/check-signature.js | 0 packages/manifest-tools/src/cli.js | 177 ++++++++++++++++++ .../{signer => manifest-tools}/src/crypto.js | 9 + .../src/manifest.js | 118 ++++++------ packages/next/README.md | 138 ++++++++++++++ packages/next/bin/dappfence-next.js | 78 ++++++++ packages/next/package.json | 46 +++++ packages/next/src/__tests__/generate.test.js | 154 +++++++++++++++ packages/next/src/index.js | 65 +++++++ packages/next/src/webpack-plugin.js | 90 +++++++++ packages/signer/package.json | 31 --- packages/test-app/package.json | 2 +- packages/test-app/src/build-config.js | 2 +- packages/test-app/src/build.js | 4 +- 28 files changed, 1315 insertions(+), 132 deletions(-) rename packages/{astro-integration => astro}/README.md (100%) rename packages/{astro-integration => astro}/package.json (78%) rename packages/{astro-integration => astro}/src/__tests__/manifest.test.js (100%) rename packages/{astro-integration => astro}/src/index.js (98%) create mode 100644 packages/astro/src/manifest.js rename packages/{signer => manifest-tools}/.gitignore (100%) create mode 100644 packages/manifest-tools/README.md create mode 100644 packages/manifest-tools/package.json create mode 100644 packages/manifest-tools/src/__tests__/manifest.test.js rename packages/{signer => manifest-tools}/src/build.js (66%) rename packages/{signer => manifest-tools}/src/check-signature.js (100%) create mode 100644 packages/manifest-tools/src/cli.js rename packages/{signer => manifest-tools}/src/crypto.js (82%) rename packages/{astro-integration => manifest-tools}/src/manifest.js (51%) create mode 100644 packages/next/README.md create mode 100755 packages/next/bin/dappfence-next.js create mode 100644 packages/next/package.json create mode 100644 packages/next/src/__tests__/generate.test.js create mode 100644 packages/next/src/index.js create mode 100644 packages/next/src/webpack-plugin.js delete mode 100644 packages/signer/package.json diff --git a/eslint.config.js b/eslint.config.js index bf1133e..368b6f7 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -14,14 +14,10 @@ export default [ }, js.configs.recommended, prettier, - // Node scripts (CommonJS + ESM) + // All Node.js packages (everything except dappfence core source) { - files: [ - 'packages/test-app/src/**/*.js', - 'packages/signer/src/**/*.js', - 'packages/astro-integration/src/**/*.js', - 'packages/dappfence/vite.config.js', - ], + files: ['packages/**/*.js'], + ignores: ['packages/dappfence/src/**/*.js'], languageOptions: { globals: globals.node, }, diff --git a/package-lock.json b/package-lock.json index 6d75cd4..f5683b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,10 +9,12 @@ "packages/*" ], "devDependencies": { + "@vitest/coverage-v8": "4.1.4", "eslint": "9.39.4", "eslint-config-prettier": "10.1.8", "prettier": "3.2.5", - "typescript-eslint": "8.58.2" + "typescript-eslint": "8.58.2", + "vitest": "4.1.4" }, "engines": { "node": ">=16.0.0" @@ -79,15 +81,19 @@ } }, "node_modules/@dappfence/astro": { - "resolved": "packages/astro-integration", + "resolved": "packages/astro", "link": true }, "node_modules/@dappfence/core": { "resolved": "packages/dappfence", "link": true }, - "node_modules/@dappfence/signer": { - "resolved": "packages/signer", + "node_modules/@dappfence/manifest-tools": { + "resolved": "packages/manifest-tools", + "link": true + }, + "node_modules/@dappfence/next": { + "resolved": "packages/next", "link": true }, "node_modules/@dappfence/test-app": { @@ -4334,9 +4340,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "packages/astro": { + "name": "@dappfence/astro", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@dappfence/core": "*", + "@dappfence/manifest-tools": "*" + }, + "devDependencies": {} + }, "packages/astro-integration": { "name": "@dappfence/astro", "version": "0.1.0", + "extraneous": true, "license": "MIT", "dependencies": { "@dappfence/core": "*", @@ -4354,20 +4371,42 @@ "devDependencies": { "@noble/hashes": "2.0.1", "@noble/secp256k1": "3.0.0", - "@vitest/coverage-v8": "4.1.4", "javascript-obfuscator": "4.1.1", "terser": "5.24.0", "vite": "7.3.2", - "vite-plugin-bundle-obfuscator": "1.8.0", - "vitest": "4.1.4" + "vite-plugin-bundle-obfuscator": "1.8.0" }, "engines": { "node": ">=16.0.0" } }, + "packages/manifest-tools": { + "name": "@dappfence/manifest-tools", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.0.1", + "@noble/secp256k1": "3.0.0" + }, + "devDependencies": {} + }, + "packages/next": { + "name": "@dappfence/next", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@dappfence/core": "*", + "@dappfence/manifest-tools": "*" + }, + "bin": { + "dappfence-next": "bin/dappfence-next.js" + }, + "devDependencies": {} + }, "packages/signer": { - "name": "@dappfence/signer", + "name": "@dappfence/manifest-tools", "version": "0.1.0", + "extraneous": true, "license": "MIT", "dependencies": { "@noble/hashes": "2.0.1", @@ -4379,7 +4418,7 @@ "version": "0.1.0", "dependencies": { "@dappfence/core": "*", - "@dappfence/signer": "*" + "@dappfence/manifest-tools": "*" }, "devDependencies": { "@playwright/test": "1.60.0", diff --git a/package.json b/package.json index 96d9794..76740fd 100644 --- a/package.json +++ b/package.json @@ -16,17 +16,19 @@ "lint": "eslint packages/ --fix", "check": "prettier --check \"*.md\" packages/ && eslint packages/", "format": "prettier --write packages/ *.md", - "publish:local": "npm run build:prod -w @dappfence/core && npm pack -w @dappfence/core --pack-destination dist && npm pack -w @dappfence/signer --pack-destination dist && npm pack -w @dappfence/astro --pack-destination dist", + "publish:local": "npm run publish:local --ws --if-present", "publish:core": "npm run check && npm run build:prod && npm publish -w @dappfence/core --access public", "prepare": "npm run check && npm run build:prod", "ci:install": "npm ci --ignore-scripts && npx playwright install chromium", "ci:local": "npm run ci:install && npm run check && npm test" }, "devDependencies": { + "@vitest/coverage-v8": "4.1.4", "eslint": "9.39.4", "eslint-config-prettier": "10.1.8", "prettier": "3.2.5", - "typescript-eslint": "8.58.2" + "typescript-eslint": "8.58.2", + "vitest": "4.1.4" }, "workspaces": [ "packages/*" diff --git a/packages/astro-integration/README.md b/packages/astro/README.md similarity index 100% rename from packages/astro-integration/README.md rename to packages/astro/README.md diff --git a/packages/astro-integration/package.json b/packages/astro/package.json similarity index 78% rename from packages/astro-integration/package.json rename to packages/astro/package.json index add5425..b17c8b6 100644 --- a/packages/astro-integration/package.json +++ b/packages/astro/package.json @@ -14,12 +14,13 @@ ], "scripts": { "test": "vitest run", - "test:coverage": "vitest run --coverage" + "test:coverage": "vitest run --coverage", + "publish:local": "npm pack --pack-destination ../../dist" }, "repository": { "type": "git", "url": "git+https://github.com/coinspect/dappfence.git", - "directory": "packages/astro-integration" + "directory": "packages/astro" }, "keywords": [ "astro", @@ -35,10 +36,6 @@ }, "dependencies": { "@dappfence/core": "*", - "@dappfence/signer": "*" - }, - "devDependencies": { - "@vitest/coverage-v8": "4.1.4", - "vitest": "4.1.4" + "@dappfence/manifest-tools": "*" } } diff --git a/packages/astro-integration/src/__tests__/manifest.test.js b/packages/astro/src/__tests__/manifest.test.js similarity index 100% rename from packages/astro-integration/src/__tests__/manifest.test.js rename to packages/astro/src/__tests__/manifest.test.js diff --git a/packages/astro-integration/src/index.js b/packages/astro/src/index.js similarity index 98% rename from packages/astro-integration/src/index.js rename to packages/astro/src/index.js index 5583863..d12e53b 100644 --- a/packages/astro-integration/src/index.js +++ b/packages/astro/src/index.js @@ -21,7 +21,7 @@ import { fileURLToPath } from 'node:url'; import { generateManifest } from './manifest.js'; const _require = createRequire(import.meta.url); -const { deriveIdentity } = _require('@dappfence/signer'); +const { deriveIdentity } = _require('@dappfence/manifest-tools'); const DAPPFENCE_JS_PATH = _require.resolve('@dappfence/core'); const DEFAULTS = { diff --git a/packages/astro/src/manifest.js b/packages/astro/src/manifest.js new file mode 100644 index 0000000..7526b09 --- /dev/null +++ b/packages/astro/src/manifest.js @@ -0,0 +1,44 @@ +import { createRequire } from 'node:module'; + +const _require = createRequire(import.meta.url); +const { + DEFAULT_EXTENSIONS, + buildScriptAttrs, + buildScriptTag, + injectScriptTag, + generateManifest: _generateManifest, +} = _require('@dappfence/manifest-tools/manifest'); + +export { DEFAULT_EXTENSIONS, buildScriptAttrs, buildScriptTag, injectScriptTag }; + +/** + * Extract server-rendered route patterns from the Astro routes array. + * Routes not marked isPrerendered are SSR (pages, server islands, API routes). + */ +export function extractDynamicRoutes(routes) { + if (!routes?.length) return []; + return routes + .filter((r) => !r.isPrerendered) + .map((r) => r.pattern) + .filter(Boolean); +} + +export function buildPageSet(pages) { + const set = new Set(); + for (const { pathname } of pages) { + const base = pathname.replace(/\/$/, ''); + set.add(base ? `${base}/index.html` : '/index.html'); + if (base) set.add(`${base}.html`); + } + return set; +} + +export async function generateManifest({ pages, routes, ...rest }) { + const dynamicRoutes = extractDynamicRoutes(routes); + const pageSet = pages?.length ? buildPageSet(pages) : null; + return _generateManifest({ + ...rest, + dynamicRoutes, + pageFilter: pageSet ? (webPath) => pageSet.has(webPath) : undefined, + }); +} diff --git a/packages/dappfence/package.json b/packages/dappfence/package.json index e091643..11ae587 100644 --- a/packages/dappfence/package.json +++ b/packages/dappfence/package.json @@ -39,16 +39,15 @@ "build:watch": "vite build --mode development --watch & vite build --mode production --watch", "clean": "rm -rf dist coverage", "test": "vitest run", - "test:coverage": "vitest run --coverage" + "test:coverage": "vitest run --coverage", + "publish:local": "npm run build:prod && npm pack --pack-destination ../../dist" }, "devDependencies": { "@noble/hashes": "2.0.1", "@noble/secp256k1": "3.0.0", - "@vitest/coverage-v8": "4.1.4", "javascript-obfuscator": "4.1.1", "terser": "5.24.0", "vite": "7.3.2", - "vite-plugin-bundle-obfuscator": "1.8.0", - "vitest": "4.1.4" + "vite-plugin-bundle-obfuscator": "1.8.0" } } diff --git a/packages/signer/.gitignore b/packages/manifest-tools/.gitignore similarity index 100% rename from packages/signer/.gitignore rename to packages/manifest-tools/.gitignore diff --git a/packages/manifest-tools/README.md b/packages/manifest-tools/README.md new file mode 100644 index 0000000..f496c19 --- /dev/null +++ b/packages/manifest-tools/README.md @@ -0,0 +1,150 @@ +# @dappfence/manifest-tools + +Core build-time tooling for [DappFence](../../README.md) — file hashing, manifest signing, directory +walking, and script tag injection. Used internally by framework integrations and +available as a standalone CLI for custom integrations. + +## Installation + +```bash +npm install @dappfence/manifest-tools +``` + +## CLI + +After installation, the `dappfence-manifest` binary is available. + +### `hash` — print SHA-256 of one or more files + +```bash +dappfence-manifest hash dist/app.js dist/style.css +# dist/app.js +# hex: 3a4f2b... +# sri: sha256-Ok8r... +# dist/style.css +# hex: 8c1d9f... +# sri: sha256-jB3p... +``` + +### `verify` — verify a manifest's signature + +```bash +dappfence-manifest verify out/integrity-manifest.json +# ✓ valid — signed by 0xAbC123... +``` + +### `sign` — generate a signed manifest from a directory + +Walks the directory, injects the dappfence script tag into every HTML file, hashes all tracked +files, signs, and writes `integrity-manifest.json`. + +```bash +dappfence-manifest sign ./out --secret-key +``` + +Generate a key once and store it in your CI secrets / `.env` file: + +```bash +node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +# → f0570667f495... +``` + +```bash +# .env (never commit this file) +DAPPFENCE_SECRET_KEY=f0570667f495... +``` + +The key can also be passed via the `DAPPFENCE_SECRET_KEY` environment variable instead of +`--secret-key`. + +#### Options for `sign` + +| Option | Default | Description | +| ---------------- | -------------------------- | ----------------------------------------------- | +| `--secret-key` | `DAPPFENCE_SECRET_KEY` env | Hex signing key (with or without `0x` prefix) | +| `--out` | `integrity-manifest.json` | Output manifest path, relative to `` | +| `--script-src` | `/dappfence.js` | Public URL where `dappfence.js` is served | +| `--manifest-url` | `/integrity-manifest.json` | Public URL where the manifest is served | +| `--mode` | `protected` | `protected` (blocks) or `reporting` (logs only) | +| `--no-inject` | — | Skip script tag injection into HTML files | +| `--ext` | `.js,.mjs,.css,.html,...` | Comma-separated file extensions to include | +| `--exclude` | — | Comma-separated web path prefixes to exclude | + +## Programmatic API + +### `calculateFileHash(input)` → `string` + +Returns an SRI hash (`sha256-`) for a file buffer or path. + +```js +const { calculateFileHash } = require('@dappfence/manifest-tools'); +const hash = calculateFileHash('/path/to/file.js'); +// → 'sha256-Ok8r...' +``` + +### `signManifest(payload, { secretKey })` → `object` + +Signs a manifest payload and returns `{ pay, sig, identity, signatureType }`. + +```js +const { signManifest } = require('@dappfence/manifest-tools'); +const manifest = signManifest({ files: { ... }, mode: 'protected', metadata: { ... } }, { secretKey }); +``` + +### `verifyManifest(manifestPath)` → `{ identity }` + +Verifies the signature on a manifest file against its embedded `identity`. Throws if unsigned or +signature does not match. + +```js +const { verifyManifest } = require('@dappfence/manifest-tools'); +const { identity } = verifyManifest('./out/integrity-manifest.json'); +``` + +### `deriveIdentity(secretKeyHex)` → `string` + +Derives the Ethereum signer address from a secret key. + +```js +const { deriveIdentity } = require('@dappfence/manifest-tools'); +const address = deriveIdentity(process.env.DAPPFENCE_SECRET_KEY); +// → '0xAbC123...' +``` + +### `generateManifest(opts)` (async) — from `@dappfence/manifest-tools/manifest` + +Walks a directory, optionally injects the dappfence script tag into HTML pages, hashes all tracked +files, and writes a signed manifest. + +```js +const { generateManifest } = require('@dappfence/manifest-tools/manifest'); + +await generateManifest({ + outDir: '/path/to/out', + manifestPath: 'integrity-manifest.json', + secretKey: process.env.DAPPFENCE_SECRET_KEY, + mode: 'protected', + scriptAttrs: { scriptSrc: '/dappfence.js', manifestUrl: '/integrity-manifest.json' }, + logger: console, +}); +``` + +### `buildScriptAttrs(opts)` / `buildScriptTag(opts)` — from `@dappfence/manifest-tools/manifest` + +Returns the script tag attributes as a plain object or as an HTML string. Framework-agnostic — use +whichever form your framework expects. + +```js +const { buildScriptAttrs, buildScriptTag } = require('@dappfence/manifest-tools/manifest'); + +// Plain object — works with React, Vue, Svelte, etc. +const attrs = buildScriptAttrs({ + scriptSrc: '/dappfence.js', + manifestUrl: '/integrity-manifest.json', +}); +// → { src: '/dappfence.js', 'data-manifest': '/integrity-manifest.json', ... } + +// HTML string — for direct injection into HTML files +const tag = buildScriptTag({ scriptSrc: '/dappfence.js', manifestUrl: '/integrity-manifest.json' }); +// → '' +``` diff --git a/packages/manifest-tools/package.json b/packages/manifest-tools/package.json new file mode 100644 index 0000000..c5151e7 --- /dev/null +++ b/packages/manifest-tools/package.json @@ -0,0 +1,41 @@ +{ + "name": "@dappfence/manifest-tools", + "version": "0.1.0", + "description": "Hashing, signing, and manifest generation tools for DappFence integrations", + "license": "MIT", + "type": "commonjs", + "main": "src/build.js", + "exports": { + ".": "./src/build.js", + "./crypto": "./src/crypto.js", + "./manifest": "./src/manifest.js" + }, + "bin": { + "dappfence-manifest": "./src/cli.js" + }, + "files": [ + "src", + "!src/__tests__" + ], + "scripts": { + "test": "vitest run", + "test:coverage": "vitest run --coverage", + "publish:local": "npm pack --pack-destination ../../dist" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/coinspect/dappfence.git", + "directory": "packages/manifest-tools" + }, + "keywords": [ + "security", + "integrity", + "manifest", + "sha256", + "signing" + ], + "dependencies": { + "@noble/hashes": "2.0.1", + "@noble/secp256k1": "3.0.0" + } +} diff --git a/packages/manifest-tools/src/__tests__/manifest.test.js b/packages/manifest-tools/src/__tests__/manifest.test.js new file mode 100644 index 0000000..15c73be --- /dev/null +++ b/packages/manifest-tools/src/__tests__/manifest.test.js @@ -0,0 +1,168 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; +import { createRequire } from 'module'; + +const _require = createRequire(import.meta.url); +const { DEFAULT_EXTENSIONS, buildScriptAttrs, buildScriptTag, injectScriptTag, generateManifest } = + _require('../manifest'); + +const MINIMAL = { scriptSrc: '/dappfence.js' }; +const LOGGER = { info: () => {}, warn: () => {}, error: () => {} }; + +let tmpDirs = []; +async function setup() { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'df-manifest-test-')); + tmpDirs.push(dir); + await fs.writeFile(path.join(dir, 'main.js'), 'console.log("hi")', 'utf8'); + await fs.writeFile( + path.join(dir, 'page.html'), + '', + 'utf8' + ); + return dir; +} + +afterEach(async () => { + for (const d of tmpDirs) await fs.rm(d, { recursive: true, force: true }); + tmpDirs = []; +}); + +describe('buildScriptAttrs', () => { + it('includes src from scriptSrc', () => { + expect(buildScriptAttrs(MINIMAL).src).toBe('/dappfence.js'); + }); + + it('omits falsy optional attributes', () => { + const attrs = buildScriptAttrs({ ...MINIMAL, appSW: null, warningUrl: null }); + expect(attrs).not.toHaveProperty('data-app-sw'); + expect(attrs).not.toHaveProperty('data-warning-url'); + }); + + it('includes all optional attributes when provided', () => { + const attrs = buildScriptAttrs({ + scriptSrc: '/dappfence.js', + manifestUrl: '/integrity-manifest.json', + manifestSignatureType: 'noble-secp256k1-recovered-eth', + manifestSignatureIdentity: '0xAbC123', + appSW: '/app-sw.js', + warningUrl: '/security-warning', + }); + expect(attrs['data-manifest']).toBe('/integrity-manifest.json'); + expect(attrs['data-manifest-signature-type']).toBe('noble-secp256k1-recovered-eth'); + expect(attrs['data-manifest-signature-identity']).toBe('0xAbC123'); + expect(attrs['data-app-sw']).toBe('/app-sw.js'); + expect(attrs['data-warning-url']).toBe('/security-warning'); + }); +}); + +describe('buildScriptTag', () => { + it('produces a valid script element', () => { + const tag = buildScriptTag(MINIMAL); + expect(tag).toMatch(/^`; } -export function injectScriptTag(html, scriptAttrs) { - const tag = buildScriptTag(scriptAttrs); - // Guard against double-injection on incremental rebuilds. +function injectScriptTag(html, opts) { + const tag = buildScriptTag(opts); if (html.includes(tag)) return html; return html.replace(/(]*>)/i, `$1\n ${tag}`); } @@ -64,64 +56,51 @@ async function walk(base, dir, extensions, excludes) { } /** - * Extract server-rendered route patterns from the Astro routes array. - * Returns route strings like '/blog/[slug]' and '/_server-islands/[name]'. + * Walk outDir, optionally inject the dappfence script tag into HTML pages, + * hash all tracked files, sign, and write the manifest. * - * Astro marks every route with `isPrerendered`. Routes that are not - * prerendered are rendered on demand (SSR pages, server islands, API routes). - * The SW can use these patterns in the future to skip full hash verification - * for requests that match them. + * @param {object} opts + * @param {string} opts.outDir - Absolute path to the output directory to walk + * @param {string} opts.manifestPath - Relative path for the manifest file (e.g. 'integrity-manifest.json') + * @param {string} opts.manifestUrl - Public URL where the manifest will be served (e.g. '/integrity-manifest.json') + * @param {string[]} [opts.extensions] - File extensions to track (defaults to DEFAULT_EXTENSIONS) + * @param {string[]} [opts.exclude] - Web path prefixes to skip + * @param {string} [opts.secretKey] - Hex secret key for signing; omit to produce unsigned manifest + * @param {string} [opts.mode] - Manifest mode ('protected' | 'reporting') + * @param {string[]} [opts.dynamicRoutes] - SSR route patterns to record in metadata (not hashed) + * @param {Function} [opts.pageFilter] - (webPath, ext) => bool; identifies HTML pages for script injection + * Defaults to any .html/.htm file + * @param {object} [opts.scriptAttrs] - Options passed to buildScriptAttrs for injection; omit to skip injection + * @param {object} opts.logger - Logger with .info / .warn / .error */ -export function extractDynamicRoutes(routes) { - if (!routes?.length) return []; - return routes - .filter((r) => !r.isPrerendered) - .map((r) => r.pattern) - .filter(Boolean); -} - -export function buildPageSet(pages) { - const set = new Set(); - for (const { pathname } of pages) { - const base = pathname.replace(/\/$/, ''); - set.add(base ? `${base}/index.html` : '/index.html'); - if (base) set.add(`${base}.html`); - } - return set; -} - -export async function generateManifest({ +async function generateManifest({ outDir, - pages, - routes, manifestPath, extensions, exclude, secretKey, mode, - logger, + dynamicRoutes, + pageFilter, scriptAttrs, + logger, }) { const exts = extensions || DEFAULT_EXTENSIONS; - // Always exclude the manifest file itself to avoid a circular reference. const excludes = [...(exclude || []), '/' + manifestPath]; + const isPage = pageFilter || ((_webPath, ext) => ext === '.html' || ext === '.htm'); - const dynamicRoutes = extractDynamicRoutes(routes); - if (dynamicRoutes.length) { + if (dynamicRoutes?.length) { logger.info(`DappFence: ${dynamicRoutes.length} dynamic (SSR) routes captured`); } const files = await walk(outDir, outDir, exts, excludes); logger.info(`DappFence: hashing ${files.length} files`); - const pageSet = pages?.length ? buildPageSet(pages) : null; - const fileHashes = {}; for (const { webPath, absPath, ext } of files) { let buf = await fs.readFile(absPath); - const isPage = pageSet ? pageSet.has(webPath) : ext === '.html' || ext === '.htm'; - if (isPage) { + if (scriptAttrs && isPage(webPath, ext)) { const html = buf.toString('utf8'); const injected = injectScriptTag(html, scriptAttrs); if (injected !== html) { @@ -141,7 +120,7 @@ export async function generateManifest({ extensions: exts, buildTime: new Date().toISOString(), version: 'latest', - ...(dynamicRoutes.length && { dynamicRoutes }), + ...(dynamicRoutes?.length && { dynamicRoutes }), }, }; @@ -163,3 +142,12 @@ export async function generateManifest({ await fs.writeFile(out, JSON.stringify(manifest, null, 2), 'utf8'); logger.info(`DappFence: manifest written → ${manifestPath}`); } + +module.exports = { + DEFAULT_EXTENSIONS, + buildScriptAttrs, + buildScriptTag, + injectScriptTag, + walk, + generateManifest, +}; diff --git a/packages/next/README.md b/packages/next/README.md new file mode 100644 index 0000000..89fb225 --- /dev/null +++ b/packages/next/README.md @@ -0,0 +1,138 @@ +# @dappfence/next + +Next.js integration for [DappFence](../../README.md) — automatically copies the security script, +hashes static assets, and generates a signed integrity manifest at build time. + +## Installation + +```bash +npm install @dappfence/next @dappfence/core +``` + +`@dappfence/core` provides the `dappfence.js` runtime copied into your output. + +## Setup + +### SSR / hybrid apps (default Next.js mode) + +Wrap your Next.js config with `withDappfence`: + +```js +// next.config.js +import { withDappfence } from '@dappfence/next'; + +export default withDappfence({ + secretKey: process.env.DAPPFENCE_SECRET_KEY, +})(nextConfig); +``` + +Then add the script tag to your root layout. Use `buildScriptAttrs` from `@dappfence/manifest-tools` +to get the correct attributes: + +```jsx +// app/layout.js +import { buildScriptAttrs } from '@dappfence/manifest-tools/manifest'; + +const dfAttrs = buildScriptAttrs({ + scriptSrc: '/dappfence.js', + manifestUrl: '/integrity-manifest.json', +}); + +export default function RootLayout({ children }) { + return ( + + + +``` + +The browser fetches the URL, parses, and executes the response. Async/defer variants change when +execution happens but not how the resource is fetched. + +**CSP**: controlled by `script-src` directive. Nonce or hash is required under strict CSP. + +**DappFence**: **SW intercept** the fetch fires, DappFence hashes the response body and blocks or +warns if it does not match the manifest. + +**`no-cors` / opaque response issue**: a ` +``` + +No network request. The script text is part of the HTML document. + +**CSP**: requires `'unsafe-inline'`, a nonce (`'nonce-...'`), or a hash (`'sha256-...'`). + +**DappFence**: **SW intercept (inline-script)** the manifest-rules design introduces a synthetic +`inline-script` destination. After the SW fetches an HTML response, it extracts all ` +``` + +Module scripts are fetched with CORS semantics. Imported submodules are fetched recursively. +Execution is always deferred. + +**CSP**: `script-src` with strict-dynamic or explicit hash/nonce. + +**DappFence**: **SW intercept** every module fetch (entry and transitive imports) fires the SW fetch +event. Each can be individually verified. + +--- + +### 1d. Inline module script + +```html + +``` + +The inline portion is not fetched; the imported URLs are. + +**DappFence**: inline portion → **SW intercept (inline-script)** the inline module text is extracted +and verified via `#scripts`, same as section 1b (the extractor keeps `type="module"`). Imported URLs +→ **SW intercept**. Both phases work on SSR routes. + +--- + +## 2. HTML event handler attributes + +```html + + + +
+ +
+ +``` + +Any element can carry `on*` attributes. The attribute value is compiled into a function and called +on the corresponding DOM event. No network request. + +**CSP**: blocked by `'unsafe-inline'` being absent from `script-src`. Nonce/hash on attributes is +not supported the only safe option is removing `'unsafe-inline'`. + +**DappFence**: **HTML doc** the handler text lives in the HTML response body. Covered on static +routes because the full document hash is in the manifest. + +**On SSR routes this is not yet covered; the planned `#handlers` DOMParser pass (see below) closes +it.** The `inline-script` extraction mechanism only finds `'); +document.write(''); +``` + +`src`-bearing scripts injected this way do generate a fetch. Inline text does not. + +**CSP**: `'unsafe-inline'` required for the `document.write` call itself if it comes from an inline +handler; the injected ` +``` + +Remaps bare specifiers to URLs. The actual fetch happens when the remapped module is imported. + +**DappFence**: The importmap JSON is **HTML doc** on static routes (covered by document hash). The +remapped module URLs are fetched → **SW intercept** (unknown URLs fail the manifest check). + +**On SSR routes the importmap content is not yet covered; planned via `#importmap` (same DOMParser +pass).** The `inline-script` extractor explicitly skips `type="importmap"`. An attacker can inject a +remap that redirects a bare specifier (e.g., `"react"`) to an attacker-controlled URL. The SW will +block unknown URLs, but remapping to an already-manifest-listed URL (e.g., redirecting `"react"` to +`/app.js`) passes undetected, the SW verifies the hash of the wrong module and passes it through +with no violation signal. An attacker with read-access to the manifest (a public signed document) +can list every listed URL and craft a targeted remap with no new external dependency. This is a +complete bypass on any SSR route that uses bare module specifiers. + +**Planned mitigation, same DOMParser pass as `#handlers`.** The DOMParser walk planned for `on*` +attributes and `javascript:` hrefs (see section 2) visits the full DOM. Adding importmap extraction +to that pass is straightforward: collect the text content of every ` +``` + +Blocked by all major modern browsers at the engine level regardless of CSP (Chrome 68+, Firefox, +Safari). Was a classic CSP bypass in older browsers. + +**CSP**: `data:` is not implicitly allowed in `script-src`; must be explicitly listed. Browser +enforcement is the primary barrier; CSP is a secondary layer. + +**DappFence**: **Browser-blocked** — no fetch event fires for `data:` URIs; engine-level enforcement +in all modern browsers makes this dead. For SSR routes, the DOMParser extraction phase detects an +unexpected `"> +``` + +The iframe has a null (opaque) origin. In modern browsers (Chrome 80+, Firefox, Safari), `data:` +iframes are assigned a unique opaque origin — direct DOM access to the parent (`parent.steal()`) +throws a cross-origin `SecurityError`. The `document.domain` setter that older browsers used to +bridge origins is deprecated and no-ops in current engines. + +**CSP**: blocked by `frame-src` if `data:` is not listed. + +**DappFence**: **Browser-mitigated** — no fetch event fires for the iframe content; direct DOM +access is blocked by the null-origin restriction in modern browsers. + +The **real residual concerns** are narrower: + +- **Parent navigation**: cross-origin frames can still write to `parent.location.href`, navigating + the parent page away. This does not execute arbitrary script in the parent but can redirect + users to an attacker-controlled page. +- **`postMessage` chaining**: a null-origin iframe can call `parent.postMessage(payload, '*')`. If + the parent has any `message` listener that does not check `event.origin` and passes data to an + eval-equivalent API, this chains into the 16b (External data) vector. + +For SSR routes, the DOMParser extraction phase can detect an injected `