mirror of
https://github.com/PR0M3TH3AN/bitgate.git
synced 2026-11-07 14:01:03 +00:00
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:
1 parent
7a45f1cf3e
commit
94ef6a72f5
21 files changed
+788
-54
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 : {};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user