mirror of
https://github.com/PR0M3TH3AN/bitgate.git
synced 2026-10-27 08:31:00 +00:00
A second adversarial pass over areas the first audit did not reach: codecs, transport internals, and the verify/outbox/private-mute paths added since. Reliability/CI (most urgent) - The previous commit's Node-safety base-class fallback lost the HTMLElement type, so defineElement() and this-typing broke. It only appeared with dist cleared, so it passed locally with stale declarations and would fail CI from a clean checkout. Annotated the fallback; cold lint/test/build now pass. Security - Relay URLs from a followed contact's kind:10002 flow straight to new WebSocket(url). normalizeRelayUrl only checked the ws(s) prefix, so a URL containing CR/LF or spaces passed through to the socket constructor — a CRLF-injection shape. Now validated with the URL constructor and rejected for any control character or whitespace, with a second guard at the socket boundary regardless of caller. - canonicalStringify recursed without cycle detection: a self-referential value was a stack-overflow DoS rather than a wrong answer. Now guarded with a WeakSet; a repeat serializes as a sentinel. - The viewer's private mute payload was JSON.parsed with no size bound. Capped at 128 KiB, checked before spending a decrypt on it. What held up under this pass: address-coordinate parsing, the outbox per-author relay cap (3), verifier handling of NaN/Infinity/string timestamps, and mute tag robustness against non-string entries. 861 tests; audit clean; cold gates green.
413 lines
14 KiB
JavaScript
413 lines
14 KiB
JavaScript
// 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);
|
|
});
|
|
});
|
|
|
|
describe("second audit: relay url injection", () => {
|
|
it("rejects relay urls with control characters or whitespace", async () => {
|
|
const { normalizeRelayUrl } = await import("@bitgate/nostr");
|
|
for (const url of ["wss://a\r\nb", "ws:// spaces", "wss://\thost", "wss://", "javascript:alert(1)"]) {
|
|
expect(normalizeRelayUrl(url), url).toBe("");
|
|
}
|
|
expect(normalizeRelayUrl("wss://ok.example/")).toBe("wss://ok.example");
|
|
});
|
|
|
|
it("drops a malicious contact's crlf relay from an outbox grouping", async () => {
|
|
const { groupAuthorsByWriteRelay } = await import("@bitgate/nostr");
|
|
const pubkey = "d4".repeat(32);
|
|
const lists = new Map([
|
|
[pubkey, { pubkey, updatedAt: NOW, read: [], write: ["wss://evil\r\nInjected: x", "wss://good.example"] }],
|
|
]);
|
|
const grouped = groupAuthorsByWriteRelay([pubkey], lists);
|
|
expect([...grouped.keys()]).toEqual(["wss://good.example"]);
|
|
});
|
|
});
|
|
|
|
describe("second audit: fingerprint cycle safety", () => {
|
|
it("does not overflow on a self-referential value", async () => {
|
|
const { canonicalStringify, fingerprint } = await import("@bitgate/core");
|
|
const cyclic = { a: 1 };
|
|
cyclic.self = cyclic;
|
|
expect(() => canonicalStringify(cyclic)).not.toThrow();
|
|
expect(() => fingerprint(cyclic)).not.toThrow();
|
|
});
|
|
|
|
it("still distinguishes genuinely different structures", async () => {
|
|
const { fingerprint } = await import("@bitgate/core");
|
|
expect(fingerprint({ a: 1 })).not.toBe(fingerprint({ a: 2 }));
|
|
});
|
|
});
|
|
|
|
describe("second audit: private mute payload is bounded", () => {
|
|
it("refuses an oversized encrypted payload before parsing", async () => {
|
|
const { decodePrivateMuteEntries, MUTE_LIST_KIND } = await import("@bitgate/nostr");
|
|
const viewer = "d4".repeat(32);
|
|
const event = {
|
|
id: "a".repeat(64),
|
|
pubkey: viewer,
|
|
kind: MUTE_LIST_KIND,
|
|
created_at: 1,
|
|
tags: [],
|
|
content: "x".repeat(200 * 1024),
|
|
};
|
|
let decryptCalled = false;
|
|
const entries = await decodePrivateMuteEntries(event, {
|
|
viewerPubkey: viewer,
|
|
decrypt: async () => {
|
|
decryptCalled = true;
|
|
return "[]";
|
|
},
|
|
});
|
|
expect(entries).toEqual([]);
|
|
// The bound is checked before spending a decrypt on it.
|
|
expect(decryptCalled).toBe(false);
|
|
});
|
|
});
|