OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / test / ref-update-observed-untrusted-body.test.js

173 lines · javascript ref:
// SECURITY (pear-git org #12) — ref-update-observed must not trust its body.
//
// The handler dispatches ANONYMOUSLY over HTTP (hyper-svc's /rpc/<method> has
// no allowlist and no auth gate) and is internet-reachable through
// https://7rnx.net/git/rpc/. It used to hand the caller's oldSha/newSha
// straight to gipPublishRef, whose only gate is `tracker.writable` — the
// autobase-writer concept, not an authorization check. So on any daemon that
// is a writer for a repo, an anonymous caller could DELETE a ref from the
// shared, replicated per-repo autobase (newSha = zero oid) or repoint it.
//
// The fix: the caller names WHICH repo/ref to re-read; the ref state is read
// from this daemon's own bare repo. These tests fail against the old code —
// case 1 removes the ref from the view, case 2 moves it to the forged oid.

import test from 'brittle'
import { mkdtempSync, mkdirSync, writeFileSync } from '#node:fs'
import { join } from '#node:path'
import { tmpdir } from '#node:os'
import { Client } from 'hyper-svc'

import { startPearGit } from '../index.js'
import { execFileSync } from '../lib/_compat-exec.js'

const ZERO = '0000000000000000000000000000000000000000'

function git (args, opts = {}) {
  return execFileSync('git', args, { encoding: 'utf8', ...opts }).toString().trim()
}
function tmpStorage () { return mkdtempSync(join(tmpdir(), 'pgv3-refauth-')) }
function tmpSock () { return join(mkdtempSync(join(tmpdir(), 'pgv3-refauth-sock-')), 'svc.sock') }

// Poll until fn() is truthy. Returns null on timeout — so "stayed put" reads
// as a null result rather than needing a bare sleep.
async function until (fn, ms = 5000) {
  for (let waited = 0; waited < ms; waited += 100) {
    const v = await fn()
    if (v) return v
    await new Promise(r => setTimeout(r, 100))
  }
  return null
}

// Boot a daemon with one bare repo carrying two commits: c1 on refs/heads/main
// and its child c2 on refs/heads/other. Both objects live in the bare repo, so
// a forged "move main to c2" is a FAST-FORWARD — it would sail past the FF
// guard in tracker.updateRef. That matters: it proves the body is what's being
// rejected, not that some other check happened to catch it.
async function setup (t) {
  const storage = tmpStorage()
  const node = await startPearGit({ storage, uds: tmpSock(), http: { port: 0 }, swarm: false, autoImportSSH: false })
  t.teardown(async () => { try { await node.close() } catch {} })

  const reposDir = node.svc._reposDir
  mkdirSync(reposDir, { recursive: true })
  const bare = join(reposDir, 'auth-test.git')
  git(['init', '--bare', '-q', bare])

  const work = join(storage, 'work')
  git(['init', '-q', work])
  git(['-C', work, 'config', 'user.email', 't@t'])
  git(['-C', work, 'config', 'user.name', 'T'])
  git(['-C', work, 'config', 'commit.gpgsign', 'false'])
  writeFileSync(join(work, 'a.txt'), 'one\n')
  git(['-C', work, 'add', '.'])
  git(['-C', work, 'commit', '-q', '-m', 'c1'])
  const oid1 = git(['-C', work, 'rev-parse', 'HEAD'])
  git(['-C', work, 'push', '-q', bare, 'HEAD:refs/heads/main'])

  writeFileSync(join(work, 'a.txt'), 'two\n')
  git(['-C', work, 'commit', '-q', '-am', 'c2'])
  const oid2 = git(['-C', work, 'rev-parse', 'HEAD'])
  git(['-C', work, 'push', '-q', bare, 'HEAD:refs/heads/other'])

  const client = await Client.discover({ uds: node.svc.uds.path })
  t.teardown(async () => { try { await client.close() } catch {} })

  // Land the honest state first: main published at oid1.
  await client.call('ref-update-observed', { name: 'auth-test', ref: 'refs/heads/main', oldSha: ZERO, newSha: oid1 })
  const tracker = await node.svc.issueRegistry.getForRepo('auth-test')
  const seeded = await until(async () => {
    const r = await tracker.getRef('refs/heads/main')
    return r && r.hash === oid1 ? r : null
  })
  t.ok(seeded, 'baseline: main published into the per-repo autobase at the real oid')

  return { node, client, tracker, oid1, oid2, work, bare }
}

