// 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')
})