OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-rooms commits / 41bb020

0.1.5: authorize writer-set ops by author, not by the RPC (pear-git org #15)
_applyAddWriter honoured any add-writer op that appeared in the linearized log, with no check on who authored it, and took the indexer flag straight from the op (`indexer: op.role === 'owner'`). An existing plain writer could append {type:'add-writer', role:'owner'} naming its own key and promote ITSELF to indexer. pear-git-v3's requireAdmin on repo-add-writer could not prevent it: apply runs on ops that arrive by REPLICATION, not only on ops that came through that RPC, so authorization has to live in apply or nowhere. Beyond the privilege issue, an indexer-set change migrates the autobase manifest — precisely what poisons standalone-peer trackers (pear-git #3) — so this was also a denial of service on the room. The op's author must now be a current indexer. Autobase already hands apply what is needed: every applyBatch node carries `from`, the authoring writer's core (lib/apply-state.js:1086,1300), and host is PrivateApplyCalls which exposes `system` (lib/apply-calls.js:58). Both node.from.key and system.indexers[].key are writer CORE keys — verified, since system.add() is called with the writer key and removeable() compares indexers[0].key against one — so they are directly comparable. Indexers are autobase's own trust root, the quorum that signs the linearized view, so this reuses the authority the room already depends on rather than inventing one. It needs no new op fields and no proof plumbing, so unlike the voucher approach it does not require every existing caller to be updated in lockstep. Genesis is handled: with no indexers recorded yet the creator is the only possible author, so there is nobody to authorize against. _applyRemoveWriter gets the same gate. Not named in the issue, but it is the same defect on the same writer set — ungated, any writer could evict any other, including an indexer. Gating only the add side would have left the room removable by a plain writer. Refusals are logged rather than silent: a writer-set change that stops applying is otherwise indistinguishable from replication lag, and this is new behaviour on a substrate hyper-issues and hyper-orgs both build on. Promotion by an existing indexer still works — that is what `pgh org set-auth --level owner` and `pgh repo grant-writer` are — and there is a test for it so the gate cannot quietly break them. Verified in the failing direction: against the old code the suite is 2/5, the self-escalation to indexer SUCCEEDS, a non-indexer admits a third party, and a non-indexer evicts a writer. The 2 that pass are exactly the two legitimate controls, which pass before and after. Full existing suite stays green under Node: 33/33 tests, 92/92 asserts (npm test; four test files import Node builtins bare-style so they run under Node, not `bare`). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
41bb020a6e830edf091a948335f6eed436f802c2
by plankatron <fleeky@gmail.com> · 2026-07-30T14:47:31.000Z

Changed files (3) +215 -6

lib/room.js +81 -5
……@@ -315,13 +315,15 @@ export class Room extends ReadyResource {
315315 continue
316316 }
317317
318 // Generic writer ops
318 // Generic writer ops. `node` is passed through so the apply can see WHO
319 // authored the op — authorization for writer-set changes lives here, not
320 // at the RPC that happened to produce them (org #15).
319321 if (op.type === 'add-writer') {
320 await this._applyAddWriter(batch, op, host)
322 await this._applyAddWriter(batch, op, host, node)
321323 continue
322324 }
323325 if (op.type === 'remove-writer') {
324 await this._applyRemoveWriter(batch, op, host)
326 await this._applyRemoveWriter(batch, op, host, node)
325327 continue
326328 }
327329 if (op.type === 'request-writer') {
……@@ -343,7 +345,72 @@ export class Room extends ReadyResource {
343345 await batch.flush()
344346 }
345347
346 async _applyAddWriter (batch, op, host) {
348 /**
349 * Is the op's author a current INDEXER of this room? (org #15)
350 *
351 * Autobase hands every apply node a `from` — the authoring writer's core —
352 * so the writer set can be authorized from inside apply, which is the only
353 * place that works: apply runs on ops that arrive by REPLICATION, not just
354 * on ops that came through some RPC, so a gate on the RPC (pear-git-v3's
355 * requireAdmin on repo-add-writer) cannot bind an op appended directly to
356 * the base by an existing writer.
357 *
358 * Indexers are autobase's own trust root — the quorum that signs the
359 * linearized view — so "author is an indexer" is the same authority the
360 * room already relies on, not a new concept layered on top.
361 *
362 * Genesis: before any indexer is recorded, the room's creator is the only
363 * possible author and there is nobody to authorize against, so allow.
364 */
365 _authorIsIndexer (host, node) {
366 const author = node && node.from && node.from.key
367 if (!author) return false
368
369 const system = host && host.system
370 const indexers = system && Array.isArray(system.indexers) ? system.indexers : []
371 if (indexers.length === 0) return true
372
373 for (const idx of indexers) {
374 if (idx && idx.key && b4a.equals(idx.key, author)) return true
375 }
376 return false
377 }
378
379 // Refusals are LOGGED, never silent. A writer-set change that stops applying
380 // is otherwise indistinguishable from replication lag, and this gate is new
381 // behaviour on a substrate that hyper-issues and hyper-orgs both build on.
382 _refuseWriterOp (kind, op, node) {
383 const author = node && node.from && node.from.key
384 // eslint-disable-next-line no-console
385 console.error('[hyper-rooms] refused ' + kind + ' (ns=' + this._namespace + '): author ' +
386 (author ? b4a.toString(author, 'hex').slice(0, 16) : 'unknown') +
387 ' is not a current indexer; userId=' + (op && op.userId))
388 }
389
390 /**
391 * Apply an add-writer op. (org #15)
392 *
393 * This used to honour ANY add-writer op that appeared in the linearized log,
394 * with no check on who authored it — and `indexer: op.role === 'owner'` took
395 * the role straight from the op. So an existing plain writer could append
396 * `{type:'add-writer', role:'owner'}` for its own key and promote ITSELF to
397 * indexer, bypassing the requireAdmin on the RPC entirely.
398 *
399 * That was worse than a permissions issue: changing the indexer set migrates
400 * the autobase manifest, which is precisely what poisons standalone-peer
401 * trackers (pear-git #3), so it doubled as a denial of service on the room.
402 *
403 * Now the author must be a current indexer. Note this leaves promotion in
404 * the hands of existing indexers — which is what `pgh org set-auth --level
405 * owner` and `pgh repo grant-writer` already are — while removing the
406 * self-service path a plain writer had.
407 */
408 async _applyAddWriter (batch, op, host, node) {
409 if (!this._authorIsIndexer(host, node)) {
410 this._refuseWriterOp('add-writer', op, node)
411 return
412 }
413
347414 await batch.put(`writer:${op.userId}`, {
348415 role: op.role,
349416 writerKey: op.writerKey || null,
……@@ -418,7 +485,16 @@ export class Room extends ReadyResource {
418485 }
419486 }
420487
421 async _applyRemoveWriter (batch, op, host) {
488 // Same authorization as add-writer (org #15). Not named in the issue, but it
489 // is the same defect on the same writer set: ungated, any writer could evict
490 // any other writer — including an indexer — by appending one op. Gating only
491 // the add side would have left the room removable by a plain writer.
492 async _applyRemoveWriter (batch, op, host, node) {
493 if (!this._authorIsIndexer(host, node)) {
494 this._refuseWriterOp('remove-writer', op, node)
495 return
496 }
497
422498 const existing = await batch.get(`writer:${op.userId}`)
423499 if (!existing) return
424500
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "hyper-rooms",
3 "version": "0.1.4",
3 "version": "0.1.5",
44 "description": "Multiwriter rooms on Autobase with pluggable schemas — the substrate for hyper-issues, hyper-orgs, etc.",
55 "type": "module",
66 "main": "index.js",
test/writer-op-authorization.test.js +133 -0
……@@ -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
19import test from 'brittle'
20import Corestore from 'corestore'
21import { mkdtempSync } from 'fs'
22import { join } from 'path'
23import { tmpdir } from 'os'
24import b4a from 'b4a'
25import { replicateAndSync } from 'autobase-test-helpers'
26import { Room, jsonSchema } from '../index.js'
27
28function tmp () { return mkdtempSync(join(tmpdir(), 'rooms-writerauth-')) }
29const schema = jsonSchema({ validate () {}, async apply () {} })
30
31async 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
39const hasWriter = async (room, keyHex) => (await room.listWriters()).some((w) => w.writerKey === keyHex)
40
41function 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.
48async 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
59test('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
67test('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
87test('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
105test('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
119test('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})