-
Potentially Malicious Content Blocked
-
+
+ Potentially Malicious Content Blocked
+
+
DappFence has detected that content on this page has been modified and may
be unsafe. This protection prevents potentially malicious code from running
in your browser.
@@ -56,6 +58,7 @@
Active Blocks0
const {
apiToken = null,
activeBlocks = [],
+ summary = null,
autoConfirmSiteLock = false,
} = DAPPFENCE_CONFIG;
@@ -65,6 +68,7 @@
Active Blocks0
document.getElementById('security-timestamp').textContent =
'Blocked at ' + new Date().toLocaleString();
+ applySummary(summary);
renderBlocks(activeBlocks);
// Site unblock button — clears ALL active blocks at once
@@ -139,6 +143,18 @@
Active Blocks0
}
});
+ function applySummary(summary) {
+ if (!summary) {
+ return;
+ }
+ if (summary.subtitle) {
+ document.getElementById('security-subtitle').textContent = summary.subtitle;
+ }
+ if (summary.message) {
+ document.getElementById('security-message').textContent = summary.message;
+ }
+ }
+
function renderBlocks(blocks) {
// Only show the Active Blocks section when we actually have
// blocks to show. In the normal flow there's always ≥1
@@ -161,32 +177,57 @@
Active Blocks0
card.open = true;
}
- const summary = document.createElement('summary');
- summary.className = 'block-summary';
+ const view = block.view || {};
+ const summaryEl = document.createElement('summary');
+ summaryEl.className = 'block-summary';
+ const heading = document.createElement('div');
+ heading.className = 'block-heading';
+ if (view.title) {
+ const title = document.createElement('span');
+ title.className = 'block-title';
+ title.textContent = view.title;
+ heading.appendChild(title);
+ }
const name = document.createElement('span');
name.className = 'block-name';
name.textContent = block.fileKey || 'Unknown';
name.title = block.fileKey || 'Unknown';
- summary.appendChild(name);
+ heading.appendChild(name);
+ summaryEl.appendChild(heading);
const count = block.occurrenceCount || 1;
const badge = document.createElement('span');
badge.className = 'occurrence-badge';
badge.textContent = `${count}×`;
badge.title = `${count} occurrence${count > 1 ? 's' : ''}`;
- summary.appendChild(badge);
+ summaryEl.appendChild(badge);
- card.appendChild(summary);
+ card.appendChild(summaryEl);
const body = document.createElement('div');
body.className = 'block-body';
- body.appendChild(
- infoRow('Expected hash', block.expectedHash || 'Not available', 'hash')
- );
- body.appendChild(
- infoRow('Actual hash', block.actualHash || 'Not available', 'hash')
- );
+ if (view.expectedLabel) {
+ body.appendChild(
+ infoRow(
+ view.expectedLabel,
+ formatList(block.expectedHashes),
+ 'hash'
+ )
+ );
+ }
+ if (view.actualLabel) {
+ body.appendChild(
+ infoRow(
+ view.actualLabel,
+ block.actualHash || 'Not available',
+ 'hash'
+ )
+ );
+ }
+ if (block.reason) {
+ body.appendChild(infoRow('Reason', block.reason));
+ }
body.appendChild(
infoRow('First seen', block.formattedTimestamp || 'Unknown')
);
@@ -196,6 +237,13 @@
Active Blocks0
});
}
+ function formatList(values) {
+ if (!Array.isArray(values) || values.length === 0) {
+ return 'Not available';
+ }
+ return values.join(', ');
+ }
+
function infoRow(label, value, extraValueClass) {
const row = document.createElement('div');
row.className = 'info-row';
diff --git a/packages/test-app/src/build-config.js b/packages/test-app/src/build-config.js
index 38926ef4..9ce3dc54 100644
--- a/packages/test-app/src/build-config.js
+++ b/packages/test-app/src/build-config.js
@@ -24,10 +24,11 @@ const directories = {
templateDir: path.resolve(ROOT_DIR, 'template'),
};
-// This key is used for TESTING only
-const secretKey = hexToBytes('46c88fcabce00eced90f15ceb9325fd879e44b43c623b174416a219a6103e05d');
-const publicKey = getPublicKey(secretKey);
-const keys = { publicKey, secretKey };
+// These keys are used for TESTING only
+const secretKeyA = hexToBytes('46c88fcabce00eced90f15ceb9325fd879e44b43c623b174416a219a6103e05d');
+const secretKeyB = hexToBytes('a1b2c3d4e5f6071829304a5b6c7d8e9f0011223344556677889900aabbccddee');
+const keyPairA = { publicKey: getPublicKey(secretKeyA), secretKey: secretKeyA };
+const keyPairB = { publicKey: getPublicKey(secretKeyB), secretKey: secretKeyB };
const defaultManifest = {
mode: MODE.PROTECTED,
@@ -62,6 +63,7 @@ const defaultManifest = {
'/csp-test-denied': [],
},
},
+ keyPair: keyPairA,
};
const simpleAppPages = {
@@ -82,6 +84,14 @@ const simpleAppPages = {
template: 'simple-app.html',
manifest: 'csp-report-only-manifest.json',
},
+ 'signer-b.html': {
+ template: 'simple-app.html',
+ manifest: 'integrity-manifest-signer-b.json',
+ },
+ 'revoke.html': {
+ template: 'simple-app.html',
+ manifest: 'integrity-manifest-revoke.json',
+ },
};
const simpleAppBase = {
@@ -108,6 +118,8 @@ const simpleAppBase = {
reportSample: true,
},
},
+ 'integrity-manifest-signer-b.json': { ...defaultManifest, keyPair: keyPairB },
+ 'integrity-manifest-revoke.json': { ...defaultManifest, revokeManifests: true },
},
pages: simpleAppPages,
};
@@ -130,6 +142,7 @@ const BUILD_CONFIGURATIONS = {
description: 'Tampering Security Test',
manifests: {
'tampering-test-manifest.json': {
+ keyPair: keyPairA,
pathRules: [{ type: 'directory-index' }],
},
},
@@ -143,14 +156,10 @@ const BUILD_CONFIGURATIONS = {
'reporting-test': {
...simpleAppBase,
templateFlags: { USE_SW_REGISTER: true, USE_APP: false },
- manifests: {
- 'integrity-manifest.json': { ...defaultManifest, mode: MODE.REPORTING },
- 'no-not-found-manifest.json': {
- ...defaultManifest,
- pathRules: [{ type: 'directory-index' }],
- mode: MODE.REPORTING,
- },
- },
+ manifests: Object.entries(simpleAppBase.manifests).reduce((acc, [k, m]) => {
+ acc[k] = { ...m, mode: MODE.REPORTING };
+ return acc;
+ }, {}),
},
};
@@ -167,4 +176,4 @@ for (const env in DAPPFENCE_PACKAGES) {
};
}
}
-export { OUT_DIR, BUILD_TARGETS, keys, EXTERNAL_ASSETS };
+export { OUT_DIR, BUILD_TARGETS, EXTERNAL_ASSETS };
diff --git a/packages/test-app/src/build.js b/packages/test-app/src/build.js
index 0224f33d..068d2596 100644
--- a/packages/test-app/src/build.js
+++ b/packages/test-app/src/build.js
@@ -18,7 +18,7 @@ import {
bytesToHex,
keccak256,
} from '@dappfence/manifest-tools/crypto';
-import { BUILD_TARGETS, OUT_DIR, keys, EXTERNAL_ASSETS } from './build-config.js';
+import { BUILD_TARGETS, OUT_DIR, EXTERNAL_ASSETS } from './build-config.js';
let log = console.log;
@@ -52,8 +52,18 @@ function render(inputPath, outFile, signatureData, args) {
log(` Render: ${outputPath}`);
}
-function renderPages(target, outDir, signatureData, version) {
+function renderPages(target, outDir, { personalSign }, version) {
for (const [output, page] of Object.entries(target.pages || {})) {
+ const manifestConfig = target.manifests?.[page.manifest];
+ if (!manifestConfig?.keyPair) {
+ throw new Error(
+ `Page "${output}" references manifest "${page.manifest}" with no signing keyPair`
+ );
+ }
+ const signatureData = {
+ identity: ethereumAddress(manifestConfig.keyPair.publicKey),
+ type: personalSign ? 'personal-sign-alt' : 'noble-secp256k1-recovered-eth',
+ };
const pageDir = path.dirname(path.join(outDir, output));
if (!fs.existsSync(pageDir)) fs.mkdirSync(pageDir, { recursive: true });
render(path.join(target.templateDir, page.template), output, signatureData, {
@@ -120,8 +130,12 @@ function hashOutputFiles(outDir) {
return files;
}
-async function writeSignedManifest(manifests, outDir, { personalSign, signatureData }) {
- for (const { manifestFile, data } of manifests) {
+async function writeSignedManifest(manifests, outDir, { personalSign }) {
+ for (const { manifestFile, data, keyPair } of manifests) {
+ const signatureData = {
+ identity: ethereumAddress(keyPair.publicKey),
+ type: personalSign ? 'personal-sign-alt' : 'noble-secp256k1-recovered-eth',
+ };
let signedManifest;
if (personalSign) {
const msg = new TextEncoder('utf-8').encode(JSON.stringify(data, null, 2));
@@ -148,18 +162,21 @@ async function writeSignedManifest(manifests, outDir, { personalSign, signatureD
}
signedManifest = { pay: data, sig: signature };
} else {
- const { pay, sig } = signManifest(data, keys);
+ const { pay, sig } = signManifest(data, keyPair);
signedManifest = { pay, sig };
}
const outPath = path.join(outDir, manifestFile);
fs.writeFileSync(outPath, JSON.stringify(signedManifest, null, 2));
- log(`Manifest written to: ${outPath}`);
+ log(`Manifest ${manifestFile} signed by ${signatureData.identity}, written to: ${outPath}`);
}
}
async function buildManifestsData(target, sharedFiles, { targetName, version, outDir }) {
const results = [];
for (const [manifestFile, manifest] of Object.entries(target.manifests || {})) {
+ if (!manifest.keyPair) {
+ throw new Error(`Manifest "${manifestFile}" has no signing keyPair declared`);
+ }
const additionalFileHashes = {};
for (const [manifestKey, relPaths] of Object.entries(manifest.additionalFiles || {})) {
const hashes = relPaths.map((p) => calculateFileHash(path.join(target.assetDir, p)));
@@ -201,11 +218,12 @@ async function buildManifestsData(target, sharedFiles, { targetName, version, ou
pathRules: manifest.pathRules || [],
contentRules: manifest.contentRules || null,
...(csp ? { csp } : {}),
+ ...(manifest.revokeManifests ? { revokeManifests: true } : {}),
metadata: { buildTime: new Date().toISOString(), version, target: targetName },
};
log(`Manifest ${manifestFile}: ${Object.keys(data.files).length} assets`);
- results.push({ manifestFile, data });
+ results.push({ manifestFile, data, keyPair: manifest.keyPair });
}
return results;
}
@@ -216,21 +234,15 @@ async function buildTarget(targetName, target, { personalSign = false }, version
if (fs.existsSync(outDir)) fs.rmSync(outDir, { recursive: true, force: true });
fs.mkdirSync(outDir, { recursive: true });
- const signatureData = {
- identity: ethereumAddress(keys.publicKey),
- type: personalSign ? 'personal-sign-alt' : 'noble-secp256k1-recovered-eth',
- };
- log(`Manifest signer: ${signatureData.identity}`);
-
copySourceFiles(target, outDir, version);
- renderPages(target, outDir, signatureData, version);
+ renderPages(target, outDir, { personalSign }, version);
const sharedFiles = hashOutputFiles(outDir);
const manifests = await buildManifestsData(target, sharedFiles, {
targetName,
version,
outDir,
});
- await writeSignedManifest(manifests, outDir, { personalSign, signatureData });
+ await writeSignedManifest(manifests, outDir, { personalSign });
}
// CLI
diff --git a/packages/test-app/test/common/sw-identity-rotation.spec.ts b/packages/test-app/test/common/sw-identity-rotation.spec.ts
new file mode 100644
index 00000000..05541ced
--- /dev/null
+++ b/packages/test-app/test/common/sw-identity-rotation.spec.ts
@@ -0,0 +1,109 @@
+import { expect, test } from '../sw-fixtures';
+import type { Page } from '@playwright/test';
+
+async function readIDB
(page: Page, key: string): Promise {
+ return page.evaluate((k) => {
+ return new Promise((resolve, reject) => {
+ const req = indexedDB.open('AppSecurity', 1);
+ req.onerror = () => reject(req.error);
+ req.onsuccess = () => {
+ const db = req.result;
+ const tx = db.transaction(['data'], 'readonly');
+ const getReq = tx.objectStore('data').get(k);
+ getReq.onsuccess = () => resolve(getReq.result);
+ getReq.onerror = () => reject(getReq.error);
+ };
+ });
+ }, key) as Promise;
+}
+
+type BlockRecord = {
+ status: string;
+ reason?: string;
+ expectedHashes?: string[];
+ actualHash?: string;
+};
+
+async function readActiveBlocks(page: Page): Promise {
+ const ids = (await readIDB(page, 'active-block-ids')) ?? [];
+ const blocks = (await readIDB>(page, 'blocks')) ?? {};
+ return ids.map((id) => blocks[id]).filter(Boolean);
+}
+
+async function waitForAnchoredIdentity(page: Page, timeoutMs = 5000) {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ const anchor = await readIDB<{ identity?: string }>(page, 'active-identity');
+ if (anchor?.identity) {
+ return;
+ }
+ await new Promise((r) => setTimeout(r, 100));
+ }
+ throw new Error('timed out waiting for active-identity to be anchored');
+}
+
+async function waitForActiveBlock(page: Page, timeoutMs = 10000) {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ const blocks = await readActiveBlocks(page);
+ if (blocks.length > 0) {
+ return blocks;
+ }
+ await new Promise((r) => setTimeout(r, 100));
+ }
+ throw new Error('timed out waiting for an active block');
+}
+
+test('SIGNER_CHANGED when navigating from signer A to signer B', async ({ page, swHelper }) => {
+ await page.goto('/');
+ await expect(page).toHaveTitle('DappFence - Manifest Mode Example');
+ await swHelper.waitForServiceWorkerActivation();
+ await waitForAnchoredIdentity(page);
+
+ await page.goto('/signer-b.html');
+ const [block] = await waitForActiveBlock(page);
+ expect(block.status).toBe('MANIFEST_UNTRUSTED');
+ expect(block.reason).toBe('SIGNER_CHANGED');
+ await page.reload();
+ await page.waitForURL(/.*\/sw-api\/security-warning/);
+});
+
+test('Remove Site Lock accepts rotation and lets signer B load normally', async ({
+ page,
+ swHelper,
+}) => {
+ await page.goto('/');
+ await expect(page).toHaveTitle('DappFence - Manifest Mode Example');
+ await swHelper.waitForServiceWorkerActivation();
+ await waitForAnchoredIdentity(page);
+
+ await page.goto('/signer-b.html').catch(() => {});
+ await waitForActiveBlock(page);
+
+ page.on('dialog', async (dialog) => {
+ await dialog.accept();
+ });
+ const unblock = page.getByRole('button', { name: 'Remove Site Lock' });
+ await unblock.waitFor({ state: 'visible', timeout: 10000 });
+ await unblock.click();
+
+ await page.goto('/signer-b.html').catch(() => {});
+ await expect(page).toHaveTitle('DappFence - Manifest Mode Example');
+});
+
+test('revokeManifests flag wipes stored manifest history', async ({ page, swHelper }) => {
+ await page.goto('/');
+ await expect(page).toHaveTitle('DappFence - Manifest Mode Example');
+ await swHelper.waitForServiceWorkerActivation();
+ await waitForAnchoredIdentity(page);
+
+ const before = (await readIDB(page, 'trusted-manifest')) ?? [];
+ expect(before.length).toBeGreaterThanOrEqual(1);
+
+ await page.goto('/revoke.html');
+ await expect(page).toHaveTitle('DappFence - Manifest Mode Example');
+ await swHelper.waitForServiceWorkerActivation();
+
+ const after = (await readIDB(page, 'trusted-manifest')) ?? [];
+ expect(after).toHaveLength(1);
+});