// SECURITY (pear-git org #12) — the debug/repair handlers are local-only.
//
// hyper-svc dispatches /rpc/<method> with no allowlist and no auth gate, and
// the prod daemon is reachable at https://7rnx.net/git/rpc/. Any handler that
// does not gate itself is therefore world-callable. This pins the gate on the
// cluster that exposes daemon internals or mutates autobase state:
//
// identity-repair calls base.repair() on the IdentityRegistry autobase
// probe-core corestore.get() on a caller-supplied key, waits ≤30s
// dump-mirror-writers writer keys + autobase internals
// debug-swarm-peers connected peer detail
// identity-introspect identity autobase internals
// identity-ff-debug identity autobase internals
//
// Sibling handlers (tracker-repair, heal-repos, repo-resync,
// resync-from-origin) already assert transport === 'uds'; these did not.
//
// UDS must keep working — `pgh app status --verbose` gathers status,
// debug-swarm-peers and dump-mirror-writers over Client.connectUds.
import test from 'brittle'
import { mkdtempSync } from '#node:fs'
import { join } from '#node:path'
import { tmpdir } from '#node:os'
import http from '#node:http'
import b4a from 'b4a'
import { Client } from 'hyper-svc'
import { startPearGit } from '../index.js'
const GATED = [
'identity-repair',
'probe-core',
'dump-mirror-writers',
'debug-swarm-peers',
'identity-introspect',
'identity-ff-debug'
]
function tmpStorage () { return mkdtempSync(join(tmpdir(), 'pgv3-udsonly-')) }
function tmpSock () { return join(mkdtempSync(join(tmpdir(), 'pgv3-udsonly-sock-')), 'svc.sock') }
// Bare has no global fetch — same http.request shape the rest of the suite
// uses (see test/attachment-upload.test.js).
function rpcOverHttp (port, method, params = {}) {
return new Promise((resolve, reject) => {
const json = JSON.stringify(params)
const req = http.request({
method: 'POST',
hostname: '127.0.0.1',
port,
path: '/rpc/' + method,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'Content-Length': b4a.byteLength(json)
}
}, (res) => {
const parts = []
res.on('data', (c) => parts.push(c))
res.on('end', () => {
const text = b4a.concat(parts).toString('utf8')
let parsed
try { parsed = JSON.parse(text) } catch { parsed = { raw: text } }
resolve({ status: res.statusCode, body: parsed })
})
res.on('error', reject)
})
req.on('error', reject)
req.write(json)
req.end()
})
}
test('debug/repair handlers reject anonymous HTTP callers', async (t) => {
const node = await startPearGit({
storage: tmpStorage(), uds: tmpSock(), http: { port: 0 }, swarm: false, autoImportSSH: false
})
t.teardown(async () => { try { await node.close() } catch {} })
const port = node.svc.http.server.address().port
for (const method of GATED) {
// probe-core needs a well-formed key so that a rejection can only come
// from the transport gate, not from its own argument validation.
const params = method === 'probe-core' ? { key: 'a'.repeat(64) } : {}
const { body } = await rpcOverHttp(port, method, params)
t.ok(body.error, method + ' over HTTP is refused')
// TWO independent layers refuse this now, and which one speaks first is an
// implementation detail — what matters is that it is refused.
// 1. the anonymous dispatch gate (org #12, hyper-svc 0.1.8) — fires
// first, because these methods are not in ANONYMOUS_METHODS
// 2. the handler's own assertLocalOnly (org #12, 0.1.188) — still the
// backstop for an AUTHENTICATED caller who is not on the UDS, which
// the gate deliberately lets through
// Asserting only /UDS-only/ pinned layer 2's message and broke the moment
// layer 1 was added, despite the security property being strictly stronger.
t.ok(/UDS-only|anonymous access denied/.test(body.error || ''),
method + ' refused by a transport/auth gate: ' + body.error)
}
})
test('the same handlers still answer over UDS', async (t) => {
const node = await startPearGit({
storage: tmpStorage(), uds: tmpSock(), http: { port: 0 }, swarm: false, autoImportSSH: false
})
t.teardown(async () => { try { await node.close() } catch {} })
const client = await Client.discover({ uds: node.svc.uds.path })
t.teardown(async () => { try { await client.close() } catch {} })
// The three `pgh app status --verbose` gathers. A handler may still fail for
// its own reasons; what must NOT happen is rejection by the transport gate.
for (const method of ['status', 'debug-swarm-peers', 'dump-mirror-writers']) {
let err = null
try { await client.call(method, {}) } catch (e) { err = e }
t.absent(err && /UDS-only/.test(err.message || ''), method + ' is not gated against UDS')
}
})
test('a public read stays anonymous — the gate did not over-reach', async (t) => {
const node = await startPearGit({
storage: tmpStorage(), uds: tmpSock(), http: { port: 0 }, swarm: false, autoImportSSH: false
})
t.teardown(async () => { try { await node.close() } catch {} })
const port = node.svc.http.server.address().port
// The gate must not swallow the methods that are deliberately public.
// `status` is the remote health endpoint, explicitly left open when the
// debug handlers were gated and explicitly opted in to anonymous dispatch.
const { body } = await rpcOverHttp(port, 'status', {})
t.absent(body.error, 'status still answers anonymously over HTTP')
t.ok(body.result, 'and returns a result')
// list-orgs stands in for the closed surface. (list-repos is NOT closed: it
// is opted in for the swarm-peer resync path — resync-from-origin calls it
// on the origin over swarm, where ctx is trust:'network' and so anonymous.)
const denied = await rpcOverHttp(port, 'list-orgs', {})
t.ok(/anonymous access denied/.test((denied.body && denied.body.error) || ''),
'an unmarked method is closed to anonymous callers')
})