mirror of
https://github.com/PR0M3TH3AN/bitgate.git
synced 2026-10-27 08:31:00 +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
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user