| … | … | @@ -0,0 +1,133 @@ |
| 1 | // SECURITY (pear-git org #15) — writer-set ops are authorized by AUTHOR. |
| 2 | // |
| 3 | // _applyAddWriter used to honour any add-writer op that appeared in the |
| 4 | // linearized log, with no check on who wrote it, and took the indexer flag |
| 5 | // straight from the op (`indexer: op.role === 'owner'`). So an existing plain |
| 6 | // writer could append `{type:'add-writer', role:'owner'}` naming its own key |
| 7 | // and promote ITSELF to indexer — the requireAdmin on pear-git-v3's |
| 8 | // repo-add-writer RPC could not stop it, because apply runs on ops that |
| 9 | // arrive by REPLICATION, not only on ops that came through that RPC. |
| 10 | // |
| 11 | // Worse than a permissions bug: an indexer-set change migrates the autobase |
| 12 | // manifest, which is what poisons standalone-peer trackers (pear-git #3), so |
| 13 | // it doubles as a denial of service on the room. |
| 14 | // |
| 15 | // The gate: the op's author must be a current indexer. Autobase hands apply |
| 16 | // `node.from` (the authoring writer's core), and indexers are autobase's own |
| 17 | // trust root, so this reuses the authority the room already depends on. |
| 18 | |
| 19 | import test from 'brittle' |
| 20 | import Corestore from 'corestore' |
| 21 | import { mkdtempSync } from 'fs' |
| 22 | import { join } from 'path' |
| 23 | import { tmpdir } from 'os' |
| 24 | import b4a from 'b4a' |
| 25 | import { replicateAndSync } from 'autobase-test-helpers' |
| 26 | import { Room, jsonSchema } from '../index.js' |
| 27 | |
| 28 | function tmp () { return mkdtempSync(join(tmpdir(), 'rooms-writerauth-')) } |
| 29 | const schema = jsonSchema({ validate () {}, async apply () {} }) |
| 30 | |
| 31 | async function settle (rooms, rounds = 50) { |
| 32 | const bases = rooms.map((r) => r.base) |
| 33 | for (let i = 0; i < rounds; i++) { |
| 34 | for (const b of bases) { try { await b.ack() } catch {} } |
| 35 | await replicateAndSync(bases) |
| 36 | } |
| 37 | } |
| 38 | |
| 39 | const hasWriter = async (room, keyHex) => (await room.listWriters()).some((w) => w.writerKey === keyHex) |
| 40 | |
| 41 | function isIndexer (room, keyHex) { |
| 42 | const indexers = room.base.system && room.base.system.indexers ? room.base.system.indexers : [] |
| 43 | return indexers.some((i) => i && i.key && b4a.toString(i.key, 'hex') === keyHex) |
| 44 | } |
| 45 | |
| 46 | // Genesis room G (creator = the only indexer) plus A, admitted by G as a |
| 47 | // PLAIN writer. A is the attacker: legitimately admitted, not an indexer. |
| 48 | async function setup (dir) { |
| 49 | const G = new Room(new Corestore(join(dir, 'G')), { schema, namespace: 'ns' }) |
| 50 | await G.ready() |
| 51 | const A = new Room(new Corestore(join(dir, 'A')), { schema, namespace: 'ns', bootstrapKey: G.key }) |
| 52 | await A.ready() |
| 53 | await settle([G, A]) |
| 54 | await G.addWriter('A', { writerKey: A.localKeyHex, role: 'writer' }) |
| 55 | await settle([G, A]) |
| 56 | return { G, A } |
| 57 | } |
| 58 | |
| 59 | test('control: an indexer CAN still add a writer', async (t) => { |
| 60 | const dir = tmp() |
| 61 | const { G, A } = await setup(dir) |
| 62 | t.ok(await hasWriter(G, A.localKeyHex), 'G (the genesis indexer) admitted A') |
| 63 | t.ok(A.base.writable, 'A is writable — the normal admission path is intact') |
| 64 | t.teardown(() => Promise.all([G.close(), A.close()]).catch(() => {})) |
| 65 | }) |
| 66 | |
| 67 | test('a plain writer CANNOT promote itself to indexer via add-writer role:owner', async (t) => { |
| 68 | const dir = tmp() |
| 69 | const { G, A } = await setup(dir) |
| 70 | |
| 71 | t.absent(isIndexer(G, A.localKeyHex), 'precondition: A is a writer but NOT an indexer') |
| 72 | |
| 73 | // THE ATTACK: A appends an add-writer op for its own key claiming 'owner', |
| 74 | // which the old apply turned into host.addWriter(..., { indexer: true }). |
| 75 | await A.addWriter('A-escalated', { writerKey: A.localKeyHex, role: 'owner' }) |
| 76 | await settle([G, A]) |
| 77 | |
| 78 | t.absent(isIndexer(G, A.localKeyHex), 'A did NOT become an indexer on G') |
| 79 | t.absent(isIndexer(A, A.localKeyHex), 'and did not become one in its own view either') |
| 80 | |
| 81 | const escalated = (await G.listWriters()).find((w) => w.userId === 'A-escalated') |
| 82 | t.absent(escalated, 'the self-authored owner entry was refused, not recorded') |
| 83 | |
| 84 | t.teardown(() => Promise.all([G.close(), A.close()]).catch(() => {})) |
| 85 | }) |
| 86 | |
| 87 | test('a plain writer CANNOT admit a third party', async (t) => { |
| 88 | const dir = tmp() |
| 89 | const { G, A } = await setup(dir) |
| 90 | |
| 91 | const B = new Room(new Corestore(join(dir, 'B')), { schema, namespace: 'ns', bootstrapKey: G.key }) |
| 92 | await B.ready() |
| 93 | await settle([G, A, B]) |
| 94 | |
| 95 | // A is a writer but not an indexer, so it may not extend the writer set. |
| 96 | await A.addWriter('B', { writerKey: B.localKeyHex, role: 'writer' }) |
| 97 | await settle([G, A, B]) |
| 98 | |
| 99 | t.absent(await hasWriter(G, B.localKeyHex), 'B was not admitted by a non-indexer') |
| 100 | t.absent(B.base.writable, 'B stays read-only') |
| 101 | |
| 102 | t.teardown(() => Promise.all([G.close(), A.close(), B.close()]).catch(() => {})) |
| 103 | }) |
| 104 | |
| 105 | test('a plain writer CANNOT evict another writer', async (t) => { |
| 106 | const dir = tmp() |
| 107 | const { G, A } = await setup(dir) |
| 108 | |
| 109 | // Not named in the issue, but the same defect on the same writer set: |
| 110 | // ungated, any writer could remove any other — including an indexer. |
| 111 | await A.removeWriter('A') |
| 112 | await settle([G, A]) |
| 113 | |
| 114 | t.ok(await hasWriter(G, A.localKeyHex), 'the writer entry survives a non-indexer remove-writer') |
| 115 | |
| 116 | t.teardown(() => Promise.all([G.close(), A.close()]).catch(() => {})) |
| 117 | }) |
| 118 | |
| 119 | test('an indexer can still promote a writer to owner (legitimate path preserved)', async (t) => { |
| 120 | const dir = tmp() |
| 121 | const { G, A } = await setup(dir) |
| 122 | |
| 123 | // This is what `pgh org set-auth --level owner` / `pgh repo grant-writer` |
| 124 | // do, so the gate must not break them. |
| 125 | await G.addWriter('A', { writerKey: A.localKeyHex, role: 'owner' }) |
| 126 | await settle([G, A]) |
| 127 | |
| 128 | const w = (await G.listWriters()).find((x) => x.userId === 'A') |
| 129 | t.is(w && w.role, 'owner', 'indexer-authored promotion is recorded') |
| 130 | t.ok(isIndexer(G, A.localKeyHex), 'and A really did become an indexer') |
| 131 | |
| 132 | t.teardown(() => Promise.all([G.close(), A.close()]).catch(() => {})) |
| 133 | }) |