test('forged zero-oid body cannot DELETE a ref from the replicated view', async (t) => {
  const { client, tracker, oid1 } = await setup(t)

  // The attack: claim the ref was deleted. The ref is still on disk.
  const res = await client.call('ref-update-observed', {
    name: 'auth-test', ref: 'refs/heads/main', oldSha: oid1, newSha: ZERO
  })
  t.ok(res, 'handler responded')

  const vanished = await until(async () => {
    const r = await tracker.getRef('refs/heads/main')
    return (!r || !r.hash) ? true : null
  }, 2500)
  t.absent(vanished, 'ref survives — disk still has it, so the zero oid is ignored')

  const still = await tracker.getRef('refs/heads/main')
  t.is(still && still.hash, oid1, 'view still points at the real on-disk oid')
})

test('forged newSha cannot REPOINT a ref (even to a valid fast-forward oid)', async (t) => {
  const { client, tracker, oid1, oid2 } = await setup(t)

  // The attack: claim main advanced to oid2. oid2 exists in the bare repo and
  // is a descendant of oid1, so this is a legitimate-looking fast-forward —
  // but main is NOT at oid2 on disk.
  const res = await client.call('ref-update-observed', {
    name: 'auth-test', ref: 'refs/heads/main', oldSha: oid1, newSha: oid2
  })
  t.ok(res, 'handler responded')

  const moved = await until(async () => {
    const r = await tracker.getRef('refs/heads/main')
    return r && r.hash === oid2 ? r : null
  }, 2500)
  t.absent(moved, 'view did not move to the forged oid')

  const still = await tracker.getRef('refs/heads/main')
  t.is(still && still.hash, oid1, 'view still at the oid the bare repo actually holds')
})

test('disk is the authority — a real advance publishes even when the body lies', async (t) => {
  const { client, tracker, oid1, oid2, work, bare } = await setup(t)

  // Really advance main on disk to oid2...
  git(['-C', work, 'push', '-q', '-f', bare, oid2 + ':refs/heads/main'])

  // ...but send a body that still claims main is at oid1 (equal old/new, which
  // the old code would have treated as "publish oid1", a no-op).
  await client.call('ref-update-observed', {
    name: 'auth-test', ref: 'refs/heads/main', oldSha: oid1, newSha: oid1
  })

  const advanced = await until(async () => {
    const r = await tracker.getRef('refs/heads/main')
    return r && r.hash === oid2 ? r : null
  })
  t.ok(advanced, 'view followed the bare repo to oid2, not the stale body')
})

test('an invalid repo name is rejected before it can materialize a tracker', async (t) => {
  const { node, client } = await setup(t)

  // getForRepo() creates a per-repo autobase on demand, so an unvalidated name
  // let an anonymous caller allocate storage for names of its choosing.
  await t.exception(
    client.call('ref-update-observed', { name: '../../etc/passwd', ref: 'refs/heads/main', newSha: ZERO }),
    /invalid repo name/,
    'traversal-shaped name rejected'
  )

  const repos = await node.svc.issueRegistry.listRepos()
  t.absent(repos.includes('../../etc/passwd'), 'no tracker autobase was allocated for the bad name')
})

test('a well-formed name for a repo this daemon does not serve is a no-op', async (t) => {
  const { node, client } = await setup(t)

  const res = await client.call('ref-update-observed', { name: 'no-such-repo', ref: 'refs/heads/main', newSha: ZERO })
  t.is(res && res.ok, false, 'reported as unknown repo')
  t.is(res && res.reason, 'unknown repo', 'with the expected reason')

  const repos = await node.svc.issueRegistry.listRepos()
  t.absent(repos.includes('no-such-repo'), 'no tracker autobase allocated for an unserved repo')
})