// SECURITY (pear-git org #15) — writer-set ops are authorized by AUTHOR.
//
// _applyAddWriter used to honour any add-writer op that appeared in the
// linearized log, with no check on who wrote it, and took the indexer flag
// straight from the op (`indexer: op.role === 'owner'`). So an existing plain
// writer could append `{type:'add-writer', role:'owner'}` naming its own key
// and promote ITSELF to indexer — the requireAdmin on pear-git-v3's
// repo-add-writer RPC could not stop it, because apply runs on ops that
// arrive by REPLICATION, not only on ops that came through that RPC.
//
// Worse than a permissions bug: an indexer-set change migrates the autobase
// manifest, which is what poisons standalone-peer trackers (pear-git #3), so
// it doubles as a denial of service on the room.
//
// The gate: the op's author must be a current indexer. Autobase hands apply
// `node.from` (the authoring writer's core), and indexers are autobase's own
// trust root, so this reuses the authority the room already depends on.
import test from 'brittle'
import Corestore from 'corestore'
import { mkdtempSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import b4a from 'b4a'
import { replicateAndSync } from 'autobase-test-helpers'
import { Room, jsonSchema } from '../index.js'
function tmp () { return mkdtempSync(join(tmpdir(), 'rooms-writerauth-')) }
const schema = jsonSchema({ validate () {}, async apply () {} })
async function settle (rooms, rounds = 50) {
const bases = rooms.map((r) => r.base)
for (let i = 0; i < rounds; i++) {
for (const b of bases) { try { await b.ack() } catch {} }
await replicateAndSync(bases)
}
}
const hasWriter = async (room, keyHex) => (await room.listWriters()).some((w) => w.writerKey === keyHex)
function isIndexer (room, keyHex) {
const indexers = room.base.system && room.base.system.indexers ? room.base.system.indexers : []
return indexers.some((i) => i && i.key && b4a.toString(i.key, 'hex') === keyHex)
}
// Genesis room G (creator = the only indexer) plus A, admitted by G as a
// PLAIN writer. A is the attacker: legitimately admitted, not an indexer.
async function setup (dir) {
const G = new Room(new Corestore(join(dir, 'G')), { schema, namespace: 'ns' })
await G.ready()
const A = new Room(new Corestore(join(dir, 'A')), { schema, namespace: 'ns', bootstrapKey: G.key })
await A.ready()
await settle([G, A])
await G.addWriter('A', { writerKey: A.localKeyHex, role: 'writer' })
await settle([G, A])
return { G, A }
}
test('control: an indexer CAN still add a writer', async (t) => {
const dir = tmp()
const { G, A } = await setup(dir)
t.ok(await hasWriter(G, A.localKeyHex), 'G (the genesis indexer) admitted A')
t.ok(A.base.writable, 'A is writable — the normal admission path is intact')
t.teardown(() => Promise.all([G.close(), A.close()]).catch(() => {}))
})
test('a plain writer CANNOT promote itself to indexer via add-writer role:owner', async (t) => {
const dir = tmp()
const { G, A } = await setup(dir)
t.absent(isIndexer(G, A.localKeyHex), 'precondition: A is a writer but NOT an indexer')
// THE ATTACK: A appends an add-writer op for its own key claiming 'owner',
// which the old apply turned into host.addWriter(..., { indexer: true }).
await A.addWriter('A-escalated', { writerKey: A.localKeyHex, role: 'owner' })
await settle([G, A])
t.absent(isIndexer(G, A.localKeyHex), 'A did NOT become an indexer on G')
t.absent(isIndexer(A, A.localKeyHex), 'and did not become one in its own view either')
const escalated = (await G.listWriters()).find((w) => w.userId === 'A-escalated')
t.absent(escalated, 'the self-authored owner entry was refused, not recorded')
t.teardown(() => Promise.all([G.close(), A.close()]).catch(() => {}))
})
test('a plain writer CANNOT admit a third party', async (t) => {
const dir = tmp()
const { G, A } = await setup(dir)
const B = new Room(new Corestore(join(dir, 'B')), { schema, namespace: 'ns', bootstrapKey: G.key })
await B.ready()
await settle([G, A, B])
// A is a writer but not an indexer, so it may not extend the writer set.
await A.addWriter('B', { writerKey: B.localKeyHex, role: 'writer' })
await settle([G, A, B])
t.absent(await hasWriter(G, B.localKeyHex), 'B was not admitted by a non-indexer')
t.absent(B.base.writable, 'B stays read-only')
t.teardown(() => Promise.all([G.close(), A.close(), B.close()]).catch(() => {}))
})
test('a plain writer CANNOT evict another writer', async (t) => {
const dir = tmp()
const { G, A } = await setup(dir)
// Not named in the issue, but the same defect on the same writer set:
// ungated, any writer could remove any other — including an indexer.
await A.removeWriter('A')
await settle([G, A])
t.ok(await hasWriter(G, A.localKeyHex), 'the writer entry survives a non-indexer remove-writer')
t.teardown(() => Promise.all([G.close(), A.close()]).catch(() => {}))
})
test('an indexer can still promote a writer to owner (legitimate path preserved)', async (t) => {
const dir = tmp()
const { G, A } = await setup(dir)
// This is what `pgh org set-auth --level owner` / `pgh repo grant-writer`
// do, so the gate must not break them.
await G.addWriter('A', { writerKey: A.localKeyHex, role: 'owner' })
await settle([G, A])
const w = (await G.listWriters()).find((x) => x.userId === 'A')
t.is(w && w.role, 'owner', 'indexer-authored promotion is recorded')
t.ok(isIndexer(G, A.localKeyHex), 'and A really did become an indexer')
t.teardown(() => Promise.all([G.close(), A.close()]).catch(() => {}))
})