OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-rooms / test / writer-op-authorization.test.js

134 lines · javascript ref:
// 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(() => {}))
})