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.
338 lines
11 KiB
JavaScript
338 lines
11 KiB
JavaScript
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import { createPolicyDefinition } from "@bitgate/core";
|
|
import { REPORT_KIND, encodeRoles } from "@bitgate/nostr";
|
|
|
|
import { ERROR_CODES, createCommands } from "../src/commands.js";
|
|
import { createBitGate } from "../src/runtime.js";
|
|
import { createMemoryTransport } from "../src/interfaces.js";
|
|
|
|
const ROOT = "a1".repeat(32);
|
|
const MODERATOR = "b2".repeat(32);
|
|
const CURATOR = "c3".repeat(32);
|
|
const CREATOR = "d4".repeat(32);
|
|
const STRANGER = "f6".repeat(32);
|
|
const EVENT_ID = "1b".repeat(32);
|
|
const NOW = 1_750_000_000;
|
|
|
|
const POLICY = createPolicyDefinition({
|
|
id: "app",
|
|
version: "1.0.0",
|
|
profiles: { feed: { name: "feed" } },
|
|
});
|
|
|
|
/**
|
|
* A signer for a given pubkey. Signing simply stamps an id, which is enough for
|
|
* transport assertions and keeps crypto out of the test.
|
|
* @param {string} pubkey
|
|
*/
|
|
function signerFor(pubkey) {
|
|
return {
|
|
async getPublicKey() {
|
|
return pubkey;
|
|
},
|
|
async signEvent(template) {
|
|
return { ...template, id: "ff".repeat(32), pubkey, sig: "00".repeat(64) };
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param {Object} [options]
|
|
* @param {string} [options.actor]
|
|
* @param {any} [options.signer]
|
|
* @param {any} [options.transport]
|
|
*/
|
|
function setup({ actor = MODERATOR, signer, transport } = {}) {
|
|
const memory = transport ?? createMemoryTransport();
|
|
const runtime = createBitGate({
|
|
applicationId: "test",
|
|
namespace: "app",
|
|
transport: memory,
|
|
signer: signer ?? signerFor(actor),
|
|
policy: POLICY,
|
|
now: () => NOW,
|
|
});
|
|
|
|
runtime.admin.setRoles({
|
|
root: ROOT,
|
|
actors: {
|
|
[ROOT]: ["super_admin"],
|
|
[MODERATOR]: ["moderator"],
|
|
[CURATOR]: ["curator"],
|
|
},
|
|
});
|
|
|
|
return { runtime, commands: createCommands(runtime), transport: memory };
|
|
}
|
|
|
|
describe("authority enforcement", () => {
|
|
it("permits a moderator to deny a user", async () => {
|
|
const { commands, transport } = setup();
|
|
const result = await commands.denyUser(CREATOR);
|
|
expect(result.ok).toBe(true);
|
|
expect(transport.published).toHaveLength(1);
|
|
});
|
|
|
|
it("refuses an actor with no roles", async () => {
|
|
const { commands, transport } = setup({ actor: STRANGER });
|
|
const result = await commands.denyUser(CREATOR);
|
|
expect(result.ok).toBe(false);
|
|
expect(result.code).toBe(ERROR_CODES.NOT_AUTHORIZED);
|
|
expect(transport.published).toHaveLength(0);
|
|
});
|
|
|
|
it("refuses a curator denying an event", async () => {
|
|
const { commands } = setup({ actor: CURATOR });
|
|
const result = await commands.denyEvent(EVENT_ID);
|
|
expect(result.code).toBe(ERROR_CODES.NOT_AUTHORIZED);
|
|
});
|
|
|
|
it("permits a curator denying a user", async () => {
|
|
const { commands } = setup({ actor: CURATOR });
|
|
expect((await commands.denyUser(CREATOR)).ok).toBe(true);
|
|
});
|
|
|
|
it("never invokes the signer for an unauthorized command", async () => {
|
|
const signer = signerFor(STRANGER);
|
|
const signSpy = vi.spyOn(signer, "signEvent");
|
|
const { commands } = setup({ signer });
|
|
|
|
await commands.denyUser(CREATOR);
|
|
expect(signSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("stops accepting commands after a role is revoked", async () => {
|
|
const { runtime, commands } = setup();
|
|
expect((await commands.denyUser(CREATOR)).ok).toBe(true);
|
|
|
|
runtime.admin.setRoles({ root: ROOT, actors: { [ROOT]: ["super_admin"] } });
|
|
expect((await commands.denyUser(CREATOR)).code).toBe(ERROR_CODES.NOT_AUTHORIZED);
|
|
});
|
|
|
|
it("refuses role management to a moderator", async () => {
|
|
const { commands } = setup();
|
|
expect((await commands.setRoles({ actors: {} })).code).toBe(ERROR_CODES.NOT_AUTHORIZED);
|
|
});
|
|
|
|
it("permits role management to the root", async () => {
|
|
const { commands } = setup({ actor: ROOT });
|
|
expect((await commands.setRoles({ actors: { [MODERATOR]: ["moderator"] } })).ok).toBe(true);
|
|
});
|
|
|
|
it("refuses policy management to a moderator", async () => {
|
|
const { commands } = setup();
|
|
expect((await commands.setPolicy(POLICY)).code).toBe(ERROR_CODES.NOT_AUTHORIZED);
|
|
});
|
|
|
|
it("refuses community-source management to a curator", async () => {
|
|
const { commands } = setup({ actor: CURATOR });
|
|
const result = await commands.setCommunitySources([
|
|
{ curator: CURATOR, identifier: "list", kind: 30078 },
|
|
]);
|
|
expect(result.code).toBe(ERROR_CODES.NOT_AUTHORIZED);
|
|
});
|
|
});
|
|
|
|
describe("protected targets", () => {
|
|
it("refuses to deny the root", async () => {
|
|
const { commands } = setup();
|
|
const result = await commands.denyUser(ROOT);
|
|
expect(result.code).toBe(ERROR_CODES.PROTECTED_TARGET);
|
|
});
|
|
|
|
it("does not publish a refused denial", async () => {
|
|
const { commands, transport } = setup();
|
|
await commands.denyUser(ROOT);
|
|
expect(transport.published).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("argument validation", () => {
|
|
it("rejects an unknown contribution kind", async () => {
|
|
const { commands } = setup();
|
|
const result = await commands.contribute(/** @type {any} */ ("nonsense"), [
|
|
{ type: "user", pubkey: CREATOR },
|
|
]);
|
|
expect(result.code).toBe(ERROR_CODES.INVALID_ARGUMENT);
|
|
});
|
|
|
|
it("rejects an empty target list", async () => {
|
|
const { commands } = setup();
|
|
expect((await commands.contribute("user-deny", [])).code).toBe(ERROR_CODES.INVALID_ARGUMENT);
|
|
});
|
|
|
|
it("rejects a malformed target", async () => {
|
|
const { commands } = setup();
|
|
expect((await commands.denyUser("not-a-pubkey")).code).toBe(ERROR_CODES.INVALID_TARGET);
|
|
});
|
|
|
|
it("rejects a non-object policy", async () => {
|
|
const { commands } = setup({ actor: ROOT });
|
|
expect((await commands.setPolicy(/** @type {any} */ (null))).code).toBe(
|
|
ERROR_CODES.INVALID_ARGUMENT,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("signer failures", () => {
|
|
it("reports a missing signer", async () => {
|
|
const runtime = createBitGate({
|
|
applicationId: "test",
|
|
namespace: "app",
|
|
transport: createMemoryTransport(),
|
|
policy: POLICY,
|
|
now: () => NOW,
|
|
});
|
|
const commands = createCommands(runtime);
|
|
const result = await commands.denyUser(CREATOR);
|
|
expect(result.code).toBe(ERROR_CODES.NO_SIGNER);
|
|
});
|
|
});
|
|
|
|
describe("publishing", () => {
|
|
it("succeeds on partial relay acceptance and keeps failures visible", async () => {
|
|
const transport = createMemoryTransport();
|
|
transport.publish = async (event) => ({
|
|
ok: true,
|
|
accepted: ["wss://a"],
|
|
failed: [{ relay: "wss://b", error: "rejected" }],
|
|
});
|
|
const { commands } = setup({ transport });
|
|
|
|
const result = await commands.denyUser(CREATOR);
|
|
expect(result.ok).toBe(true);
|
|
expect(result.accepted).toEqual(["wss://a"]);
|
|
expect(result.failed).toHaveLength(1);
|
|
});
|
|
|
|
it("fails when no relay accepts", async () => {
|
|
const transport = createMemoryTransport();
|
|
transport.publish = async () => ({
|
|
ok: false,
|
|
accepted: [],
|
|
failed: [{ relay: "wss://b", error: "rejected" }],
|
|
});
|
|
const { commands } = setup({ transport });
|
|
|
|
const result = await commands.denyUser(CREATOR);
|
|
expect(result.ok).toBe(false);
|
|
expect(result.code).toBe(ERROR_CODES.PUBLISH_FAILED);
|
|
});
|
|
|
|
it("reports a transport that throws", async () => {
|
|
const transport = createMemoryTransport();
|
|
transport.publish = async () => {
|
|
throw new Error("network down");
|
|
};
|
|
const { commands } = setup({ transport });
|
|
|
|
const result = await commands.denyUser(CREATOR);
|
|
expect(result.code).toBe(ERROR_CODES.PUBLISH_FAILED);
|
|
expect(result.event).toBeDefined();
|
|
});
|
|
|
|
it("stamps the injected clock onto signed events", async () => {
|
|
const { commands, transport } = setup();
|
|
await commands.denyUser(CREATOR);
|
|
expect(transport.published[0].created_at).toBe(NOW);
|
|
});
|
|
});
|
|
|
|
describe("contribution round-trip", () => {
|
|
it("produces an event the runtime can ingest back into state", async () => {
|
|
const { runtime, commands, transport } = setup();
|
|
await commands.denyUser(CREATOR);
|
|
|
|
const fresh = createBitGate({
|
|
applicationId: "test",
|
|
namespace: "app",
|
|
transport: createMemoryTransport(),
|
|
policy: POLICY,
|
|
now: () => NOW,
|
|
root: ROOT,
|
|
trustUnsignedEvents: true,
|
|
});
|
|
fresh.admin.setRoles({
|
|
root: ROOT,
|
|
actors: { [ROOT]: ["super_admin"], [MODERATOR]: ["moderator"] },
|
|
});
|
|
fresh.ingestEvent(transport.published[0]);
|
|
|
|
expect(fresh.admin.state.userDeny.has(`user:${CREATOR}`)).toBe(true);
|
|
});
|
|
|
|
it("marks a community source on the published event", async () => {
|
|
const { commands, transport } = setup({ actor: CURATOR });
|
|
await commands.denyUser(CREATOR, { source: "list-a" });
|
|
expect(transport.published[0].tags).toContainEqual(["source", "list-a"]);
|
|
});
|
|
|
|
it("denies an address coordinate", async () => {
|
|
const { commands, transport } = setup();
|
|
const result = await commands.denyAddress("30078", CREATOR, "sku-001");
|
|
expect(result.ok).toBe(true);
|
|
expect(transport.published[0].tags).toContainEqual(["a", `30078:${CREATOR}:sku-001`]);
|
|
});
|
|
|
|
it("adds trust seeds", async () => {
|
|
const { commands } = setup();
|
|
expect((await commands.addTrustSeeds([CREATOR])).ok).toBe(true);
|
|
});
|
|
|
|
it("allows a user when the actor holds the capability", async () => {
|
|
const { commands } = setup({ actor: ROOT });
|
|
expect((await commands.allowUser(CREATOR)).ok).toBe(true);
|
|
});
|
|
|
|
it("refuses user-allow to a moderator", async () => {
|
|
const { commands } = setup();
|
|
expect((await commands.allowUser(CREATOR)).code).toBe(ERROR_CODES.NOT_AUTHORIZED);
|
|
});
|
|
});
|
|
|
|
describe("reporting", () => {
|
|
it("needs no capability", async () => {
|
|
const { commands, transport } = setup({ actor: STRANGER });
|
|
const result = await commands.report({ type: "event", id: EVENT_ID }, "spam");
|
|
expect(result.ok).toBe(true);
|
|
expect(transport.published[0].kind).toBe(REPORT_KIND);
|
|
});
|
|
|
|
it("rejects a malformed target", async () => {
|
|
const { commands } = setup();
|
|
const result = await commands.report(/** @type {any} */ ({ type: "event", id: "x" }), "spam");
|
|
expect(result.code).toBe(ERROR_CODES.INVALID_TARGET);
|
|
});
|
|
|
|
it("rejects an empty category", async () => {
|
|
const { commands } = setup();
|
|
const result = await commands.report({ type: "event", id: EVENT_ID }, " ");
|
|
expect(result.code).toBe(ERROR_CODES.INVALID_ARGUMENT);
|
|
});
|
|
});
|
|
|
|
describe("viewer overrides", () => {
|
|
it("sets an override locally without publishing", () => {
|
|
const { runtime, commands, transport } = setup();
|
|
const result = commands.setOverride({ type: "user", pubkey: CREATOR }, "allow");
|
|
expect(result.ok).toBe(true);
|
|
expect(transport.published).toHaveLength(0);
|
|
expect(runtime.overrides.toMap().has(`user:${CREATOR}`)).toBe(true);
|
|
});
|
|
|
|
it("clears an override", () => {
|
|
const { runtime, commands } = setup();
|
|
commands.setOverride({ type: "user", pubkey: CREATOR }, "allow");
|
|
commands.clearOverride({ type: "user", pubkey: CREATOR });
|
|
expect(runtime.overrides.toMap().size).toBe(0);
|
|
});
|
|
|
|
it("rejects a malformed target", () => {
|
|
const { commands } = setup();
|
|
expect(commands.setOverride(/** @type {any} */ ({ type: "user", pubkey: "x" }), "allow").code).toBe(
|
|
ERROR_CODES.INVALID_TARGET,
|
|
);
|
|
});
|
|
});
|