mirror of
https://github.com/PR0M3TH3AN/bitgate.git
synced 2026-10-27 08:31:00 +00:00
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.
187 lines
5.7 KiB
JavaScript
187 lines
5.7 KiB
JavaScript
import { describe, expect, it } from "vitest";
|
|
|
|
import { decodeContribution } from "../src/canonical.js";
|
|
import {
|
|
LEGACY_COMMUNITY_SOURCES_IDENTIFIER,
|
|
LEGACY_EDITORS_IDENTIFIER,
|
|
LEGACY_KIND,
|
|
decodeLegacyCommunitySources,
|
|
decodeLegacyEditors,
|
|
decodeLegacyList,
|
|
decodeMixedContributions,
|
|
isLegacyAdminEvent,
|
|
} from "../src/legacy.js";
|
|
|
|
const ROOT = "a1".repeat(32);
|
|
const CURATOR = "c3".repeat(32);
|
|
const CREATOR = "d4".repeat(32);
|
|
const EVENT_ID = "1b".repeat(32);
|
|
const NPUB = "npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6";
|
|
const NPUB_HEX = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
|
|
|
|
/**
|
|
* @param {Partial<import('../src/replaceable.js').NostrEvent>} parts
|
|
* @returns {import('../src/replaceable.js').NostrEvent}
|
|
*/
|
|
const event = (parts) => ({
|
|
id: "00".repeat(32),
|
|
pubkey: ROOT,
|
|
kind: LEGACY_KIND,
|
|
created_at: 1000,
|
|
tags: [],
|
|
content: "",
|
|
...parts,
|
|
});
|
|
|
|
describe("isLegacyAdminEvent", () => {
|
|
it("recognizes the known legacy lists", () => {
|
|
for (const identifier of [
|
|
"bitvid:admin:whitelist",
|
|
"bitvid:admin:blacklist",
|
|
"bitvid:admin:event-blacklist",
|
|
LEGACY_EDITORS_IDENTIFIER,
|
|
LEGACY_COMMUNITY_SOURCES_IDENTIFIER,
|
|
]) {
|
|
expect(isLegacyAdminEvent(event({ tags: [["d", identifier]] }))).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("rejects unrelated lists", () => {
|
|
expect(isLegacyAdminEvent(event({ tags: [["d", "bitvid:playlist"]] }))).toBe(false);
|
|
});
|
|
|
|
it("rejects the wrong kind", () => {
|
|
expect(isLegacyAdminEvent(event({ kind: 30078, tags: [["d", "bitvid:admin:blacklist"]] }))).toBe(
|
|
false,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("decodeLegacyList", () => {
|
|
it("maps the blacklist onto a user denial", () => {
|
|
const contribution = decodeLegacyList(
|
|
event({ tags: [["d", "bitvid:admin:blacklist"], ["p", CREATOR]] }),
|
|
);
|
|
expect(contribution).toMatchObject({
|
|
actor: ROOT,
|
|
kind: "user-deny",
|
|
targets: [{ type: "user", pubkey: CREATOR }],
|
|
});
|
|
});
|
|
|
|
it("maps the whitelist onto a user allowance", () => {
|
|
const contribution = decodeLegacyList(
|
|
event({ tags: [["d", "bitvid:admin:whitelist"], ["p", CREATOR]] }),
|
|
);
|
|
expect(contribution?.kind).toBe("user-allow");
|
|
});
|
|
|
|
it("maps the event blacklist onto an event denial", () => {
|
|
const contribution = decodeLegacyList(
|
|
event({ tags: [["d", "bitvid:admin:event-blacklist"], ["e", EVENT_ID]] }),
|
|
);
|
|
expect(contribution?.targets).toEqual([{ type: "event", id: EVENT_ID }]);
|
|
});
|
|
|
|
it("normalizes npub entries to hex", () => {
|
|
const contribution = decodeLegacyList(
|
|
event({ tags: [["d", "bitvid:admin:blacklist"], ["p", NPUB]] }),
|
|
);
|
|
expect(contribution?.targets).toEqual([{ type: "user", pubkey: NPUB_HEX }]);
|
|
});
|
|
|
|
it("treats hex and npub entries as the same target", () => {
|
|
const fromNpub = decodeLegacyList(
|
|
event({ tags: [["d", "bitvid:admin:blacklist"], ["p", NPUB]] }),
|
|
);
|
|
const fromHex = decodeLegacyList(
|
|
event({ tags: [["d", "bitvid:admin:blacklist"], ["p", NPUB_HEX]] }),
|
|
);
|
|
expect(fromNpub?.targets).toEqual(fromHex?.targets);
|
|
});
|
|
|
|
it("returns null for the editor roster", () => {
|
|
expect(decodeLegacyList(event({ tags: [["d", LEGACY_EDITORS_IDENTIFIER]] }))).toBeNull();
|
|
});
|
|
|
|
it("returns null for an unknown identifier", () => {
|
|
expect(decodeLegacyList(event({ tags: [["d", "bitvid:other"]] }))).toBeNull();
|
|
});
|
|
|
|
it("skips malformed entries", () => {
|
|
const contribution = decodeLegacyList(
|
|
event({ tags: [["d", "bitvid:admin:blacklist"], ["p", "junk"], ["p", CREATOR]] }),
|
|
);
|
|
expect(contribution?.targets).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe("decodeLegacyEditors", () => {
|
|
it("returns editors as plain pubkeys", () => {
|
|
expect(
|
|
decodeLegacyEditors(event({ tags: [["d", LEGACY_EDITORS_IDENTIFIER], ["p", CREATOR]] })),
|
|
).toEqual([CREATOR]);
|
|
});
|
|
|
|
it("deduplicates", () => {
|
|
expect(
|
|
decodeLegacyEditors(
|
|
event({ tags: [["d", LEGACY_EDITORS_IDENTIFIER], ["p", CREATOR], ["p", CREATOR]] }),
|
|
),
|
|
).toHaveLength(1);
|
|
});
|
|
|
|
it("returns nothing for other lists", () => {
|
|
expect(decodeLegacyEditors(event({ tags: [["d", "bitvid:admin:blacklist"]] }))).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("decodeLegacyCommunitySources", () => {
|
|
it("decodes curator list references", () => {
|
|
const sources = decodeLegacyCommunitySources(
|
|
event({
|
|
tags: [
|
|
["d", LEGACY_COMMUNITY_SOURCES_IDENTIFIER],
|
|
["a", `30000:${CURATOR}:bitvid:admin:blacklist`],
|
|
],
|
|
}),
|
|
);
|
|
expect(sources).toEqual([
|
|
{ curator: CURATOR, identifier: "bitvid:admin:blacklist", kind: 30000 },
|
|
]);
|
|
});
|
|
|
|
it("skips malformed coordinates", () => {
|
|
const sources = decodeLegacyCommunitySources(
|
|
event({ tags: [["d", LEGACY_COMMUNITY_SOURCES_IDENTIFIER], ["a", "garbage"]] }),
|
|
);
|
|
expect(sources).toEqual([]);
|
|
});
|
|
|
|
it("returns nothing for other lists", () => {
|
|
expect(decodeLegacyCommunitySources(event({ tags: [["d", "bitvid:admin:blacklist"]] }))).toEqual(
|
|
[],
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("decodeMixedContributions", () => {
|
|
it("reads legacy and canonical events side by side", () => {
|
|
const legacy = event({ tags: [["d", "bitvid:admin:blacklist"], ["p", CREATOR]] });
|
|
const canonical = event({
|
|
kind: 30078,
|
|
tags: [["d", "app:governance:user-deny:v1"], ["p", CREATOR]],
|
|
});
|
|
|
|
const contributions = decodeMixedContributions([legacy, canonical], decodeContribution);
|
|
expect(contributions).toHaveLength(2);
|
|
expect(contributions.every((entry) => entry.kind === "user-deny")).toBe(true);
|
|
});
|
|
|
|
it("ignores events neither codec recognizes", () => {
|
|
expect(decodeMixedContributions([event({ tags: [["d", "unknown"]] })], decodeContribution)).toEqual(
|
|
[],
|
|
);
|
|
});
|
|
});
|