Files
bitgate/tests/security/audit-regressions.test.js
thePR0M3TH3AN 7cc5756c15 fix(security): second-audit findings + restore cold CI
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.
2026-08-10 09:18:17 -04:00

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);
});
});