OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop commits / 27cae62

Harden P2P plugin and filter distribution
27cae62694963d345f08911dd84408913e23114d
by Local LLM <localllm@local> · 2026-07-16T10:25:58.000Z

Changed files (17) +306 -72

CHANGELOG.md +3 -0
……@@ -35,6 +35,9 @@ checksum-verified, pinned on HiveRelay, hot-swappable, and usable offline.
3535
3636- A list checksum mismatch, malformed/oversized drive, missing capability, or
3737 silent plugin capability increase fails closed before any contribution runs.
38- Plugin install/re-consent grants are bound to the exact reviewed manifest and
39 asset snapshot; community catalogues cannot mint the browser's curated mark,
40 and third-party CSS cannot break out of its browser-owned style element.
3841- Blocking remains browser-owned: denied requests never reach peers, relays, or
3942 clearnet, and cached lists keep working offline.
4043
README.md +16 -16
……@@ -10,15 +10,15 @@ A local-first peer-to-peer browser, app store, search engine, naming layer, Nost
1010
1111## Install it
1212
13Primary desktop distribution is now native GitHub release packages. The current
14`v0.6.1` release has attached macOS, Windows, and Linux assets with SHA-256
15sidecars and platform manifests. Download from the
16[`v0.6.1` release](https://github.com/bigdestiny2/pearbrowser-desktop/releases/tag/v0.6.1),
13Primary desktop distribution is now native GitHub release packages. The
14`v0.7.0` release targets macOS, Windows, and Linux assets with SHA-256 sidecars
15and platform manifests. Download from the
16[`v0.7.0` release](https://github.com/bigdestiny2/pearbrowser-desktop/releases/tag/v0.7.0),
1717follow the [native install guide](./docs/INSTALL_NATIVE_PACKAGES.md), or resolve
1818the recommended asset for your machine from a source checkout:
1919
2020```sh
21npm run resolve:native-release -- --tag v0.6.1 --repo bigdestiny2/pearbrowser-desktop
21npm run resolve:native-release -- --tag v0.7.0 --repo bigdestiny2/pearbrowser-desktop
2222```
2323
2424The current package targets match the `cmake-pear` appling toolchain:
……@@ -224,29 +224,29 @@ node scripts/verify-live-catalog.js --expect-app peercord --expect-app peerit --
224224
225225## Distribution
226226
227The `appling/` directory contains the multi-architecture native shell — Bare + CMake builds for macOS / Windows / Linux. GitHub release assets are produced by `.github/workflows/desktop-native-release.yml`, which builds the appling on hosted macOS, Windows, and Linux runners, collects the native artifacts, writes SHA-256 sidecars, and attaches them to the matching release tag. Run the workflow manually with tag `v0.6.1` and `source_ref` set to the release commit to produce or refresh the attached release assets.
227The `appling/` directory contains the multi-architecture native shell — Bare + CMake builds for macOS / Windows / Linux. GitHub release assets are produced by `.github/workflows/desktop-native-release.yml`, which builds the appling on hosted macOS, Windows, and Linux runners, collects the native artifacts, writes SHA-256 sidecars, and attaches them to the matching release tag. Run the workflow manually with tag `v0.7.0` and `source_ref` set to the release commit to produce or refresh the attached release assets.
228228
229229Current generated artifacts are `.app.zip` on macOS, `.msix` on Windows, and `.AppImage` on Linux. The workflow uses `npm ci --prefix appling`, so update `appling/package-lock.json` deliberately when the native wrapper toolchain changes.
230230
231231```sh
232npm run check:appling-release -- --tag v0.6.1
232npm run check:appling-release -- --tag v0.7.0
233233npm run check:linux-appimage-metadata
234npm run resolve:native-release -- --tag v0.6.1 --repo bigdestiny2/pearbrowser-desktop
235npm run -s generate:native-signing-secret-plan -- --repo bigdestiny2/pearbrowser-desktop --tag v0.6.1 --source-ref <release-commit>
236npm run -s generate:native-install-snippet -- --tag v0.6.1 --repo bigdestiny2/pearbrowser-desktop
237npm run -s generate:native-install-guide -- --tag v0.6.1 --repo bigdestiny2/pearbrowser-desktop
238npm run -s generate:native-install-smoke-plan -- --tag v0.6.1 --repo bigdestiny2/pearbrowser-desktop --source-ref <release-commit>
239npm run generate:package-manager-manifests -- --tag v0.6.1 --repo bigdestiny2/pearbrowser-desktop --trust-mode package-proof
234npm run resolve:native-release -- --tag v0.7.0 --repo bigdestiny2/pearbrowser-desktop
235npm run -s generate:native-signing-secret-plan -- --repo bigdestiny2/pearbrowser-desktop --tag v0.7.0 --source-ref <release-commit>
236npm run -s generate:native-install-snippet -- --tag v0.7.0 --repo bigdestiny2/pearbrowser-desktop
237npm run -s generate:native-install-guide -- --tag v0.7.0 --repo bigdestiny2/pearbrowser-desktop
238npm run -s generate:native-install-smoke-plan -- --tag v0.7.0 --repo bigdestiny2/pearbrowser-desktop --source-ref <release-commit>
239npm run generate:package-manager-manifests -- --tag v0.7.0 --repo bigdestiny2/pearbrowser-desktop --trust-mode package-proof
240240npm run check:native-signing -- --require-public-trust --secret-source github --repo bigdestiny2/pearbrowser-desktop
241npm run check:public-trust-readiness -- --tag v0.6.1 --repo bigdestiny2/pearbrowser-desktop --source-ref <release-commit> --signing-secret-source github
242npm run -s generate:public-trust-operator-report -- --tag v0.6.1 --repo bigdestiny2/pearbrowser-desktop --source-ref <release-commit> --signing-secret-source github
241npm run check:public-trust-readiness -- --tag v0.7.0 --repo bigdestiny2/pearbrowser-desktop --source-ref <release-commit> --signing-secret-source github
242npm run -s generate:public-trust-operator-report -- --tag v0.7.0 --repo bigdestiny2/pearbrowser-desktop --source-ref <release-commit> --signing-secret-source github
243243npm run -s generate:release-evidence-handoff
244244cd appling
245245npm ci
246246npm run generate
247247npm run build
248248cd ..
249npm run package:appling -- --tag v0.6.1
249npm run package:appling -- --tag v0.7.0
250250```
251251
252252Code signing is per-platform:
backend/constants.js +2 -2
……@@ -209,8 +209,8 @@ const CMD_PLUGIN_REGISTER = 237 // { id, manifest, contribution?, enabled? }
209209const CMD_SHIELD_SUBSCRIBE_LIST = 239 // { driveKey } → subscribe to a filter-list drive
210210const CMD_SHIELD_UNSUBSCRIBE_LIST = 240 // { driveKey }
211211const CMD_SHIELD_REFRESH_LISTS = 241 // { driveKey?, force? } → refresh one or all subscriptions
212const CMD_PLUGIN_INSTALL_DRIVE = 242 // { driveKey, granted? } → install a plugin from its drive
213const CMD_PLUGIN_UPDATE_DRIVE = 243 // { driveKey, acceptEscalation? }
212const CMD_PLUGIN_INSTALL_DRIVE = 242 // preview {driveKey}; accept {driveKey, granted, reviewedFingerprint}
213const CMD_PLUGIN_UPDATE_DRIVE = 243 // update/preview escalation; accept with grant + reviewedFingerprint
214214const CMD_PLUGIN_UNINSTALL = 244 // { driveKey }
215215const CMD_PLUGIN_CATALOG = 245 // → { entries, sources } (builtin seed + subscribed catalogue drives)
216216const CMD_PLUGIN_CATALOG_LOAD_DRIVE = 246 // { driveKey } → subscribe to a catalogue drive (/plugins.json)
backend/content-shield.cjs +3 -1
……@@ -731,7 +731,9 @@ function parseFilterList (text) {
731731 if (!/[\s#]/.test(rawHostPart)) {
732732 const hostPart = normalizeHost(rawHostPart)
733733 const selector = line.slice(cosmeticIndex + 2).trim()
734 if (selector && !selector.includes('{') && !selector.includes('}')) {
734 // Selectors are embedded into a browser-owned <style> block. Reject
735 // markup delimiters here and also escape style text at the proxy sink.
736 if (selector && !/[{}<>]/.test(selector)) {
735737 cosmetic.push({ host: hostPart || null, selector })
736738 }
737739 continue
backend/hyper-proxy.js +13 -3
……@@ -201,6 +201,15 @@ function escapeHtml (str) {
201201 .replace(/`/g, '&#96;')
202202}
203203
204// A <style> element is an HTML raw-text container: the HTML parser will end it
205// on a literal "</style" even when those bytes came from otherwise-valid CSS.
206// Escape every less-than sign as a CSS code point before embedding third-party
207// filter/plugin CSS so a style-only capability can never become script markup.
208function escapeStyleText (css) {
209 if (typeof css !== 'string') return ''
210 return css.replace(/</g, '\\3c ')
211}
212
204213class HyperProxy {
205214 constructor (getDrive, onError, relayClient, opts = {}) {
206215 this._getDrive = getDrive // async (keyHex) => Hyperdrive
……@@ -543,8 +552,8 @@ class HyperProxy {
543552 (includeAnongpt ? this._anongptShim : '') +
544553 scriptletTags.join('') +
545554 pluginScriptTags.join('') +
546 (shieldCss ? `<style data-pear-shield>${shieldCss}</style>` : '') +
547 (pluginCss ? `<style data-pear-plugin-style>${pluginCss}</style>` : '')
555 (shieldCss ? `<style data-pear-shield>${escapeStyleText(shieldCss)}</style>` : '') +
556 (pluginCss ? `<style data-pear-plugin-style>${escapeStyleText(pluginCss)}</style>` : '')
548557 let injected = html.includes('<head>')
549558 ? html.replace('<head>', `<head>${headInjection}`)
550559 : html.replace(/<html>/i, `<html><head>${headInjection}</head>`)
……@@ -1222,5 +1231,6 @@ li{padding:8px 0;border-bottom:1px solid #333}a{color:#4dabf7;text-decoration:no
12221231module.exports = {
12231232 HyperProxy,
12241233 getUserFriendlyError,
1225 USER_FRIENDLY_ERRORS
1234 USER_FRIENDLY_ERRORS,
1235 escapeStyleText
12261236}
backend/index.js +20 -4
……@@ -12,6 +12,7 @@ const Hyperdrive = require('hyperdrive')
1212const b4a = require('b4a')
1313const z32 = require('z32')
1414const fs = require('bare-fs')
15const crypto = require('bare-crypto')
1516
1617// Normalize a Hyperdrive key from either 64-char hex or
1718// 52-char z-base-32 (Vinjari-style) into lowercase hex.
……@@ -501,7 +502,8 @@ rpc.handle(C.CMD_PLUGIN_INSTALL_DRIVE, async (data = {}) => {
501502 await whenReady()
502503 if (!pluginDriveLoader) throw new Error('Plugin drive loader not available')
503504 const result = await pluginDriveLoader.installFromDrive(data.driveKey, {
504 grantedCapabilities: data.granted
505 grantedCapabilities: data.granted,
506 reviewedFingerprint: data.reviewedFingerprint
505507 })
506508 await persistShieldState()
507509 return result
……@@ -511,7 +513,8 @@ rpc.handle(C.CMD_PLUGIN_UPDATE_DRIVE, async (data = {}) => {
511513 await whenReady()
512514 if (!pluginDriveLoader) throw new Error('Plugin drive loader not available')
513515 const result = await pluginDriveLoader.updateFromDrive(data.driveKey, {
514 acceptEscalation: !!data.acceptEscalation
516 grantedCapabilities: data.granted,
517 reviewedFingerprint: data.reviewedFingerprint
515518 })
516519 await persistShieldState()
517520 return result
……@@ -3174,10 +3177,20 @@ async function boot () {
31743177 // P2P distribution (Phase 2/3 gates). Rule text and plugin payloads were
31753178 // already rehydrated above, so both work fully offline; these two own
31763179 // the drive-sourced metadata and the hot-swap lifecycle.
3177 const sha256Hex = (buf) => require('crypto').createHash('sha256').update(buf).digest('hex')
3180 const sha256Hex = (buf) => crypto.createHash('sha256').update(buf).digest('hex')
3181 const refreshDistributionDrive = async (keyHex) => {
3182 const drive = await getDriveForProxy(keyHex)
3183 if (!drive) return
3184 const before = drive.version
3185 await updateDriveBestEffort(drive, 8000)
3186 if (drive.version !== before && proxy && typeof proxy.invalidateCache === 'function') {
3187 proxy.invalidateCache(keyHex)
3188 }
3189 }
31783190 shieldListSync = new ShieldListSync({
31793191 shield: contentShield,
31803192 fetchDriveFile: (keyHex, path) => proxy._hybridFetch(keyHex, path),
3193 refreshDrive: refreshDistributionDrive,
31813194 sha256Hex,
31823195 persistMeta: async (meta) => {
31833196 if (userData) await userData.setSettings({ contentShieldListSync: meta })
……@@ -3189,6 +3202,8 @@ async function boot () {
31893202 pluginDriveLoader = new PluginDriveLoader({
31903203 registry: pearPlugins,
31913204 fetchDriveFile: (keyHex, path) => proxy._hybridFetch(keyHex, path),
3205 refreshDrive: refreshDistributionDrive,
3206 sha256Hex,
31923207 persistInstall: async (id, payload) => {
31933208 if (payload === null) {
31943209 delete persistShieldState._pluginPayloads[id]
……@@ -3210,7 +3225,8 @@ async function boot () {
32103225 pluginDriveLoader.restore(shieldSettings.contentShieldPluginInstalls)
32113226 }
32123227 pluginCatalog = new PluginCatalog({
3213 fetchDriveFile: (keyHex, path) => proxy._hybridFetch(keyHex, path)
3228 fetchDriveFile: (keyHex, path) => proxy._hybridFetch(keyHex, path),
3229 refreshDrive: refreshDistributionDrive
32143230 })
32153231 if (shieldSettings && shieldSettings.contentShieldPluginCatalog) {
32163232 pluginCatalog.restore(shieldSettings.contentShieldPluginCatalog)
backend/plugin-catalog.cjs +6 -2
……@@ -93,8 +93,9 @@ class PluginCatalog {
9393 }
9494 this._fetch = opts.fetchDriveFile
9595 this._builtin = (Array.isArray(opts.builtin) ? opts.builtin : BUILTIN_PLUGIN_CATALOG)
96 .map(entry => validateCatalogEntry(entry))
96 .map(entry => validateCatalogEntry(entry, { trusted: true }))
9797 .filter(Boolean)
98 this._refreshDrive = typeof opts.refreshDrive === 'function' ? opts.refreshDrive : async () => {}
9899 this._sources = new Map() // driveKeyHex -> { name, entries, loadedAt }
99100 this._now = typeof opts.now === 'function' ? opts.now : Date.now
100101 }
……@@ -130,6 +131,7 @@ class PluginCatalog {
130131 const key = normalizeDriveKey(driveKey)
131132 if (!key) throw new PluginCatalogError('invalid-drive-key', 'A 64-hex catalogue drive key is required')
132133
134 await this._refreshDrive(key)
133135 const fetched = await this._fetch(key, '/plugins.json')
134136 const content = fetched && fetched.content ? fetched.content : null
135137 if (!content || content.length === 0) {
……@@ -225,7 +227,9 @@ function validateCatalogEntry (entry, opts = {}) {
225227 description: cleanString(entry.description, 500),
226228 author: cleanString(entry.author, 120),
227229 capabilities,
228 verified: entry.verified === true,
230 // Only browser-shipped entries may carry the curated trust mark. Drive
231 // catalogues are untrusted discovery metadata, including after restore.
232 verified: opts.trusted === true && entry.verified === true,
229233 ...(typeof entry.unpublished === 'string' && entry.unpublished ? { unpublished: cleanString(entry.unpublished, 200) } : {})
230234 }
231235}
backend/plugin-drive-loader.cjs +87 -20
……@@ -54,6 +54,8 @@ class PluginDriveLoader {
5454 * @param {object} opts
5555 * @param {object} opts.registry — PearPluginRegistry
5656 * @param {function} opts.fetchDriveFile — async (driveKeyHex, path) => { content: Buffer } | null
57 * @param {function} opts.sha256Hex — cryptographic snapshot hash for consent binding
58 * @param {function} [opts.refreshDrive] — async (driveKeyHex) => advance cached drive state
5759 * @param {function} [opts.persistInstall] — async (id, payload|null) => void; null clears
5860 * @param {function} [opts.now]
5961 */
……@@ -64,8 +66,13 @@ class PluginDriveLoader {
6466 if (typeof opts.fetchDriveFile !== 'function') {
6567 throw new TypeError('PluginDriveLoader requires a fetchDriveFile transport')
6668 }
69 if (typeof opts.sha256Hex !== 'function') {
70 throw new TypeError('PluginDriveLoader requires a sha256Hex function')
71 }
6772 this._registry = opts.registry
6873 this._fetch = opts.fetchDriveFile
74 this._sha256Hex = opts.sha256Hex
75 this._refreshDrive = typeof opts.refreshDrive === 'function' ? opts.refreshDrive : async () => {}
6976 this._persistInstall = typeof opts.persistInstall === 'function' ? opts.persistInstall : async () => {}
7077 this._now = typeof opts.now === 'function' ? opts.now : Date.now
7178 this._installs = new Map() // driveKeyHex -> { granted, version, escalated: {added}|null }
……@@ -83,7 +90,12 @@ class PluginDriveLoader {
8390 version: cleanString(record.version),
8491 installedAt: Number.isFinite(record.installedAt) ? record.installedAt : 0,
8592 escalated: record.escalated && Array.isArray(record.escalated.added)
86 ? { added: normalizeCapabilityList(record.escalated.added) }
93 ? {
94 added: normalizeCapabilityList(record.escalated.added),
95 capabilities: normalizeCapabilityList(record.escalated.capabilities),
96 version: cleanString(record.escalated.version),
97 fingerprint: normalizeFingerprint(record.escalated.fingerprint)
98 }
8799 : null
88100 })
89101 restored++
……@@ -101,9 +113,10 @@ class PluginDriveLoader {
101113 }
102114
103115 /**
104 * Install a plugin from its drive. The user's grant is the manifest's
105 * capability set at install time (the consent surface shows exactly this
106 * list before calling install).
116 * Install through a two-step consent handshake. The first call returns the
117 * requested capabilities and a cryptographic snapshot fingerprint without
118 * registering anything. Acceptance must echo that fingerprint and an
119 * explicit grant, binding consent to the exact bytes the user reviewed.
107120 */
108121 async installFromDrive (driveKey, opts = {}) {
109122 const key = normalizeDriveKey(driveKey)
……@@ -111,9 +124,11 @@ class PluginDriveLoader {
111124
112125 const loaded = await this._load(key)
113126 const requested = loaded.capabilities
114 const granted = opts.grantedCapabilities !== undefined
115 ? normalizeCapabilityList(opts.grantedCapabilities).filter(cap => requested.includes(cap))
116 : requested
127 const reviewedFingerprint = normalizeFingerprint(opts.reviewedFingerprint)
128 if (!reviewedFingerprint || reviewedFingerprint !== loaded.fingerprint || opts.grantedCapabilities === undefined) {
129 return consentPreview(key, loaded, reviewedFingerprint ? 'plugin-changed' : 'consent-required')
130 }
131 const granted = normalizeCapabilityList(opts.grantedCapabilities).filter(cap => requested.includes(cap))
117132
118133 const result = this._registry.register({
119134 id: key,
……@@ -148,7 +163,8 @@ class PluginDriveLoader {
148163 * Update an installed plugin from its drive. Fail-closed on capability
149164 * escalation: the plugin is disabled, the escalation is recorded, and the
150165 * caller gets the added capabilities to show in a re-consent prompt.
151 * Passing `acceptEscalation: true` re-grants to the new capability set.
166 * Acceptance must echo the reviewed snapshot fingerprint and complete
167 * capability set; changed bytes produce a new warning instead of a grant.
152168 */
153169 async updateFromDrive (driveKey, opts = {}) {
154170 const key = normalizeDriveKey(driveKey)
……@@ -158,17 +174,31 @@ class PluginDriveLoader {
158174 const loaded = await this._load(key)
159175 const added = loaded.capabilities.filter(cap => !record.granted.includes(cap))
160176
161 if (added.length > 0 && !opts.acceptEscalation) {
162 this._registry.setEnabled(key, false)
163 record.escalated = { added }
164 await this._persistRecordOnly(key, record)
165 return {
166 ok: false,
167 escalated: true,
168 driveKey: key,
169 added,
170 version: loaded.version,
171 message: 'Update requests new capabilities; plugin disabled pending re-consent'
177 if (added.length > 0) {
178 const reviewedFingerprint = normalizeFingerprint(opts.reviewedFingerprint)
179 const acceptedCapabilities = normalizeCapabilityList(opts.grantedCapabilities)
180 const pending = record.escalated
181 const acceptedSnapshot = pending &&
182 reviewedFingerprint &&
183 pending.fingerprint === reviewedFingerprint &&
184 loaded.fingerprint === reviewedFingerprint &&
185 arraysEqual(acceptedCapabilities, pending.capabilities) &&
186 arraysEqual(loaded.capabilities, pending.capabilities)
187
188 if (!acceptedSnapshot) {
189 this._registry.setEnabled(key, false)
190 record.escalated = escalationSnapshot(loaded, added)
191 await this._persistRecordOnly(key, record)
192 return {
193 ok: false,
194 escalated: true,
195 driveKey: key,
196 ...record.escalated,
197 changedSinceReview: !!reviewedFingerprint,
198 message: reviewedFingerprint
199 ? 'Plugin changed since review; inspect the new capabilities before re-consenting'
200 : 'Update requests new capabilities; plugin disabled pending re-consent'
201 }
172202 }
173203 }
174204
……@@ -208,6 +238,7 @@ class PluginDriveLoader {
208238 }
209239
210240 async _load (key) {
241 await this._refreshDrive(key)
211242 const manifestFile = await this._fetch(key, '/manifest.json')
212243 const manifestBytes = manifestFile && manifestFile.content ? manifestFile.content : null
213244 if (!manifestBytes || manifestBytes.length === 0) {
……@@ -246,11 +277,16 @@ class PluginDriveLoader {
246277 contribution.filters = await this._readAsset(key, content.filters, MAX_FILTER_BYTES)
247278 }
248279
280 const version = cleanString(manifest.version) || '0.0.0'
281 const fingerprint = normalizeFingerprint(this._sha256Hex(Buffer.from(JSON.stringify({ manifest, contribution }))))
282 if (!fingerprint) throw new PluginDriveError('fingerprint-failed', 'Could not fingerprint the plugin snapshot')
249283 return {
250284 manifest,
251285 capabilities,
252286 contribution,
253 version: cleanString(manifest.version) || '0.0.0'
287 version,
288 fingerprint,
289 name: cleanString(manifest.name) || `plugin ${key.slice(0, 8)}…`
254290 }
255291 }
256292
……@@ -289,6 +325,37 @@ class PluginDriveLoader {
289325 }
290326}
291327
328function consentPreview (driveKey, loaded, reason) {
329 return {
330 ok: false,
331 consentRequired: true,
332 reason,
333 driveKey,
334 name: loaded.name,
335 version: loaded.version,
336 requested: loaded.capabilities,
337 fingerprint: loaded.fingerprint
338 }
339}
340
341function escalationSnapshot (loaded, added) {
342 return {
343 added: normalizeCapabilityList(added),
344 capabilities: [...loaded.capabilities],
345 version: loaded.version,
346 fingerprint: loaded.fingerprint
347 }
348}
349
350function normalizeFingerprint (value) {
351 const text = typeof value === 'string' ? value.trim().toLowerCase() : ''
352 return /^[0-9a-f]{64}$/.test(text) ? text : ''
353}
354
355function arraysEqual (a, b) {
356 return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((value, index) => value === b[index])
357}
358
292359function withCapabilities (manifest, capabilities) {
293360 // The registry derives capabilities from the manifest; scope registration
294361 // to the GRANTED set so an ungranted capability never reaches the engine.
backend/shield-list-sync.cjs +3 -0
……@@ -38,6 +38,7 @@ class ShieldListSync {
3838 * @param {object} opts
3939 * @param {object} opts.shield — ContentShield (addList/removeList)
4040 * @param {function} opts.fetchDriveFile — async (driveKeyHex, path) => { content: Buffer } | null
41 * @param {function} [opts.refreshDrive] — async (driveKeyHex) => advance cached drive state
4142 * @param {function} [opts.sha256Hex] — (bufferOrString) => hex string; required to verify checksums
4243 * @param {function} [opts.persistMeta] — async (metaByKey) => void
4344 * @param {function} [opts.now] — clock injection for tests
……@@ -51,6 +52,7 @@ class ShieldListSync {
5152 }
5253 this._shield = opts.shield
5354 this._fetch = opts.fetchDriveFile
55 this._refreshDrive = typeof opts.refreshDrive === 'function' ? opts.refreshDrive : async () => {}
5456 this._sha256Hex = typeof opts.sha256Hex === 'function' ? opts.sha256Hex : null
5557 this._persistMeta = typeof opts.persistMeta === 'function' ? opts.persistMeta : async () => {}
5658 this._now = typeof opts.now === 'function' ? opts.now : Date.now
……@@ -157,6 +159,7 @@ class ShieldListSync {
157159 }
158160
159161 async _pull (key, { force }) {
162 await this._refreshDrive(key)
160163 const manifest = await this._readManifest(key)
161164 const existing = this._meta.get(key)
162165
docs/RELEASE_NOTES_v0.7.0.md +2 -0
……@@ -18,6 +18,8 @@ Highlights:
1818 restore.
1919- Pear Plugins for scoped styles, scripts, and network filters.
2020- Capability-escalation protection: updates cannot silently gain permissions.
21- Snapshot-bound install consent and safe CSS embedding across list/plugin
22 boundaries; community catalogues cannot forge the curated trust mark.
2123- One-click P2P catalogue discovery, including anonGPT as an app entry.
2224- Per-drive shield allowlists and strict mode through the browser-owned proxy.
2325
test/content-shield-proxy.test.js +22 -1
……@@ -13,7 +13,7 @@ Module._load = function (request, parent, isMain) {
1313 if (request === 'bare-http1') return {}
1414 return origLoad.call(this, request, parent, isMain)
1515}
16const { HyperProxy } = (await import('../backend/hyper-proxy.js')).default
16const { HyperProxy, escapeStyleText } = (await import('../backend/hyper-proxy.js')).default
1717Module._load = origLoad
1818
1919const require = createRequire(import.meta.url)
……@@ -203,3 +203,24 @@ test('plugin styles and scripts inject only when enabled', async () => {
203203 assert.doesNotMatch(off, /data-pear-plugin-style/)
204204 assert.doesNotMatch(off, /data-pear-plugin="plug"/)
205205})
206
207test('filter and plugin CSS cannot close the browser-owned style element', async () => {
208 const payload = '</style><script>window.__escaped=1</script><style>'
209 assert.equal(escapeStyleText(payload).includes('</style>'), false)
210
211 const shield = new ContentShield({ builtinList: false })
212 shield.addList('malicious-list', `##${payload}`)
213 assert.equal(shield.cosmeticCssFor('example.test'), '')
214
215 shield.applyPluginContribution('style-only', {
216 styles: { matches: ['*'], css: payload }
217 }, ['pear.content.styles'])
218 const { proxy } = makeProxy(shield)
219 const html = '<html><head></head><body>hi</body></html>'
220 const injected = (await proxy._injectHtmlHead(Buffer.from(html), DRIVE, `/hyper/${DRIVE}/index.html`)).toString('utf8')
221 const style = injected.match(/<style data-pear-plugin-style>([\s\S]*?)<\/style>/)
222 assert.ok(style)
223 assert.equal(style[1].includes('</style>'), false)
224 assert.equal(injected.includes('<script>window.__escaped=1</script>'), false)
225 assert.match(style[1], /\\3c \/style>/)
226})
test/plugin-catalog.test.js +5 -2
……@@ -46,26 +46,29 @@ test('builtin seed lists anonGPT with its production drive key, installable by t
4646
4747test('a catalogue drive loads, validates entries, and merges after builtin', async () => {
4848 const pluginKey = 'ab'.repeat(32)
49 const refreshes = []
4950 const catalog = makeCatalog({
5051 '/plugins.json': JSON.stringify({
5152 name: 'Community Plugins',
5253 plugins: [
53 { driveKey: pluginKey, name: 'Community Styler', kind: 'plugin', capabilities: ['pear.content.styles'], description: 'x' },
54 { driveKey: pluginKey, name: 'Community Styler', kind: 'plugin', capabilities: ['pear.content.styles'], description: 'x', verified: true },
5455 { driveKey: 'not-a-key', name: 'Broken entry' },
5556 { name: 'No key at all' },
5657 { driveKey: ANONGPT_DRIVE_KEY, name: 'anonGPT shadow attempt', kind: 'app' },
5758 42
5859 ]
5960 })
60 })
61 }, { refreshDrive: async (key) => { refreshes.push(key) } })
6162
6263 const result = await catalog.loadFromDrive(CAT_KEY)
64 assert.deepEqual(refreshes, [CAT_KEY])
6365 assert.equal(result.name, 'Community Plugins')
6466 assert.equal(result.entryCount, 2) // styler + shadow attempt survive validation
6567
6668 const entries = catalog.entries()
6769 const styler = entries.find(entry => entry.driveKey === pluginKey)
6870 assert.equal(styler.source, CAT_KEY)
71 assert.equal(styler.verified, false, 'community sources cannot mint the curated trust mark')
6972
7073 // Builtin wins the dedupe: the shadow entry cannot replace anonGPT.
7174 const anongpt = entries.filter(entry => entry.driveKey === ANONGPT_DRIVE_KEY)
test/plugin-drive-loader.test.js +76 -8
……@@ -1,6 +1,7 @@
11import test from 'node:test'
22import assert from 'node:assert/strict'
33import { createRequire } from 'node:module'
4import { createHash } from 'node:crypto'
45
56const require = createRequire(import.meta.url)
67const { ContentShield } = require('../backend/content-shield.cjs')
……@@ -22,23 +23,35 @@ function makeLoader (files) {
2223 const registry = new PearPluginRegistry({ shield })
2324 const persisted = new Map()
2425 const store = { ...files }
26 const refreshes = []
2527 const loader = new PluginDriveLoader({
2628 registry,
2729 fetchDriveFile: async (driveKey, path) => {
2830 const value = store[path]
2931 return value == null ? null : { content: Buffer.from(value) }
3032 },
33 refreshDrive: async (driveKey) => { refreshes.push(driveKey) },
34 sha256Hex: (input) => createHash('sha256').update(input).digest('hex'),
3135 persistInstall: async (id, payload) => {
3236 if (payload === null) persisted.delete(id)
3337 else persisted.set(id, structuredClone(payload))
3438 },
3539 now: () => 99
3640 })
37 return { loader, registry, shield, persisted, store }
41 return { loader, registry, shield, persisted, store, refreshes }
42}
43
44async function approveInstall (loader, key = KEY, granted) {
45 const preview = await loader.installFromDrive(key)
46 assert.equal(preview.consentRequired, true)
47 return loader.installFromDrive(key, {
48 grantedCapabilities: granted === undefined ? preview.requested : granted,
49 reviewedFingerprint: preview.fingerprint
50 })
3851}
3952
4053test('install from a drive fetches assets and applies the contribution', async () => {
41 const { loader, registry, shield, persisted } = makeLoader({
54 const { loader, registry, shield, persisted, refreshes } = makeLoader({
4255 '/manifest.json': pluginManifest(
4356 ['pear.content.styles', 'pear.content.scripts', 'pear.net.filter'],
4457 {
……@@ -52,7 +65,16 @@ test('install from a drive fetches assets and applies the contribution', async (
5265 '/filters.txt': '||fixture-ads.example.com^'
5366 })
5467
55 const result = await loader.installFromDrive(KEY)
68 const preview = await loader.installFromDrive(KEY)
69 assert.equal(preview.consentRequired, true)
70 assert.deepEqual(refreshes, [KEY])
71 assert.deepEqual(preview.requested, ['pear.content.styles', 'pear.content.scripts', 'pear.net.filter'])
72 assert.equal(registry.list().length, 0)
73 const result = await loader.installFromDrive(KEY, {
74 grantedCapabilities: preview.requested,
75 reviewedFingerprint: preview.fingerprint
76 })
77 assert.deepEqual(refreshes, [KEY, KEY])
5678 assert.equal(result.ok, true)
5779 assert.equal(result.version, '1.0.0')
5880 assert.deepEqual(result.granted, ['pear.content.styles', 'pear.content.scripts', 'pear.net.filter'])
……@@ -83,7 +105,7 @@ test('a narrower explicit grant strips ungranted capabilities before the engine'
83105 '/filters.txt': '||fixture-ads.example.com^'
84106 })
85107
86 const result = await loader.installFromDrive(KEY, { grantedCapabilities: ['pear.content.styles'] })
108 const result = await approveInstall(loader, KEY, ['pear.content.styles'])
87109 assert.deepEqual(result.granted, ['pear.content.styles'])
88110
89111 // Styles applied, network filter NOT applied (capability was not granted).
……@@ -98,7 +120,7 @@ test('an update that escalates capabilities disables the plugin pending re-conse
98120 }),
99121 '/style.css': '.fixture-hide { display: none }'
100122 })
101 await loader.installFromDrive(KEY)
123 await approveInstall(loader)
102124
103125 // The drive updates itself over the swarm: same plugin, new powers.
104126 store['/manifest.json'] = pluginManifest(
……@@ -121,13 +143,59 @@ test('an update that escalates capabilities disables the plugin pending re-conse
121143 assert.equal(loader.installRecord(KEY).escalated.added[0], 'pear.content.scripts')
122144
123145 // Explicit re-consent accepts the escalation and re-enables.
124 const accepted = await loader.updateFromDrive(KEY, { acceptEscalation: true })
146 const accepted = await loader.updateFromDrive(KEY, {
147 grantedCapabilities: outcome.capabilities,
148 reviewedFingerprint: outcome.fingerprint
149 })
125150 assert.equal(accepted.ok, true)
126151 assert.equal(accepted.escalationAccepted, true)
127152 assert.deepEqual(accepted.granted, ['pear.content.styles', 'pear.content.scripts'])
128153 assert.equal(registry.list().find(item => item.id === KEY).enabled, true)
129154})
130155
156test('install and escalation consent are bound to the exact reviewed snapshot', async () => {
157 const { loader, registry, store } = makeLoader({
158 '/manifest.json': pluginManifest(['pear.content.styles'], {
159 styles: { matches: ['*'], path: '/style.css' }
160 }),
161 '/style.css': '.v1 { color: red }'
162 })
163
164 const installPreview = await loader.installFromDrive(KEY)
165 store['/style.css'] = '.v2 { color: blue }'
166 const changedInstall = await loader.installFromDrive(KEY, {
167 grantedCapabilities: installPreview.requested,
168 reviewedFingerprint: installPreview.fingerprint
169 })
170 assert.equal(changedInstall.consentRequired, true)
171 assert.equal(changedInstall.reason, 'plugin-changed')
172 assert.equal(registry.list().length, 0)
173
174 await approveInstall(loader)
175 store['/manifest.json'] = pluginManifest(['pear.content.styles', 'pear.content.scripts'], {
176 styles: { matches: ['*'], path: '/style.css' },
177 scripts: { matches: ['*'], path: '/content.js' }
178 }, '2.0.0')
179 store['/content.js'] = 'window.__v2 = true'
180 const warning = await loader.updateFromDrive(KEY)
181
182 // Publisher changes the reviewed snapshot before the user accepts it.
183 store['/manifest.json'] = pluginManifest(['pear.content.styles', 'pear.content.scripts', 'pear.net.filter'], {
184 styles: { matches: ['*'], path: '/style.css' },
185 scripts: { matches: ['*'], path: '/content.js' },
186 filters: '/filters.txt'
187 }, '2.1.0')
188 store['/filters.txt'] = '||new-power.example^'
189 const raced = await loader.updateFromDrive(KEY, {
190 grantedCapabilities: warning.capabilities,
191 reviewedFingerprint: warning.fingerprint
192 })
193 assert.equal(raced.escalated, true)
194 assert.equal(raced.changedSinceReview, true)
195 assert.deepEqual(raced.added, ['pear.content.scripts', 'pear.net.filter'])
196 assert.equal(registry.list().find(item => item.id === KEY).enabled, false)
197})
198
131199test('a same-capability update hot-swaps without consent friction', async () => {
132200 const { loader, shield, store } = makeLoader({
133201 '/manifest.json': pluginManifest(['pear.content.styles'], {
……@@ -135,7 +203,7 @@ test('a same-capability update hot-swaps without consent friction', async () =>
135203 }),
136204 '/style.css': '.v1 { display: none }'
137205 })
138 await loader.installFromDrive(KEY)
206 await approveInstall(loader)
139207
140208 store['/manifest.json'] = pluginManifest(['pear.content.styles'], {
141209 styles: { matches: ['*'], path: '/style.css' }
……@@ -189,7 +257,7 @@ test('uninstall removes the registration and the durable payload', async () => {
189257 '/style.css': '.fixture-hide { display: none }',
190258 '/filters.txt': '||fixture-ads.example.com^'
191259 })
192 await loader.installFromDrive(KEY)
260 await approveInstall(loader)
193261 assert.equal(shield.shouldBlockUrl('https://fixture-ads.example.com/x.js').blocked, true)
194262
195263 const removed = await loader.uninstall(KEY)
test/shield-distribution-examples.test.js +11 -2
……@@ -35,16 +35,25 @@ function makeHarness (dir) {
3535 const loader = new PluginDriveLoader({
3636 registry,
3737 fetchDriveFile: driveFromDirectory(dir),
38 sha256Hex: (input) => createHash('sha256').update(input).digest('hex'),
3839 persistInstall: async () => {}
3940 })
4041 return { shield, registry, loader }
4142}
4243
44async function approveInstall (loader, key) {
45 const preview = await loader.installFromDrive(key)
46 return loader.installFromDrive(key, {
47 grantedCapabilities: preview.requested,
48 reviewedFingerprint: preview.fingerprint
49 })
50}
51
4352test('dark-reader example installs and styles every host', async () => {
4453 const key = 'a'.repeat(64)
4554 const { shield, loader } = makeHarness('examples/plugins/dark-reader')
4655
47 const result = await loader.installFromDrive(key)
56 const result = await approveInstall(loader, key)
4857 assert.equal(result.ok, true)
4958 assert.deepEqual(result.granted, ['pear.content.styles'])
5059
……@@ -58,7 +67,7 @@ test('peerit-enhancer example installs scoped to the peerit drive', async () =>
5867 const key = 'b'.repeat(64)
5968 const { shield, loader } = makeHarness('examples/plugins/peerit-enhancer')
6069
61 const result = await loader.installFromDrive(key)
70 const result = await approveInstall(loader, key)
6271 assert.equal(result.ok, true)
6372 assert.deepEqual(result.granted, ['pear.content.styles', 'pear.content.scripts', 'pear.net.filter'])
6473
test/shield-distribution-wiring.test.js +2 -1
……@@ -89,6 +89,7 @@ test('catalogue UI lists entries with install/open actions and a source loader',
8989test('escalation guard is fail-closed in the loader source', () => {
9090 const loader = readFileSync(join(root, 'backend/plugin-drive-loader.cjs'), 'utf8')
9191 assert.match(loader, /setEnabled\(key, false\)/)
92 assert.match(loader, /acceptEscalation/)
92 assert.match(loader, /reviewedFingerprint/)
93 assert.match(loader, /acceptedSnapshot/)
9394 assert.match(loader, /escalated: true/)
9495})
test/shield-list-sync.test.js +5 -2
……@@ -25,21 +25,23 @@ function fakeDrive (files) {
2525
2626function makeSync ({ files, shield } = {}) {
2727 const persisted = []
28 const refreshes = []
2829 const drive = fakeDrive(files || {})
2930 const engine = shield || new ContentShield({ builtinList: false })
3031 const sync = new ShieldListSync({
3132 shield: engine,
3233 fetchDriveFile: drive.fetch,
34 refreshDrive: async (driveKey) => { refreshes.push(driveKey) },
3335 sha256Hex,
3436 persistMeta: async (meta) => { persisted.push(structuredClone(meta)) },
3537 now: () => 1234
3638 })
37 return { sync, shield: engine, drive, persisted }
39 return { sync, shield: engine, drive, persisted, refreshes }
3840}
3941
4042test('subscribe fetches, verifies, registers, and persists a drive list', async () => {
4143 const filters = '||ads.example.com^\n##.promo\n'
42 const { sync, shield, persisted } = makeSync({
44 const { sync, shield, persisted, refreshes } = makeSync({
4345 files: {
4446 '/manifest.json': JSON.stringify({ name: 'pear-default', version: '3', sha256: sha256Hex(filters) }),
4547 '/filters.txt': filters
……@@ -47,6 +49,7 @@ test('subscribe fetches, verifies, registers, and persists a drive list', async
4749 })
4850
4951 const result = await sync.subscribe(KEY)
52 assert.deepEqual(refreshes, [KEY])
5053 assert.equal(result.changed, true)
5154 assert.equal(result.name, 'pear-default')
5255 assert.equal(result.version, '3')
ui/shell.js +30 -8
……@@ -5034,7 +5034,8 @@ function ContentShieldSection ({ rpc, C, activeDriveKey = '', onBrowse }) {
50345034 const [err, setErr] = useState('')
50355035 const [subscribeKey, setSubscribeKey] = useState('')
50365036 const [installKey, setInstallKey] = useState('')
5037 const [escalation, setEscalation] = useState(null) // { driveKey, added: [] }
5037 const [pendingInstall, setPendingInstall] = useState(null)
5038 const [escalation, setEscalation] = useState(null)
50385039 const [catalog, setCatalog] = useState({ entries: [], sources: [] })
50395040 const [catalogSourceKey, setCatalogSourceKey] = useState('')
50405041 const driveKey = typeof activeDriveKey === 'string' && /^[0-9a-f]{64}$/i.test(activeDriveKey)
……@@ -5161,12 +5162,18 @@ function ContentShieldSection ({ rpc, C, activeDriveKey = '', onBrowse }) {
51615162 finally { setBusy(false) }
51625163 }
51635164
5164 const installPluginByKey = async (key) => {
5165 const installPluginByKey = async (key, review = null) => {
51655166 const normalized = String(key || '').trim().toLowerCase()
51665167 if (!/^[0-9a-f]{64}$/.test(normalized) || C.CMD_PLUGIN_INSTALL_DRIVE == null) return
51675168 setBusy(true); setErr('')
51685169 try {
5169 await rpc.request(C.CMD_PLUGIN_INSTALL_DRIVE, { driveKey: normalized }, 30000)
5170 const payload = { driveKey: normalized }
5171 if (review) {
5172 payload.granted = review.requested || []
5173 payload.reviewedFingerprint = review.fingerprint
5174 }
5175 const outcome = await rpc.request(C.CMD_PLUGIN_INSTALL_DRIVE, payload, 30000)
5176 setPendingInstall(outcome && outcome.consentRequired ? outcome : null)
51705177 await refreshStatusAndPlugins()
51715178 } catch (e) { setErr(`install: ${e.message}`) }
51725179 finally { setBusy(false) }
……@@ -5199,12 +5206,17 @@ function ContentShieldSection ({ rpc, C, activeDriveKey = '', onBrowse }) {
51995206 finally { setBusy(false) }
52005207 }
52015208
5202 const updatePlugin = async (id, acceptEscalation = false) => {
5209 const updatePlugin = async (id, review = null) => {
52035210 if (C.CMD_PLUGIN_UPDATE_DRIVE == null) return
52045211 setBusy(true); setErr('')
52055212 try {
5206 const outcome = await rpc.request(C.CMD_PLUGIN_UPDATE_DRIVE, { driveKey: id, acceptEscalation }, 30000)
5207 setEscalation(outcome && outcome.escalated ? { driveKey: id, added: outcome.added || [] } : null)
5213 const payload = { driveKey: id }
5214 if (review) {
5215 payload.granted = review.capabilities || []
5216 payload.reviewedFingerprint = review.fingerprint
5217 }
5218 const outcome = await rpc.request(C.CMD_PLUGIN_UPDATE_DRIVE, payload, 30000)
5219 setEscalation(outcome && outcome.escalated ? { driveKey: id, ...outcome } : null)
52085220 await refreshStatusAndPlugins()
52095221 } catch (e) { setErr(`update: ${e.message}`) }
52105222 finally { setBusy(false) }
……@@ -5308,7 +5320,7 @@ function ContentShieldSection ({ rpc, C, activeDriveKey = '', onBrowse }) {
53085320 ${catalog.entries.map((entry) => html`
53095321 <div className="settings-row" key=${entry.id} data-testid=${'catalog-entry-' + entry.id}>
53105322 <div>
5311 <div className="settings-label">${entry.name}${entry.verified ? html`<span title="Curated entry" style=${{ marginLeft: '5px', color: '#3fb950', fontSize: '12px' }}>✦</span>` : ''}</div>
5323 <div className="settings-label">${entry.name}${entry.source === 'builtin' && entry.verified ? html`<span title="Curated entry" style=${{ marginLeft: '5px', color: '#3fb950', fontSize: '12px' }}>✦</span>` : ''}</div>
53125324 <div className="settings-subtle">${entry.description}</div>
53135325 <div className="settings-subtle">${entry.kind === 'app' ? 'P2P app' : 'plugin'}${entry.capabilities?.length ? ` · ${entry.capabilities.join(', ')}` : ''}${entry.source !== 'builtin' ? ` · from ${String(entry.source).slice(0, 8)}…` : ''}</div>
53145326 </div>
……@@ -5360,10 +5372,20 @@ function ContentShieldSection ({ rpc, C, activeDriveKey = '', onBrowse }) {
53605372 <button className="btn" data-testid="plugin-install" onClick=${installPlugin}
53615373 disabled=${busy || !/^[0-9a-f]{64}$/i.test(installKey.trim())}>Install</button>
53625374 </div>
5375 ${pendingInstall && html`
5376 <div className="apps-error" data-testid="plugin-install-consent">
5377 ${pendingInstall.name} ${pendingInstall.version ? `v${pendingInstall.version}` : ''} requests:
5378 ${(pendingInstall.requested || []).join(', ') || 'no capabilities'}.
5379 Review this grant before installing; catalogue labels are not trusted permissions.
5380 <button className="btn small" onClick=${() => installPluginByKey(pendingInstall.driveKey, pendingInstall)} disabled=${busy}>Grant and install</button>
5381 <button className="btn small subtle" onClick=${() => setPendingInstall(null)} disabled=${busy}>Cancel</button>
5382 </div>
5383 `}
53635384 ${escalation && html`
53645385 <div className="apps-error" data-testid="plugin-escalation">
53655386 Update for ${escalation.driveKey.slice(0, 12)}… requests new capabilities: ${escalation.added.join(', ')}.
5366 <button className="btn small" onClick=${() => updatePlugin(escalation.driveKey, true)} disabled=${busy}>Accept and re-enable</button>
5387 ${escalation.changedSinceReview ? ' The plugin changed after the previous review; inspect this new request.' : ''}
5388 <button className="btn small" onClick=${() => updatePlugin(escalation.driveKey, escalation)} disabled=${busy}>Accept and re-enable</button>
53675389 </div>
53685390 `}
53695391 ${plugins.map((p) => html`