fix(security): close every finding from the audit

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 <namespace>:governance:<scope>: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.
This commit is contained in:
thePR0M3TH3AN committed 2026-08-09 21:52:44 -04:00
1 parent 7a45f1cf3e
commit 94ef6a72f5
21 files changed
+788 -54

No files matched your search

+3 -1
View File
@@ -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;
}
+6 -3
View File
@@ -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<string, readonly GovernanceCapability[]>} */
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<string, string[]>} */
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<GovernanceCapability>} */
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);
}
}
+7 -3
View File
@@ -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<string, number>} */
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<string, number>} */
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;
}
+15 -8
View File
@@ -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<string, number>} */
const reportsByCategory = {};
const reportsByCategory = Object.create(null);
for (const key of Object.keys(evidence.trustedReportsByCategory).sort()) {
reportsByCategory[key] = evidence.trustedReportsByCategory[key];
}
/** @type {Record<string, number>} */
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;
+19 -8
View File
@@ -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<string, CategoryThresholds>} */
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<string, PolicyProfile>} */
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 : {};
}
/**
+4 -1
View File
@@ -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;
}