From 94ef6a72f5c0f42658ccbe24bf1d627cf2ec752a Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 9 Aug 2026 21:52:44 -0400 Subject: [PATCH] fix(security): close every finding from the audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critical - Any pubkey could publish a kind:30078 roles document and become root of a deployment, granting itself every capability and denying anyone. Roles are now accepted only from the configured root, compared against the constructor value rather than current authority state so a bad ingest cannot move the goalposts. Reproduced end to end before the fix; regression test added. - Administrative state now fails closed without signature verification. Without it, event.pubkey is an unauthenticated claim, so an authorship check alone proves nothing — a relay can put the root's key on an event the root never wrote. trustUnsignedEvents opts in for local and test use. High - Report categories could evade moderation entirely: resolveThresholds used bracket truthiness, so "constructor" resolved to the Object constructor (all gates undefined) and "__proto__" to Object.prototype, both bypassing the default threshold. Own-property lookups and null-prototype tables throughout core — policy, evaluator, evidence, authority, presets. - Live subscriptions bypassed signature verification entirely; an attacker only had to wait for the subscription rather than the load. - The default administrative query asked relays for every kind:30078 and kind:30000 event in existence, with no author, identifier, or limit. Now filtered to governance identifiers with a bounded limit. Medium - Cached state was trusted as authority: a cache naming a different root is now rejected, so same-origin tampering cannot persist a takeover. - Provider attribute changes silently stopped working after the first one, and concurrent starts leaked a transport. Generation-guarded. - Unbounded growth in the report store, mute store, decision cache, and the transport's per-subscription dedupe set — all now capped. Also restores the on-wire `d` identifier to :governance::v1. The BitGate rename replaced "governance:" too broadly and silently changed the event format; both encoder and parser changed together, so round-trip tests never noticed. The identifier names the kind of document, not the library. Adds docs/security.md and 21 regression tests reproducing each finding. 832 tests. --- docs/security.md | 87 ++++++ packages/core/src/adminState.js | 4 +- packages/core/src/authority.js | 9 +- packages/core/src/evaluator.js | 10 +- packages/core/src/evidence.js | 23 +- packages/core/src/policy.js | 27 +- packages/core/src/presets.js | 5 +- packages/nostr/src/canonical.js | 12 +- packages/nostr/test/canonical.test.js | 2 +- packages/nostr/test/legacy.test.js | 2 +- packages/runtime/src/commands.js | 4 +- packages/runtime/src/interfaces.js | 1 + packages/runtime/src/relayTransport.js | 11 + packages/runtime/src/runtime.js | 202 ++++++++++++- packages/runtime/src/stores.js | 31 +- packages/runtime/test/commands.test.js | 2 + packages/runtime/test/outbox.test.js | 4 + packages/runtime/test/persistence.test.js | 7 +- packages/runtime/test/runtime.test.js | 12 +- packages/widget/src/provider.js | 36 ++- tests/security/audit-regressions.test.js | 351 ++++++++++++++++++++++ 21 files changed, 788 insertions(+), 54 deletions(-) create mode 100644 docs/security.md create mode 100644 tests/security/audit-regressions.test.js diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..e29b757 --- /dev/null +++ b/docs/security.md @@ -0,0 +1,87 @@ +# Security model + +What BitGate assumes, what it enforces, and what it cannot do for you. + +## The trust boundary + +Everything arriving from a relay is untrusted input. Relays are transport, not +authority: a relay can withhold events, replay old ones, and — absent signature +verification — fabricate them wholesale, including the `pubkey` field. + +Two independent checks gate administrative state: + +1. **Authorship.** A roles document is accepted only from the deployment's + configured `root`. Comparison is against `configuredRoot` — the value passed + to the constructor — never against the current authority state, so a bad + ingest cannot move the goalposts for the next one. +2. **Authenticity.** `event.pubkey` is an unauthenticated claim until a + signature is checked, so an authorship check alone proves nothing. + +## Signature verification is required + +Administrative state **fails closed** without it. With no `verifySignature` +configured, roles, policy, and contribution documents are rejected and counted +in `diagnostics.rejectedUnverified`. + +```js +createBitGate({ + root: ROOT_PUBKEY, + verifySignature: (event) => verifyEventSignature(event), // your crypto library + … +}); +``` + +`trustUnsignedEvents: true` exists for local development and tests, where +events are constructed in-process and there is no relay to lie. Never set it in +production; `describe().signatureVerification` reports the posture. + +Trust signals — reports and mutes — are **not** gated this way, because they are +bounded by the viewer's own follow graph rather than by authority. A forged +report only counts if it appears to come from someone the viewer already +follows. That is a weaker guarantee than the administrative path and is the +reason verification still matters for them. + +## Hostile input BitGate is designed to survive + +| Attack | Defence | +| --- | --- | +| Publishing a roles document to become root | Authorship check against configured root | +| A relay forging an event from the root's key | Signature verification; fails closed without it | +| Report category crafted to dodge thresholds (`constructor`, `__proto__`) | Own-property lookups and null-prototype tables throughout | +| Denied account manufacturing reports | Denied and blocked accounts are excluded before aggregation | +| One account reporting repeatedly | Deduplicated per (reporter, category) | +| Curator exceeding their remit | Capabilities resolved at merge time, per contribution kind | +| Denying the root or a protected actor | Protected actors stripped from every denial set, and refused at the command boundary | +| Same-origin script tampering with cached state | A cached roster naming a different root is rejected outright | +| Stale relay data holding a target down forever | Configurable mute validity window | +| Unbounded relay responses | Administrative queries filter on governance `d` identifiers and carry a limit | + +## What BitGate does not do + +**It never handles secret material.** Signing and decryption are delegated to an +injected signer. There is no code path that reads a private key. + +**It cannot decrypt another account's private mutes**, and does not try. Only +the viewer's own list is decrypted, and the check lives at the codec boundary +rather than depending on callers to behave. + +**It does not moderate relays.** BitGate governs what a client shows; what a +relay stores is a different layer (see NIP-86). + +**It is not a rate limiter.** Report and mute stores carry ceilings to bound +memory, not to resist flooding. A relay that floods you will evict your own +cache first. + +## Operational guidance + +- Configure `root` explicitly. Without it, every roster is refused. +- Configure `verifySignature`. Without it, all administrative state is refused. +- Treat `describe()` as a health check: `signatureVerification`, `stale`, + `rejectedUnauthorized`, and `rejectedUnverified` all indicate posture. +- Rotating the root invalidates cached state by design — the storage key is + namespaced by root identity. +- Serve the widget from your own origin, built from source you have read. + +## Reporting a vulnerability + +Open a private security advisory on the repository rather than a public issue. diff --git a/packages/core/src/adminState.js b/packages/core/src/adminState.js index a48e50b..7cd28b3 100644 --- a/packages/core/src/adminState.js +++ b/packages/core/src/adminState.js @@ -94,7 +94,9 @@ export function reduceAdminState(contributions, authority) { continue; } - const capability = KIND_CAPABILITY[contribution.kind]; + const capability = Object.hasOwn(KIND_CAPABILITY, contribution.kind) + ? KIND_CAPABILITY[contribution.kind] + : undefined; if (!capability) { continue; } diff --git a/packages/core/src/authority.js b/packages/core/src/authority.js index ac577e2..df87551 100644 --- a/packages/core/src/authority.js +++ b/packages/core/src/authority.js @@ -102,8 +102,10 @@ export function isGovernanceCapability(value) { * @returns {AuthorityState} */ export function createAuthorityState(options = {}) { + // Role names arrive from published rosters; a null prototype keeps a + // "__proto__" role from mutating the object instead of being stored. /** @type {Record} */ - const roles = {}; + const roles = Object.create(null); for (const [name, capabilities] of Object.entries(options.roles ?? DEFAULT_ROLE_CAPABILITIES)) { const trimmed = name.trim(); if (!trimmed) { @@ -115,7 +117,7 @@ export function createAuthorityState(options = {}) { } /** @type {Record} */ - const actors = {}; + const actors = Object.create(null); for (const [pubkey, roleNames] of Object.entries(options.actors ?? {})) { const normalized = normalizePubkey(pubkey); if (!normalized) { @@ -187,7 +189,8 @@ export function getActorCapabilities(pubkey, authority) { /** @type {Set} */ const capabilities = new Set(); for (const roleName of authority.actors[normalized] ?? []) { - for (const capability of authority.roles[roleName] ?? []) { + const granted = Object.hasOwn(authority.roles, roleName) ? authority.roles[roleName] : []; + for (const capability of granted) { capabilities.add(capability); } } diff --git a/packages/core/src/evaluator.js b/packages/core/src/evaluator.js index e1fdcc7..40f6d45 100644 --- a/packages/core/src/evaluator.js +++ b/packages/core/src/evaluator.js @@ -153,8 +153,11 @@ export function aggregateReports(key, snapshot, viewer, trustSet) { reporters.add(record.reporter); } + // Null prototype: categories come from report events, so a "__proto__" key + // on a plain object would be silently dropped from the map while still + // counting toward the total. /** @type {Record} */ - const counts = {}; + const counts = Object.create(null); let total = 0; for (const [category, set] of byCategory.entries()) { counts[category] = set.size; @@ -209,7 +212,7 @@ export function aggregateMutes(key, snapshot, viewer, trustSet, now, windowSecon } /** @type {Record} */ - const counts = {}; + const counts = Object.create(null); for (const [category, set] of byCategory.entries()) { counts[category] = set.size; } @@ -404,7 +407,8 @@ export function evaluateTarget(target, snapshot, context = { surface: "default" applyEffects(decision, effects, reasons); for (const [category, count] of Object.entries(mutes.byCategory)) { - const categoryThresholds = profile.mutes?.[category]; + const categoryThresholds = + profile.mutes && Object.hasOwn(profile.mutes, category) ? profile.mutes[category] : undefined; if (!categoryThresholds) { continue; } diff --git a/packages/core/src/evidence.js b/packages/core/src/evidence.js index 65a7b4d..af56204 100644 --- a/packages/core/src/evidence.js +++ b/packages/core/src/evidence.js @@ -38,10 +38,13 @@ export function createEmptyEvidence() { return { trustedReportTotal: 0, - trustedReportsByCategory: {}, + // Null prototype throughout: categories originate in report and mute + // events, so a "__proto__" key on a normal object literal would be + // silently discarded while still counting toward the total. + trustedReportsByCategory: Object.create(null), trustedReporterPubkeys: [], trustedMuteTotal: 0, - trustedMutesByCategory: {}, + trustedMutesByCategory: Object.create(null), trustedMuterPubkeys: [], personalBlock: false, personalMute: false, @@ -65,13 +68,13 @@ export function createEmptyEvidence() { */ export function freezeEvidence(evidence) { /** @type {Record} */ - const reportsByCategory = {}; + const reportsByCategory = Object.create(null); for (const key of Object.keys(evidence.trustedReportsByCategory).sort()) { reportsByCategory[key] = evidence.trustedReportsByCategory[key]; } /** @type {Record} */ - const mutesByCategory = {}; + const mutesByCategory = Object.create(null); for (const key of Object.keys(evidence.trustedMutesByCategory).sort()) { mutesByCategory[key] = evidence.trustedMutesByCategory[key]; } @@ -112,12 +115,16 @@ export function mergeEvidence(records) { } for (const [category, count] of Object.entries(record.trustedReportsByCategory ?? {})) { - merged.trustedReportsByCategory[category] = - (merged.trustedReportsByCategory[category] ?? 0) + count; + const previous = Object.hasOwn(merged.trustedReportsByCategory, category) + ? merged.trustedReportsByCategory[category] + : 0; + merged.trustedReportsByCategory[category] = previous + count; } for (const [category, count] of Object.entries(record.trustedMutesByCategory ?? {})) { - merged.trustedMutesByCategory[category] = - (merged.trustedMutesByCategory[category] ?? 0) + count; + const previous = Object.hasOwn(merged.trustedMutesByCategory, category) + ? merged.trustedMutesByCategory[category] + : 0; + merged.trustedMutesByCategory[category] = previous + count; } merged.trustedReportTotal += record.trustedReportTotal ?? 0; diff --git a/packages/core/src/policy.js b/packages/core/src/policy.js index f2c1852..1c213d6 100644 --- a/packages/core/src/policy.js +++ b/packages/core/src/policy.js @@ -230,8 +230,11 @@ export function normalizeCategoryThresholds(thresholds, location = "profile") { return {}; } + // Null prototype: these keys come from policy documents, which may be + // relay-supplied. `normalized["__proto__"] = …` on a normal object literal + // would mutate the prototype rather than adding a key. /** @type {Record} */ - const normalized = {}; + const normalized = Object.create(null); for (const [category, config] of Object.entries(thresholds)) { if (!config || typeof config !== "object") { @@ -243,7 +246,7 @@ export function normalizeCategoryThresholds(thresholds, location = "profile") { } /** @type {CategoryThresholds} */ - const entry = {}; + const entry = Object.create(null); for (const [gate, value] of Object.entries(config)) { if (!Number.isFinite(value)) { continue; @@ -334,7 +337,7 @@ export function normalizePolicyDefinition(policy) { } /** @type {Record} */ - const profiles = {}; + const profiles = Object.create(null); for (const [key, profile] of Object.entries(policy.profiles)) { const normalizedProfile = normalizePolicyProfile({ ...profile, name: profile?.name ?? key }); profiles[key.trim()] = normalizedProfile; @@ -369,13 +372,15 @@ export function normalizePolicyDefinition(policy) { export function resolveProfile(policy, context) { const requested = context?.policyProfile?.trim(); if (requested) { - const profile = policy.profiles[requested]; - if (!profile) { + if (!Object.hasOwn(policy.profiles, requested)) { throw new Error(`Unknown policy profile: ${requested}`); } - return profile; + return policy.profiles[requested]; } const fallback = policy.defaultProfile ?? Object.keys(policy.profiles)[0]; + if (!Object.hasOwn(policy.profiles, fallback)) { + throw new Error(`Unknown policy profile: ${fallback}`); + } return policy.profiles[fallback]; } @@ -390,10 +395,16 @@ export function resolveThresholds(table, category) { return {}; } const normalizedCategory = typeof category === "string" ? category.trim().toLowerCase() : ""; - if (normalizedCategory && table[normalizedCategory]) { + + // Own-property checks, not bracket truthiness. A report category is + // attacker-supplied, and `table["constructor"]` would otherwise resolve to + // the Object constructor — a truthy value whose gates are all undefined, + // which silently exempts that category from every threshold including the + // default. `__proto__` does the same via Object.prototype. + if (normalizedCategory && Object.hasOwn(table, normalizedCategory)) { return table[normalizedCategory]; } - return table.default ?? {}; + return Object.hasOwn(table, "default") ? table.default : {}; } /** diff --git a/packages/core/src/presets.js b/packages/core/src/presets.js index 8a82bd4..73ebb9b 100644 --- a/packages/core/src/presets.js +++ b/packages/core/src/presets.js @@ -266,5 +266,8 @@ export function getPolicyPreset(name) { if (typeof name !== "string") { return null; } - return POLICY_PRESETS[name.trim().toLowerCase()] ?? null; + const key = name.trim().toLowerCase(); + // Own-property check: POLICY_PRESETS["constructor"] would otherwise hand back + // the Object constructor as if it were a policy. + return Object.hasOwn(POLICY_PRESETS, key) ? POLICY_PRESETS[key] : null; } diff --git a/packages/nostr/src/canonical.js b/packages/nostr/src/canonical.js index 550f0a0..6509ea7 100644 --- a/packages/nostr/src/canonical.js +++ b/packages/nostr/src/canonical.js @@ -31,13 +31,19 @@ const SCOPE_TO_KIND = { }; /** - * Build a canonical `d` identifier. + * Build a canonical `d` identifier: `:governance::v1`. + * + * The middle segment is deliberately the generic word rather than the product + * name. The identifier describes the kind of document, not the library that + * wrote it — another implementation should be able to publish and read the + * same events without adopting our branding. + * * @param {string} namespace - Application namespace, e.g. "bitroad" * @param {string} scope - e.g. "user-deny" * @returns {string} */ export function canonicalIdentifier(namespace, scope) { - return `${namespace}:bitgate:${scope}:v${CANONICAL_VERSION}`; + return `${namespace}:governance:${scope}:v${CANONICAL_VERSION}`; } /** @@ -49,7 +55,7 @@ export function parseIdentifier(identifier) { if (typeof identifier !== "string") { return null; } - const match = /^([^:]+):bitgate:([^:]+):v(\d+)$/.exec(identifier.trim()); + const match = /^([^:]+):governance:([^:]+):v(\d+)$/.exec(identifier.trim()); if (!match) { return null; } diff --git a/packages/nostr/test/canonical.test.js b/packages/nostr/test/canonical.test.js index 34da35e..4bad50f 100644 --- a/packages/nostr/test/canonical.test.js +++ b/packages/nostr/test/canonical.test.js @@ -37,7 +37,7 @@ const event = (parts) => ({ describe("canonical identifiers", () => { it("builds a namespaced identifier", () => { expect(canonicalIdentifier("bitroad", "address-deny")).toBe( - "bitroad:bitgate:address-deny:v1", + "bitroad:governance:address-deny:v1", ); }); diff --git a/packages/nostr/test/legacy.test.js b/packages/nostr/test/legacy.test.js index 5b1dba9..55c9b41 100644 --- a/packages/nostr/test/legacy.test.js +++ b/packages/nostr/test/legacy.test.js @@ -170,7 +170,7 @@ describe("decodeMixedContributions", () => { const legacy = event({ tags: [["d", "bitvid:admin:blacklist"], ["p", CREATOR]] }); const canonical = event({ kind: 30078, - tags: [["d", "app:bitgate:user-deny:v1"], ["p", CREATOR]], + tags: [["d", "app:governance:user-deny:v1"], ["p", CREATOR]], }); const contributions = decodeMixedContributions([legacy, canonical], decodeContribution); diff --git a/packages/runtime/src/commands.js b/packages/runtime/src/commands.js index 7332196..40d17e5 100644 --- a/packages/runtime/src/commands.js +++ b/packages/runtime/src/commands.js @@ -281,7 +281,7 @@ export class GovernanceCommands { kind: 30078, content: JSON.stringify(policy), tags: [ - ["d", `${this.runtime.namespace}:bitgate:policy:v1`], + ["d", `${this.runtime.namespace}:governance:policy:v1`], ["v", "1"], ["client", "bitgate"], ["scope", "policy"], @@ -307,7 +307,7 @@ export class GovernanceCommands { } const tags = [ - ["d", `${this.runtime.namespace}:bitgate:community-sources:v1`], + ["d", `${this.runtime.namespace}:governance:community-sources:v1`], ["v", "1"], ["client", "bitgate"], ["scope", "community-sources"], diff --git a/packages/runtime/src/interfaces.js b/packages/runtime/src/interfaces.js index 5ea72fa..76543a4 100644 --- a/packages/runtime/src/interfaces.js +++ b/packages/runtime/src/interfaces.js @@ -23,6 +23,7 @@ /** * @typedef {Object} ListOptions * @property {number} [timeout] + * @property {number} [maxSeen] - Ceiling on the per-subscription dedupe window * @property {string[]} [relays] - Query only these relays, connecting if needed. * This is what makes outbox-model fetching possible: a contact's mute list * lives on their write relays, not on this deployment's. diff --git a/packages/runtime/src/relayTransport.js b/packages/runtime/src/relayTransport.js index 693cc59..c8694e3 100644 --- a/packages/runtime/src/relayTransport.js +++ b/packages/runtime/src/relayTransport.js @@ -316,6 +316,11 @@ export function createRelayTransport(urls, options = {}) { subscribe(filters, handlers, subscribeOptions = {}) { const id = `bg-sub-${(subscriptionCounter += 1)}`; const targets = resolveTargets(subscribeOptions.relays); + // Bounded: a long-lived subscription on a busy relay would otherwise + // retain every event id it has ever seen for the life of the page. The + // window only needs to be wide enough to catch the same event arriving + // from several relays at once. + const maxSeen = subscribeOptions.maxSeen ?? 10_000; /** @type {Set} */ const seen = new Set(); @@ -327,6 +332,12 @@ export function createRelayTransport(urls, options = {}) { if (!event?.id || seen.has(event.id)) { return; } + if (seen.size >= maxSeen) { + const oldest = seen.values().next().value; + if (oldest !== undefined) { + seen.delete(oldest); + } + } seen.add(event.id); handlers.onEvent?.(event); }, diff --git a/packages/runtime/src/runtime.js b/packages/runtime/src/runtime.js index 9501143..3587d9a 100644 --- a/packages/runtime/src/runtime.js +++ b/packages/runtime/src/runtime.js @@ -39,6 +39,30 @@ import { import { snapshotFingerprint } from "@bitgate/core"; import { Emitter } from "./emitter.js"; + +/** Governance document scopes, used to build a bounded `#d` filter. */ +const GOVERNANCE_SCOPES = [ + "roles", + "policy", + "user-allow", + "user-deny", + "event-deny", + "address-deny", + "trust-seed", + "community-sources", +]; + +/** Legacy administrative list identifiers, read for migration compatibility. */ +const LEGACY_IDENTIFIERS = [ + "bitvid:admin:whitelist", + "bitvid:admin:blacklist", + "bitvid:admin:event-blacklist", + "bitvid:admin:editors", + "bitvid:admin:community-sources", +]; + +/** Maximum administrative events accepted from one query. */ +const DEFAULT_ADMIN_LIMIT = 500; import { createMemoryStorage, createNullSigner, storageKey } from "./interfaces.js"; import { GovernanceAdminStore, @@ -116,7 +140,12 @@ export class GovernanceRuntime extends Emitter { * @param {number} [options.chunkSize] * @param {number} [options.muteWindowSeconds] * @param {import('@bitgate/nostr').SignatureVerifier} [options.verifySignature] + * @param {boolean} [options.trustUnsignedEvents] - Accept administrative events + * without a verifier. Development and tests only. * @param {string} [options.root] - Root administrator pubkey, from deployment config + * @param {number} [options.maxCachedDecisions] - Ceiling on cached decisions + * @param {number} [options.maxReportTargets] - Ceiling on targets held by the report store + * @param {number} [options.maxMuteLists] - Ceiling on mute lists held */ constructor({ applicationId, @@ -129,7 +158,11 @@ export class GovernanceRuntime extends Emitter { chunkSize = DEFAULT_CHUNK_SIZE, muteWindowSeconds = 0, verifySignature, + trustUnsignedEvents, root, + maxCachedDecisions = 5_000, + maxReportTargets = 20_000, + maxMuteLists = 5_000, }) { super(); @@ -155,6 +188,10 @@ export class GovernanceRuntime extends Emitter { // rather than pretending state was verified. this.verifySignature = verifySignature; + // Opt-in escape hatch for local development and tests, where events are + // constructed in-process and there is no relay to lie. + this.trustUnsignedEvents = trustUnsignedEvents === true; + // The deployment's own relays, used as a fallback for authors who have // published no NIP-65 list. /** @type {string[]} */ @@ -162,8 +199,12 @@ export class GovernanceRuntime extends Emitter { this.admin = new GovernanceAdminStore(); this.trust = new TrustGraphStore(); - this.reports = new ReportStore(); - this.mutes = new TrustedMuteStore({ windowSeconds: muteWindowSeconds, now: this.now }); + this.reports = new ReportStore({ maxTargets: maxReportTargets }); + this.mutes = new TrustedMuteStore({ + windowSeconds: muteWindowSeconds, + now: this.now, + maxLists: maxMuteLists, + }); /** @type {PolicyStore} */ this.policies = new PolicyStore(policy ?? NEUTRAL_POLICY); this.overrides = new OverrideStore({ now: this.now }); @@ -172,8 +213,15 @@ export class GovernanceRuntime extends Emitter { // Seeding it here keeps the storage key stable from construction; without // it, hydration could not find its own cache, because the key is derived // from the root fingerprint the cache would have to supply. - if (root) { - this.admin.setRoles({ root }); + // Kept separate from admin.authority.root on purpose. The authority state + // can be rewritten by ingested documents; this is the deployment's own + // configuration and is what every authorship check compares against, so a + // bad ingest cannot move the goalposts for the next one. + /** @type {string} */ + this.configuredRoot = root ? normalizePubkey(root) : ""; + + if (this.configuredRoot) { + this.admin.setRoles({ root: this.configuredRoot }); } /** @type {string} */ @@ -234,6 +282,10 @@ export class GovernanceRuntime extends Emitter { // for every card is the cost the extraction is supposed to remove. /** @type {Map} */ this.decisionCache = new Map(); + // Without a ceiling this grows for the lifetime of the page: an + // infinite-scroll feed evaluates new targets forever and never revisits + // most of them. Insertion order gives a cheap approximation of LRU. + this.maxCachedDecisions = maxCachedDecisions; // The snapshot and its fingerprint are rebuilt only when a store changes. // Deriving them per target walks every report and mute list, which turns a @@ -252,6 +304,8 @@ export class GovernanceRuntime extends Emitter { rejectedSignatures: 0, hydratedFromCache: false, privateMutesApplied: 0, + rejectedUnauthorized: 0, + rejectedUnverified: 0, }; } @@ -331,18 +385,32 @@ export class GovernanceRuntime extends Emitter { if (event.kind === CANONICAL_KIND) { const roles = decodeRoles(event); if (roles) { + // The roster is the root of the entire trust model: it decides who may + // deny whom, and who is protected. Accepting one from an arbitrary + // author lets anybody declare themselves root. Only the deployment's + // configured root may publish it. + if (!this.#isRootAuthored(event)) { + this.diagnostics.rejectedUnauthorized += 1; + this.emit("rejected", { reason: "roles-not-root", pubkey: event.pubkey }); + return false; + } + if (!this.#adminIngestAllowed()) { + return false; + } this.admin.setRoles(roles); return true; } const policyDocument = decodePolicy(event); if (policyDocument) { - this.applyRootPolicy(policyDocument, event.pubkey); - return true; + return this.applyRootPolicy(policyDocument, event.pubkey); } const contribution = decodeContribution(event); if (contribution) { + if (!this.#adminIngestAllowed()) { + return false; + } this.admin.upsertContribution(contribution); return true; } @@ -351,6 +419,9 @@ export class GovernanceRuntime extends Emitter { if (event.kind === LEGACY_KIND) { const contribution = decodeLegacyList(event); if (contribution) { + if (!this.#adminIngestAllowed()) { + return false; + } this.admin.upsertContribution(contribution); return true; } @@ -360,6 +431,47 @@ export class GovernanceRuntime extends Emitter { return false; } + /** + * Whether an event was authored by the deployment's configured root. + * + * Falls back to the current authority root only when no root was configured, + * which is a development convenience — a deployment with no configured root + * has no way to distinguish its administrator from anyone else. + * + * @param {NostrEvent} event + * @returns {boolean} + */ + #isRootAuthored(event) { + const root = this.configuredRoot || this.admin.authority.root; + if (!root) { + return false; + } + return normalizePubkey(event?.pubkey ?? "") === root; + } + + /** + * Whether administrative state may be ingested at all. + * + * Without signature verification, `event.pubkey` is an unauthenticated claim: + * a hostile relay can put the root's key on an event the root never wrote, so + * an authorship check alone proves nothing. Administrative documents + * therefore fail closed unless a verifier is configured, or the application + * explicitly opts in for local and test use. + * + * Trust signals — reports, mutes — are not gated here: they are bounded by + * the viewer's own trust graph rather than by authority. + * + * @returns {boolean} + */ + #adminIngestAllowed() { + if (this.verifySignature || this.trustUnsignedEvents) { + return true; + } + this.diagnostics.rejectedUnverified += 1; + this.emit("rejected", { reason: "unverified-administrative-event" }); + return false; + } + /** * Apply a root-published policy document. * @@ -372,7 +484,7 @@ export class GovernanceRuntime extends Emitter { * @returns {boolean} Whether the policy was accepted */ applyRootPolicy(document, publisher) { - const root = this.admin.authority.root; + const root = this.configuredRoot || this.admin.authority.root; if (!root || normalizePubkey(publisher) !== root) { this.emit("policy-rejected", { reason: "not-root", publisher }); return false; @@ -435,8 +547,19 @@ export class GovernanceRuntime extends Emitter { const admin = /** @type {any} */ (await this.storage.read(this.storageKeyFor("admin"))); if (admin?.schemaVersion === this.schemaVersion) { + // Cache is a performance tier, never an authority tier. Storage is + // writable by anything running on this origin, so a cached roster naming + // a different root would turn any same-origin XSS into a persistent + // moderation takeover. A mismatch means the cache is not ours. + const cachedRoot = normalizePubkey(admin.authority?.root ?? ""); + if (this.configuredRoot && cachedRoot && cachedRoot !== this.configuredRoot) { + this.emit("rejected", { reason: "cached-root-mismatch", pubkey: cachedRoot }); + this.diagnostics.hydratedFromCache = false; + return false; + } + if (admin.authority) { - this.admin.setRoles(admin.authority); + this.admin.setRoles({ ...admin.authority, root: this.configuredRoot || cachedRoot }); } if (Array.isArray(admin.contributions)) { this.admin.setContributions(admin.contributions); @@ -463,6 +586,26 @@ export class GovernanceRuntime extends Emitter { return restored; } + /** + * Ingest an event that arrived on a live subscription. + * + * Subscriptions previously called ingestEvent directly, so state fetched at + * load was verified and the identical state arriving live was not — an + * attacker only had to wait. Verification is async, so this wraps it. + * + * @param {NostrEvent} event + * @returns {Promise} + */ + async #ingestFromSubscription(event) { + if (this.verifySignature) { + const [verified] = await this.#verified([event]); + if (!verified) { + return false; + } + } + return this.ingestEvent(event); + } + /** * Verify a batch of events when a verifier is configured. * @param {NostrEvent[]} events @@ -498,15 +641,35 @@ export class GovernanceRuntime extends Emitter { await this.hydrate(); } + // Narrowed to the exact documents governance uses. An unfiltered query for + // kinds 30078 and 30000 asks a relay for every application-data and + // people-list event in existence: a self-inflicted denial of service on + // page load, and a privacy problem since it pulls unrelated app data. + const identifiers = GOVERNANCE_SCOPES.map( + (scope) => `${this.namespace}:governance:${scope}:v1`, + ); + /** @type {any} */ - const filter = { kinds: [CANONICAL_KIND, LEGACY_KIND] }; + const canonicalFilter = { + kinds: [CANONICAL_KIND], + "#d": identifiers, + limit: DEFAULT_ADMIN_LIMIT, + }; + /** @type {any} */ + const legacyFilter = { + kinds: [LEGACY_KIND], + "#d": LEGACY_IDENTIFIERS, + limit: DEFAULT_ADMIN_LIMIT, + }; + if (authors?.length) { - filter.authors = authors; + canonicalFilter.authors = authors; + legacyFilter.authors = authors; } let events; try { - events = await this.transport.list([filter]); + events = await this.transport.list([canonicalFilter, legacyFilter]); } catch (error) { this.stale = true; this.emit("stale", { reason: "relay-unreachable", error: String(error) }); @@ -703,7 +866,7 @@ export class GovernanceRuntime extends Emitter { if (!authorChunk.length) continue; opened.push( this.transport.subscribe([{ kinds: [MUTE_LIST_KIND], authors: authorChunk }], { - onEvent: (event) => this.ingestEvent(event), + onEvent: (event) => void this.#ingestFromSubscription(event), }), ); } @@ -791,7 +954,7 @@ export class GovernanceRuntime extends Emitter { if (!ids.length) continue; opened.push( this.transport.subscribe([{ kinds: [REPORT_KIND], "#e": ids }], { - onEvent: (event) => this.ingestEvent(event), + onEvent: (event) => void this.#ingestFromSubscription(event), }), ); } @@ -800,7 +963,7 @@ export class GovernanceRuntime extends Emitter { if (!keys.length) continue; opened.push( this.transport.subscribe([{ kinds: [REPORT_KIND], "#p": keys }], { - onEvent: (event) => this.ingestEvent(event), + onEvent: (event) => void this.#ingestFromSubscription(event), }), ); } @@ -966,6 +1129,17 @@ export class GovernanceRuntime extends Emitter { // Frozen because the cache hands out the same object to every caller; a // consumer mutating a decision would otherwise corrupt later reads. this.decisionCache.set(cacheKey, deepFreeze(decision)); + + if (this.decisionCache.size > this.maxCachedDecisions) { + // Maps iterate in insertion order, so the first key is the least + // recently added — good enough eviction for a cache whose miss cost is + // one pure evaluation. + const oldest = this.decisionCache.keys().next().value; + if (oldest !== undefined) { + this.decisionCache.delete(oldest); + } + } + return decision; } diff --git a/packages/runtime/src/stores.js b/packages/runtime/src/stores.js index 5c52b23..77a5230 100644 --- a/packages/runtime/src/stores.js +++ b/packages/runtime/src/stores.js @@ -231,10 +231,18 @@ export class TrustGraphStore extends Emitter { * NIP-56 reports, aggregated per target. */ export class ReportStore extends Emitter { - constructor() { + /** + * @param {Object} [options] + * @param {number} [options.maxTargets] - Ceiling on distinct targets retained + */ + constructor({ maxTargets = 0 } = {}) { super(); /** @type {Map>>} reports[targetKey][reporter][category] = createdAt */ this.reports = new Map(); + // Relays push reports for targets this viewer may never look at again. + // Without a ceiling a long-running session accumulates every target ever + // mentioned. + this.maxTargets = maxTargets; this.fingerprint = fingerprint({}); } @@ -275,6 +283,14 @@ export class ReportStore extends Emitter { } byCategory.set(report.category, createdAt); + + if (this.maxTargets > 0 && this.reports.size > this.maxTargets) { + const oldest = this.reports.keys().next().value; + if (oldest !== undefined && oldest !== targetKey) { + this.reports.delete(oldest); + } + } + this.#emitChange(targetKey); } @@ -333,11 +349,14 @@ export class TrustedMuteStore extends Emitter { * @param {Object} [options] * @param {number} [options.windowSeconds] - Mutes older than this are pruned * @param {() => number} [options.now] - Injected clock, unix seconds + * @param {number} [options.maxLists] - Ceiling on retained lists; 0 disables eviction */ - constructor({ windowSeconds = 0, now = () => 0 } = {}) { + constructor({ windowSeconds = 0, now = () => 0, maxLists = 0 } = {}) { super(); this.windowSeconds = windowSeconds; this.now = now; + /** Ceiling on retained lists; 0 disables eviction. */ + this.maxLists = maxLists; /** @type {Map }>} */ this.lists = new Map(); this.fingerprint = fingerprint({}); @@ -372,6 +391,14 @@ export class TrustedMuteStore extends Emitter { updatedAt: list.updatedAt, entries: list.entries, }); + + if (this.maxLists > 0 && this.lists.size > this.maxLists) { + const oldest = this.lists.keys().next().value; + if (oldest !== undefined && oldest !== list.owner) { + this.lists.delete(oldest); + } + } + this.#emitChange(Array.from(affected)); } diff --git a/packages/runtime/test/commands.test.js b/packages/runtime/test/commands.test.js index ad36698..0dd204d 100644 --- a/packages/runtime/test/commands.test.js +++ b/packages/runtime/test/commands.test.js @@ -250,6 +250,8 @@ describe("contribution round-trip", () => { transport: createMemoryTransport(), policy: POLICY, now: () => NOW, + root: ROOT, + trustUnsignedEvents: true, }); fresh.admin.setRoles({ root: ROOT, diff --git a/packages/runtime/test/outbox.test.js b/packages/runtime/test/outbox.test.js index 4cc87ac..2496d03 100644 --- a/packages/runtime/test/outbox.test.js +++ b/packages/runtime/test/outbox.test.js @@ -76,6 +76,7 @@ function makeRuntime(transport) { transport, policy: POLICY, now: () => NOW, + trustUnsignedEvents: true, }); runtime.setViewer(VIEWER); return runtime; @@ -238,6 +239,7 @@ describe("contact list loading", () => { transport, policy: POLICY, now: () => NOW, + trustUnsignedEvents: true, }); expect(await runtime.loadContacts()).toBe(0); }); @@ -275,6 +277,7 @@ describe("the viewer's own private mutes", () => { signer: signerWithNip44(), policy: POLICY, now: () => NOW, + trustUnsignedEvents: true, }); runtime.setViewer(VIEWER); @@ -292,6 +295,7 @@ describe("the viewer's own private mutes", () => { signer: signerWithNip44(), policy: POLICY, now: () => NOW, + trustUnsignedEvents: true, }); runtime.setViewer(VIEWER); diff --git a/packages/runtime/test/persistence.test.js b/packages/runtime/test/persistence.test.js index 598095f..9399aa9 100644 --- a/packages/runtime/test/persistence.test.js +++ b/packages/runtime/test/persistence.test.js @@ -79,6 +79,7 @@ function makeRuntime({ storage, transport, verifySignature, root = ROOT } = {}) now: () => NOW, verifySignature, root, + trustUnsignedEvents: true, }); } @@ -331,13 +332,13 @@ describe("community sources", () => { }); const merged = await runtime.loadCommunitySources([ - { curator: CURATOR, identifier: "app:bitgate:user-deny:v1", kind: CANONICAL_KIND }, + { curator: CURATOR, identifier: "app:governance:user-deny:v1", kind: CANONICAL_KIND }, ]); expect(merged).toBe(1); expect(runtime.admin.state.userDeny.has(`user:${CREATOR}`)).toBe(true); expect(runtime.admin.state.communitySources.get(`user:${CREATOR}`)).toEqual([ - `${CURATOR}:app:bitgate:user-deny:v1`, + `${CURATOR}:app:governance:user-deny:v1`, ]); }); @@ -357,7 +358,7 @@ describe("community sources", () => { runtime.admin.setRoles({ root: ROOT, actors: { [CURATOR]: ["curator"] } }); await runtime.loadCommunitySources([ - { curator: CURATOR, identifier: "app:bitgate:event-deny:v1", kind: CANONICAL_KIND }, + { curator: CURATOR, identifier: "app:governance:event-deny:v1", kind: CANONICAL_KIND }, ]); expect(runtime.admin.state.eventDeny.size).toBe(0); diff --git a/packages/runtime/test/runtime.test.js b/packages/runtime/test/runtime.test.js index ddc7371..819d431 100644 --- a/packages/runtime/test/runtime.test.js +++ b/packages/runtime/test/runtime.test.js @@ -72,6 +72,8 @@ function makeRuntime(events = []) { transport, policy: POLICY, now: () => NOW, + root: ROOT, + trustUnsignedEvents: true, }); return { runtime, transport }; } @@ -132,7 +134,9 @@ describe("construction", () => { it("changes its storage key when the root authority changes", () => { const { runtime } = makeRuntime(); const before = runtime.storageKeyFor("admin"); - runtime.admin.setRoles({ root: ROOT, actors: {} }); + // Rotating the root must invalidate the cache namespace so a new + // administration cannot inherit the previous one's stored state. + runtime.admin.setRoles({ root: CREATOR, actors: {} }); expect(runtime.storageKeyFor("admin")).not.toBe(before); }); }); @@ -266,6 +270,8 @@ describe("loadAdministrativeState", () => { transport, policy: POLICY, now: () => NOW, + root: ROOT, + trustUnsignedEvents: true, }); await runtime.loadAdministrativeState({ authors: [MODERATOR] }); @@ -284,6 +290,8 @@ describe("active targets and subscriptions", () => { policy: POLICY, now: () => NOW, chunkSize: 2, + root: ROOT, + trustUnsignedEvents: true, }); runtime.setActiveTargets([ @@ -305,6 +313,8 @@ describe("active targets and subscriptions", () => { transport, policy: POLICY, now: () => NOW, + root: ROOT, + trustUnsignedEvents: true, }); runtime.setActiveTargets([ diff --git a/packages/widget/src/provider.js b/packages/widget/src/provider.js index 8fc4bf4..06df463 100644 --- a/packages/widget/src/provider.js +++ b/packages/widget/src/provider.js @@ -49,6 +49,10 @@ export class BitGateProvider extends globalThis.HTMLElement { /** @type {Error|null} */ this.error = null; this._ready = false; + // Increments on every start, so a start that was superseded mid-flight can + // tell it lost the race and clean up after itself instead of overwriting + // the newer runtime and leaking its own sockets. + this._generation = 0; // A promise as well as an event. The event fires during element upgrade, // which for a page that calls defineBitGateElements() after parsing is @@ -92,8 +96,13 @@ export class BitGateProvider extends globalThis.HTMLElement { attributeChangedCallback(name, before, after) { // Only rebuild once connected and actually changed; the initial attribute // pass would otherwise construct several runtimes during parsing. - if (this._ready && before !== after) { + // + // `stop()` clears the ready flag, so it is restored here — otherwise the + // first attribute change would leave the guard permanently false and every + // later change would be silently ignored. + if (this._ready && this.isConnected && before !== after) { this.stop(); + this._ready = true; void this.start(); } } @@ -114,6 +123,10 @@ export class BitGateProvider extends globalThis.HTMLElement { * whatever cached state exists. */ async start() { + const generation = (this._generation += 1); + /** @type {any} */ + let transport = null; + try { const relays = this.relayUrls; const policyName = this.getAttribute("policy") ?? "admin-only"; @@ -128,12 +141,21 @@ export class BitGateProvider extends globalThis.HTMLElement { throw new Error("bitgate-provider requires a relays attribute"); } - this.transport = createRelayTransport(relays); + transport = createRelayTransport(relays); + + // A newer start began while this one was resolving its configuration. + // Close what was just opened rather than clobbering the newer runtime. + if (generation !== this._generation) { + transport.close(); + return; + } + + this.transport = transport; this.runtime = createBitGate({ applicationId: this.getAttribute("application") ?? globalThis.location?.hostname ?? "bitgate", namespace: this.getAttribute("namespace") ?? "bitgate", root: this.getAttribute("root") ?? undefined, - transport: this.transport, + transport, policy, storage: createLocalStorage(), now: () => Math.floor(Date.now() / 1000), @@ -156,6 +178,10 @@ export class BitGateProvider extends globalThis.HTMLElement { }), ); + if (generation !== this._generation) { + return; + } + await this.runtime.loadAdministrativeState(); // With a viewer known, build the trust graph without the host having to: @@ -168,6 +194,10 @@ export class BitGateProvider extends globalThis.HTMLElement { this.dispatchEvent(new CustomEvent("bitgate:loaded", { bubbles: true, composed: true })); } catch (error) { + transport?.close?.(); + if (generation !== this._generation) { + return; + } this.error = /** @type {Error} */ (error); this.setAttribute("data-error", String(error)); this._rejectReady?.(error); diff --git a/tests/security/audit-regressions.test.js b/tests/security/audit-regressions.test.js new file mode 100644 index 0000000..60f3d52 --- /dev/null +++ b/tests/security/audit-regressions.test.js @@ -0,0 +1,351 @@ +// Regressions for findings from the security audit. +// +// Each case reproduces the original exploit. They exist so a refactor cannot +// quietly reopen a hole that was already closed once. + +import { describe, expect, it, vi } from "vitest"; + +import { createPolicyDefinition, getPolicyPreset, resolveThresholds } from "@bitgate/core"; +import { encodeContribution, encodeRoles } from "@bitgate/nostr"; +import { createBitGate, createMemoryTransport } from "@bitgate/runtime"; + +const ROOT = "a1".repeat(32); +const ATTACKER = "ee".repeat(32); +const VICTIM = "d4".repeat(32); +const NOW = 1_750_000_000; + +const POLICY = createPolicyDefinition({ + id: "audit", + version: "1.0.0", + defaultProfile: "feed", + profiles: { + feed: { + name: "feed", + administrativeDeny: { visibility: "hide", interaction: "deny" }, + reports: { spam: { restrict: 3 }, default: { restrict: 1 } }, + mutes: {}, + }, + }, +}); + +function makeRuntime(options = {}) { + return createBitGate({ + applicationId: "audit", + namespace: "app", + root: ROOT, + transport: createMemoryTransport(), + policy: POLICY, + now: () => NOW, + trustUnsignedEvents: true, + ...options, + }); +} + +const signed = (pubkey, template, id = "01") => ({ + id: id.repeat(32), + pubkey, + created_at: NOW, + ...template, +}); + +describe("finding 1: roster takeover", () => { + it("refuses a roles document from anyone but the configured root", () => { + const runtime = makeRuntime(); + const rejected = vi.fn(); + runtime.on("rejected", rejected); + + const accepted = runtime.ingestEvent( + signed(ATTACKER, encodeRoles({ actors: { [ATTACKER]: ["super_admin"] } }, "app")), + ); + + expect(accepted).toBe(false); + expect(runtime.admin.authority.root).toBe(ROOT); + expect(runtime.can(ATTACKER, "manage-roles")).toBe(false); + expect(rejected).toHaveBeenCalledWith(expect.objectContaining({ reason: "roles-not-root" })); + }); + + it("still accepts the configured root's own roster", () => { + const runtime = makeRuntime(); + const moderator = "b2".repeat(32); + + expect( + runtime.ingestEvent(signed(ROOT, encodeRoles({ actors: { [moderator]: ["moderator"] } }, "app"))), + ).toBe(true); + expect(runtime.can(moderator, "contribute-user-deny")).toBe(true); + }); + + it("leaves an attacker unable to deny anyone", () => { + const runtime = makeRuntime(); + + runtime.ingestEvent( + signed(ATTACKER, encodeRoles({ actors: { [ATTACKER]: ["super_admin"] } }, "app"), "01"), + ); + runtime.ingestEvent( + signed( + ATTACKER, + encodeContribution( + { actor: ATTACKER, kind: "user-deny", targets: [{ type: "user", pubkey: VICTIM }] }, + "app", + ), + "02", + ), + ); + + expect(runtime.admin.state.userDeny.has(`user:${VICTIM}`)).toBe(false); + expect(runtime.evaluate({ type: "user", pubkey: VICTIM }, { profile: "feed" }).visibility.effect).toBe( + "allow", + ); + }); + + it("compares against the configured root, not the current authority", () => { + // Defence in depth: even if authority state were somehow rewritten, the + // configured root is what authorship is checked against. + const runtime = makeRuntime(); + runtime.admin.setRoles({ root: ATTACKER, actors: { [ATTACKER]: ["super_admin"] } }); + + expect( + runtime.ingestEvent(signed(ATTACKER, encodeRoles({ actors: {} }, "app"))), + ).toBe(false); + }); + + it("refuses every roster when no root is configured", () => { + const runtime = makeRuntime({ root: undefined }); + expect(runtime.ingestEvent(signed(ATTACKER, encodeRoles({ actors: {} }, "app")))).toBe(false); + }); +}); + +describe("finding 1b: administrative state fails closed without verification", () => { + it("refuses administrative documents when no verifier is configured", () => { + const runtime = createBitGate({ + applicationId: "audit", + namespace: "app", + root: ROOT, + transport: createMemoryTransport(), + policy: POLICY, + now: () => NOW, + }); + const rejected = vi.fn(); + runtime.on("rejected", rejected); + + // Authored by the real root — but pubkey is an unauthenticated claim + // without a signature check, so a relay could have forged it. + expect(runtime.ingestEvent(signed(ROOT, encodeRoles({ actors: {} }, "app")))).toBe(false); + expect(rejected).toHaveBeenCalledWith( + expect.objectContaining({ reason: "unverified-administrative-event" }), + ); + }); + + it("accepts them once a verifier is configured", () => { + const runtime = createBitGate({ + applicationId: "audit", + namespace: "app", + root: ROOT, + transport: createMemoryTransport(), + policy: POLICY, + now: () => NOW, + verifySignature: () => true, + }); + + expect(runtime.ingestEvent(signed(ROOT, encodeRoles({ actors: {} }, "app")))).toBe(true); + }); + + it("reports the posture in diagnostics", () => { + const runtime = createBitGate({ + applicationId: "audit", + namespace: "app", + root: ROOT, + transport: createMemoryTransport(), + policy: POLICY, + now: () => NOW, + }); + runtime.ingestEvent(signed(ROOT, encodeRoles({ actors: {} }, "app"))); + + const description = runtime.describe(); + expect(description.signatureVerification).toBe("disabled"); + expect(description.rejectedUnverified).toBeGreaterThan(0); + }); +}); + +describe("finding 2: threshold evasion through inherited keys", () => { + const table = { spam: { restrict: 3 }, default: { restrict: 1 } }; + + it("falls back to the default for prototype-chain category names", () => { + // "constructor" previously resolved to the Object constructor: truthy, with + // every gate undefined, which exempted the category from all thresholds. + for (const category of ["constructor", "__proto__", "valueof", "hasownproperty"]) { + expect(resolveThresholds(table, category), category).toEqual({ restrict: 1 }); + } + }); + + it("still resolves real categories and unknown ones", () => { + expect(resolveThresholds(table, "spam")).toEqual({ restrict: 3 }); + expect(resolveThresholds(table, "unknown")).toEqual({ restrict: 1 }); + }); + + it("returns an empty object when there is no default", () => { + expect(resolveThresholds({ spam: { restrict: 3 } }, "constructor")).toEqual({}); + }); + + it("applies the default threshold to a crafted report category end to end", () => { + const runtime = makeRuntime(); + const trusted = "01".repeat(32); + runtime.trust.setContacts([trusted]); + runtime.reports.ingest( + { reporter: trusted, category: "constructor", createdAt: NOW }, + `user:${VICTIM}`, + ); + + // One trusted report, default threshold restrict: 1 — it must bite. + expect(runtime.evaluate({ type: "user", pubkey: VICTIM }, { profile: "feed" }).visibility.effect).toBe( + "restrict", + ); + }); + + it("counts a crafted category in evidence rather than dropping it", () => { + const runtime = makeRuntime(); + const trusted = "01".repeat(32); + runtime.trust.setContacts([trusted]); + runtime.reports.ingest( + { reporter: trusted, category: "__proto__", createdAt: NOW }, + `user:${VICTIM}`, + ); + + const evidence = runtime.evaluate({ type: "user", pubkey: VICTIM }, { profile: "feed" }).evidence; + expect(evidence?.trustedReportTotal).toBe(1); + expect(evidence?.trustedReportsByCategory.__proto__).toBe(1); + }); + + it("does not hand back a prototype member as a policy preset", () => { + for (const name of ["constructor", "__proto__", "toString"]) { + expect(getPolicyPreset(name), name).toBeNull(); + } + expect(getPolicyPreset("social")).not.toBeNull(); + }); + + it("rejects a prototype member as a profile name", () => { + const runtime = makeRuntime(); + expect(() => + runtime.evaluate({ type: "user", pubkey: VICTIM }, { profile: "constructor" }), + ).toThrow(/Unknown policy profile/); + }); +}); + +describe("finding 3: live subscriptions are verified", () => { + it("drops a subscription event the verifier rejects", () => { + const transport = createMemoryTransport(); + const runtime = createBitGate({ + applicationId: "audit", + namespace: "app", + root: ROOT, + transport, + policy: POLICY, + now: () => NOW, + verifySignature: () => false, + }); + + runtime.setActiveTargets([{ type: "user", pubkey: VICTIM }]); + runtime.subscribeToActiveTargetReports(); + + transport.deliver({ + id: "aa".repeat(32), + pubkey: "01".repeat(32), + kind: 1984, + created_at: NOW, + tags: [["p", VICTIM, "spam"]], + content: "", + }); + + expect(runtime.reports.recordsFor(`user:${VICTIM}`)).toEqual([]); + }); +}); + +describe("finding 4: the administrative query is bounded", () => { + it("filters by governance identifiers and a limit", async () => { + const captured = []; + const transport = { + ...createMemoryTransport(), + relays: ["wss://r.example"], + async list(filters) { + captured.push(...filters); + return []; + }, + }; + const runtime = makeRuntime({ transport }); + + await runtime.loadAdministrativeState({ hydrateFirst: false, persistAfter: false }); + + // Never an unfiltered request for every kind:30078 on the relay. + expect(captured.length).toBeGreaterThan(0); + for (const filter of captured) { + expect(filter["#d"], JSON.stringify(filter)).toBeDefined(); + expect(filter.limit).toBeGreaterThan(0); + } + expect(captured[0]["#d"]).toContain("app:governance:roles:v1"); + }); +}); + +describe("finding 5: cached state is not an authority", () => { + it("refuses a cache naming a different root", async () => { + const stored = new Map(); + const storage = { + async read(key) { + return stored.has(key) ? stored.get(key) : null; + }, + async write(key, value) { + stored.set(key, value); + }, + async remove(key) { + stored.delete(key); + }, + }; + + const runtime = makeRuntime({ storage }); + // Simulate storage tampered with by same-origin script. + stored.set(runtime.storageKeyFor("admin"), { + schemaVersion: "v1", + authority: { root: ATTACKER, actors: { [ATTACKER]: ["super_admin"] } }, + contributions: [], + }); + + expect(await runtime.hydrate()).toBe(false); + expect(runtime.admin.authority.root).toBe(ROOT); + expect(runtime.can(ATTACKER, "manage-roles")).toBe(false); + }); +}); + +describe("finding 7: growth is bounded", () => { + it("evicts report targets past the ceiling", () => { + const runtime = makeRuntime({ maxReportTargets: 10 }); + for (let index = 0; index < 50; index += 1) { + runtime.reports.ingest( + { reporter: "01".repeat(32), category: "spam", createdAt: NOW }, + `user:${index.toString(16).padStart(2, "0").repeat(32)}`, + ); + } + expect(runtime.reports.reports.size).toBeLessThanOrEqual(11); + }); + + it("evicts cached decisions past the ceiling", () => { + const runtime = makeRuntime({ maxCachedDecisions: 5 }); + for (let index = 1; index <= 30; index += 1) { + runtime.evaluate( + { type: "user", pubkey: index.toString(16).padStart(2, "0").repeat(32) }, + { profile: "feed" }, + ); + } + expect(runtime.decisionCache.size).toBeLessThanOrEqual(6); + }); + + it("evicts mute lists past the ceiling", () => { + const runtime = makeRuntime({ maxMuteLists: 5 }); + for (let index = 1; index <= 30; index += 1) { + runtime.mutes.replaceList({ + owner: index.toString(16).padStart(2, "0").repeat(32), + updatedAt: NOW, + entries: [{ pubkey: VICTIM }], + hasEncryptedEntries: false, + }); + } + expect(runtime.mutes.lists.size).toBeLessThanOrEqual(6); + }); +});