mirror of
https://github.com/PR0M3TH3AN/bitgate.git
synced 2026-11-05 04:51: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.
298 lines
8.5 KiB
JavaScript
298 lines
8.5 KiB
JavaScript
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
CANONICAL_KIND,
|
|
MAX_POLICY_BYTES,
|
|
canonicalIdentifier,
|
|
decodeAddressCoordinate,
|
|
decodeContribution,
|
|
decodePolicy,
|
|
decodeRoles,
|
|
encodeAddressCoordinate,
|
|
encodeContribution,
|
|
encodeRoles,
|
|
isCanonicalGovernanceEvent,
|
|
parseIdentifier,
|
|
} from "../src/canonical.js";
|
|
|
|
const ROOT = "a1".repeat(32);
|
|
const MODERATOR = "b2".repeat(32);
|
|
const CREATOR = "d4".repeat(32);
|
|
const EVENT_ID = "1b".repeat(32);
|
|
|
|
/**
|
|
* @param {Partial<import('../src/replaceable.js').NostrEvent>} parts
|
|
* @returns {import('../src/replaceable.js').NostrEvent}
|
|
*/
|
|
const event = (parts) => ({
|
|
id: "00".repeat(32),
|
|
pubkey: MODERATOR,
|
|
kind: CANONICAL_KIND,
|
|
created_at: 1000,
|
|
tags: [],
|
|
content: "",
|
|
...parts,
|
|
});
|
|
|
|
describe("canonical identifiers", () => {
|
|
it("builds a namespaced identifier", () => {
|
|
expect(canonicalIdentifier("bitroad", "address-deny")).toBe(
|
|
"bitroad:governance:address-deny:v1",
|
|
);
|
|
});
|
|
|
|
it("round-trips through the parser", () => {
|
|
expect(parseIdentifier(canonicalIdentifier("app", "user-deny"))).toEqual({
|
|
namespace: "app",
|
|
scope: "user-deny",
|
|
version: "1",
|
|
});
|
|
});
|
|
|
|
it("rejects a non-governance identifier", () => {
|
|
expect(parseIdentifier("bitvid:admin:blacklist")).toBeNull();
|
|
expect(parseIdentifier("")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("isCanonicalGovernanceEvent", () => {
|
|
it("accepts a well-formed document", () => {
|
|
expect(
|
|
isCanonicalGovernanceEvent(event({ tags: [["d", canonicalIdentifier("app", "user-deny")]] })),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("rejects the wrong kind", () => {
|
|
expect(
|
|
isCanonicalGovernanceEvent(
|
|
event({ kind: 30000, tags: [["d", canonicalIdentifier("app", "user-deny")]] }),
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("can be restricted to one namespace", () => {
|
|
const candidate = event({ tags: [["d", canonicalIdentifier("app", "user-deny")]] });
|
|
expect(isCanonicalGovernanceEvent(candidate, "app")).toBe(true);
|
|
expect(isCanonicalGovernanceEvent(candidate, "other")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("decodeContribution", () => {
|
|
it("decodes a user denial list", () => {
|
|
const contribution = decodeContribution(
|
|
event({
|
|
tags: [
|
|
["d", canonicalIdentifier("app", "user-deny")],
|
|
["p", CREATOR],
|
|
],
|
|
}),
|
|
);
|
|
expect(contribution).toMatchObject({
|
|
actor: MODERATOR,
|
|
kind: "user-deny",
|
|
targets: [{ type: "user", pubkey: CREATOR }],
|
|
});
|
|
});
|
|
|
|
it("decodes an event denial list", () => {
|
|
const contribution = decodeContribution(
|
|
event({
|
|
tags: [
|
|
["d", canonicalIdentifier("app", "event-deny")],
|
|
["e", EVENT_ID],
|
|
],
|
|
}),
|
|
);
|
|
expect(contribution?.targets).toEqual([{ type: "event", id: EVENT_ID }]);
|
|
});
|
|
|
|
it("decodes an address denial list", () => {
|
|
const contribution = decodeContribution(
|
|
event({
|
|
tags: [
|
|
["d", canonicalIdentifier("app", "address-deny")],
|
|
["a", `30078:${CREATOR}:sku-001`],
|
|
],
|
|
}),
|
|
);
|
|
expect(contribution?.targets).toEqual([
|
|
{ type: "address", kind: "30078", pubkey: CREATOR, identifier: "sku-001" },
|
|
]);
|
|
});
|
|
|
|
it("carries a community source marker", () => {
|
|
const contribution = decodeContribution(
|
|
event({
|
|
tags: [
|
|
["d", canonicalIdentifier("app", "user-deny")],
|
|
["source", "list-a"],
|
|
["p", CREATOR],
|
|
],
|
|
}),
|
|
);
|
|
expect(contribution?.source).toBe("list-a");
|
|
});
|
|
|
|
it("skips malformed entries but keeps the rest", () => {
|
|
const contribution = decodeContribution(
|
|
event({
|
|
tags: [
|
|
["d", canonicalIdentifier("app", "user-deny")],
|
|
["p", "not-a-pubkey"],
|
|
["p", CREATOR],
|
|
],
|
|
}),
|
|
);
|
|
expect(contribution?.targets).toHaveLength(1);
|
|
});
|
|
|
|
it("returns null for an unknown scope", () => {
|
|
expect(
|
|
decodeContribution(event({ tags: [["d", canonicalIdentifier("app", "nonsense")]] })),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("prefers an explicit scope tag over the identifier", () => {
|
|
const contribution = decodeContribution(
|
|
event({
|
|
tags: [
|
|
["d", canonicalIdentifier("app", "user-deny")],
|
|
["scope", "user-allow"],
|
|
["p", CREATOR],
|
|
],
|
|
}),
|
|
);
|
|
expect(contribution?.kind).toBe("user-allow");
|
|
});
|
|
});
|
|
|
|
describe("address coordinates", () => {
|
|
it("decodes a coordinate", () => {
|
|
expect(decodeAddressCoordinate(`30078:${CREATOR}:sku-001`)).toEqual({
|
|
type: "address",
|
|
kind: "30078",
|
|
pubkey: CREATOR,
|
|
identifier: "sku-001",
|
|
});
|
|
});
|
|
|
|
it("keeps colons inside the d-tag", () => {
|
|
expect(decodeAddressCoordinate(`30078:${CREATOR}:bitroad:product:sku-001`)?.identifier).toBe(
|
|
"bitroad:product:sku-001",
|
|
);
|
|
});
|
|
|
|
it("rejects a non-numeric kind", () => {
|
|
expect(decodeAddressCoordinate(`abc:${CREATOR}:sku`)).toBeNull();
|
|
});
|
|
|
|
it("rejects a missing identifier", () => {
|
|
expect(decodeAddressCoordinate(`30078:${CREATOR}:`)).toBeNull();
|
|
});
|
|
|
|
it("round-trips", () => {
|
|
const coordinate = `30078:${CREATOR}:sku-001`;
|
|
const target = decodeAddressCoordinate(coordinate);
|
|
expect(encodeAddressCoordinate(/** @type {any} */ (target))).toBe(coordinate);
|
|
});
|
|
});
|
|
|
|
describe("decodeRoles", () => {
|
|
const rolesEvent = event({
|
|
pubkey: ROOT,
|
|
tags: [
|
|
["d", canonicalIdentifier("app", "roles")],
|
|
["p", MODERATOR, "moderator"],
|
|
["cap", MODERATOR, "contribute-address-deny"],
|
|
["protected", CREATOR],
|
|
],
|
|
});
|
|
|
|
it("treats the publisher as root", () => {
|
|
expect(decodeRoles(rolesEvent)?.root).toBe(ROOT);
|
|
});
|
|
|
|
it("reads role assignments", () => {
|
|
expect(decodeRoles(rolesEvent)?.actors[MODERATOR]).toEqual(["moderator"]);
|
|
});
|
|
|
|
it("reads explicit capabilities", () => {
|
|
expect(decodeRoles(rolesEvent)?.capabilities[MODERATOR]).toEqual(["contribute-address-deny"]);
|
|
});
|
|
|
|
it("always protects the root and reads extra protected actors", () => {
|
|
const roles = decodeRoles(rolesEvent);
|
|
expect(roles?.protectedActors).toContain(ROOT);
|
|
expect(roles?.protectedActors).toContain(CREATOR);
|
|
});
|
|
|
|
it("returns null for a non-roles document", () => {
|
|
expect(decodeRoles(event({ tags: [["d", canonicalIdentifier("app", "user-deny")]] }))).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("decodePolicy", () => {
|
|
const policyEvent = (content) =>
|
|
event({ tags: [["d", canonicalIdentifier("app", "policy")]], content });
|
|
|
|
it("parses valid JSON content", () => {
|
|
expect(decodePolicy(policyEvent('{"version":"1"}'))).toEqual({ version: "1" });
|
|
});
|
|
|
|
it("returns null for malformed JSON rather than throwing", () => {
|
|
expect(decodePolicy(policyEvent("{not json"))).toBeNull();
|
|
});
|
|
|
|
it("rejects non-object content", () => {
|
|
expect(decodePolicy(policyEvent("[1,2]"))).toBeNull();
|
|
expect(decodePolicy(policyEvent('"a string"'))).toBeNull();
|
|
});
|
|
|
|
it("rejects oversized content before parsing", () => {
|
|
expect(decodePolicy(policyEvent("x".repeat(MAX_POLICY_BYTES + 1)))).toBeNull();
|
|
});
|
|
|
|
it("rejects empty content", () => {
|
|
expect(decodePolicy(policyEvent(""))).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("encoders", () => {
|
|
it("encodes a contribution round-trip", () => {
|
|
const template = encodeContribution(
|
|
{ actor: MODERATOR, kind: "user-deny", targets: [{ type: "user", pubkey: CREATOR }] },
|
|
"app",
|
|
);
|
|
const decoded = decodeContribution(event({ ...template, pubkey: MODERATOR }));
|
|
expect(decoded?.kind).toBe("user-deny");
|
|
expect(decoded?.targets).toEqual([{ type: "user", pubkey: CREATOR }]);
|
|
});
|
|
|
|
it("encodes an address contribution", () => {
|
|
const template = encodeContribution(
|
|
{
|
|
actor: MODERATOR,
|
|
kind: "address-deny",
|
|
targets: [{ type: "address", kind: "30078", pubkey: CREATOR, identifier: "sku" }],
|
|
},
|
|
"app",
|
|
);
|
|
expect(template.tags).toContainEqual(["a", `30078:${CREATOR}:sku`]);
|
|
});
|
|
|
|
it("encodes roles round-trip", () => {
|
|
const template = encodeRoles(
|
|
{ actors: { [MODERATOR]: ["moderator"] }, capabilities: {}, protectedActors: [] },
|
|
"app",
|
|
);
|
|
const decoded = decodeRoles(event({ ...template, pubkey: ROOT }));
|
|
expect(decoded?.actors[MODERATOR]).toEqual(["moderator"]);
|
|
});
|
|
|
|
it("stamps the client and version tags", () => {
|
|
const template = encodeContribution({ actor: MODERATOR, kind: "user-deny", targets: [] }, "app");
|
|
expect(template.tags).toContainEqual(["v", "1"]);
|
|
expect(template.tags).toContainEqual(["client", "bitgate"]);
|
|
});
|
|
});
|