// SECURITY (pear-git org #12) — the /rpc/ surface is default-deny for
// anonymous callers.
//
// hyper-svc dispatched EVERY registered method to anyone, over HTTP and over
// swarm-rpc, with no allowlist and no auth gate, and this daemon is
// internet-reachable through https://7rnx.net/git/rpc/. So the entire RPC
// surface — 100+ methods — was world-callable, and each new svc.handle() was
// silently added to it. Demonstrated on prod: an anonymous
// `curl -X POST https://7rnx.net/git/rpc/list-orgs` returned data.
//
// The gate is opt-in at registration (hyper-svc 0.1.8). Everything not in
// ANONYMOUS_METHODS requires an authenticated caller or UDS.
import test from 'brittle'
import process from '#node:process'
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'
process.env.PGV3_USE_RAW_IDENTITY = '1'
const tmp = (p) => mkdtempSync(join(tmpdir(), p))
function rpc (port, method, params = {}, headers = {}) {
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),
...headers
}
}, (res) => {
const parts = []
res.on('data', (c) => parts.push(c))
res.on('end', () => {
const text = b4a.concat(parts).toString('utf8')
let body
try { body = JSON.parse(text) } catch { body = text }
resolve({ status: res.statusCode, body })
})
res.on('error', reject)
})
req.on('error', reject)
req.write(json)
req.end()
})
}
async function makeNode (t) {
const node = await startPearGit({
storage: tmp('pgv3-anon-'), uds: join(tmp('pgv3-anon-s-'), 'svc.sock'),
http: { port: 0 }, swarm: false, autoImportSSH: false
})
t.teardown(async () => { try { await node.close() } catch {} })
return { node, port: node.svc.http.server.address().port }
}
test('anonymous HTTP cannot reach an unmarked method', async (t) => {
const { port } = await makeNode(t)
// list-orgs is the exact method the prod repro used.
const res = await rpc(port, 'list-orgs')
t.is(res.status, 403, 'list-orgs refused anonymously (was 200 with data)')
t.ok(/anonymous access denied/.test(res.body.error || ''), 'refused by the gate: ' + res.body.error)
})
test('a broad sample of the surface is closed to anonymous callers', async (t) => {
const { port } = await makeNode(t)
// Reads, writes and admin alike — none of these should answer an
// unauthenticated caller over HTTP.
// NOTE: list-repos is deliberately NOT here — it is opted in for the
// swarm-peer resync path, which costs anonymous HTTP read access to the repo
// list. The /repos page already publishes that list to anonymous visitors.
const sample = [
'list-users', 'list-issue-repos', 'get-profile',
'create-org', 'create-repo', 'grant-permission', 'revoke-identity',
'device-invite', 'create-access-token', 'generate-link-token',
'repo-add-writer', 'serve-repo', 'retire-repo'
]
for (const m of sample) {
const res = await rpc(port, m)
t.is(res.status, 403, m + ' is closed to anonymous HTTP')
}
})
test('the opted-in methods still answer anonymously', async (t) => {
const { port } = await makeNode(t)
const status = await rpc(port, 'status')
t.is(status.status, 200, 'status stays a public health endpoint')
// The post-receive hook is a shell script that curls this with NO
// credentials on every push, across all served repos. If the gate caught it,
// ref propagation would break fleet-wide and silently.
const hook = await rpc(port, 'ref-update-observed', { name: 'no-such-repo' })
t.not(hook.status, 403, 'the post-receive hook path is NOT gated (got ' + hook.status + ')')
t.absent(/anonymous access denied/.test((hook.body && hook.body.error) || ''),
'and not refused by the anonymous gate')
})
test('an unknown method still 404s rather than 403ing', async (t) => {
const { port } = await makeNode(t)
const res = await rpc(port, 'definitely-not-a-real-method-xyz')
t.is(res.status, 404, 'typos stay debuggable')
t.ok(/unknown method/.test(res.body.error || ''), 'and say so')
})
test('UDS is unaffected — the local operator keeps full access', async (t) => {
const { node } = await makeNode(t)
const client = await Client.discover({ uds: node.svc.uds.path })
t.teardown(async () => { try { await client.close() } catch {} })
// Same methods the anonymous caller was refused.
const orgs = await client.call('list-orgs', {})
t.ok(orgs, 'list-orgs answers over UDS')
const repos = await client.call('list-repos', {})
t.ok(repos && repos.active !== undefined, 'list-repos answers over UDS')
})
test('the opted-in set is small and explicit', async (t) => {
const { node } = await makeNode(t)
const anon = node.svc.listAnonymous()
t.alike(anon, [
'dump-keys', 'list-repos', 'list-repos-detailed', 'ref-update-observed',
'release-index', 'search-all-repos', 'search-code-all', 'search-users',
'status', 'tree-registry-list', 'whoami'
], 'hook + public browsing + swarm-peer resync + registry read + release discovery')
t.ok(node.svc.listHandlers().length > 50,
'against a surface of ' + node.svc.listHandlers().length + ' registered methods')
})
test('tree-registry-list refuses to enumerate for anonymous callers', async (t) => {
// Opting this into the anonymous set is safe ONLY because the handler
// self-gates. The registry itself is public (a replicated Autobase whose key
// ships in every published package.json as `pgh.upgrade`), but an UNFILTERED
// anonymous call would enumerate every app published on this machine —
// including ones whose repo is private — which routes around the repo read
// gate. So anonymous callers must name a repo, and that name is then run
// through repoAccess exactly like the release-download route.
const { node } = await makeNode(t)
const anonCtx = { transport: 'http', identity: null }
const unfiltered = await node.svc.dispatch('tree-registry-list', {}, anonCtx)
t.is(unfiltered.count, 0, 'anonymous + no name returns nothing')
t.alike(unfiltered.entries, [], 'no entries leaked')
t.ok(/name required/i.test(unfiltered.unavailable || ''),
'and says why, rather than looking like an empty registry')
// Naming a repo is allowed — the gate is on enumeration, not on reading.
const named = await node.svc.dispatch('tree-registry-list', { name: 'some-repo' }, anonCtx)
t.ok(named && Array.isArray(named.entries), 'named lookup is permitted')
})
test('the swarm-peer resync path is not broken by the gate', async (t) => {
// REGRESSION GUARD. resync-from-origin (mirror recovery) opens a
// Client.connectSwarm to the ORIGIN and calls dump-keys then list-repos on
// it (lib/pear-git.js). A swarm ctx carries trust:'network', which this
// daemon's isAnonymous treats as anonymous — so without an explicit opt-in
// the origin refuses its own mirror and recovery silently breaks.
//
// I shipped exactly that bug: I confirmed `pgh peer call` uses HTTP and then
// ASSUMED nothing else used swarm-rpc. The audit log could not have caught
// it — it records /rpc/ HTTP, not swarm dispatch.
const { node } = await makeNode(t)
const swarmCtx = {
transport: 'swarm-rpc',
identity: { type: 'swarm', remotePublicKey: 'ab'.repeat(32), trust: 'network' }
}
for (const method of ['dump-keys', 'list-repos']) {
let err = null
try { await node.svc.dispatch(method, {}, swarmCtx) } catch (e) { err = e }
t.absent(err && err.code === 'EDENIED', method + ' is reachable by a swarm peer')
}
// ...while the surface at large still is not.
let denied = null
try { await node.svc.dispatch('list-orgs', {}, swarmCtx) } catch (e) { denied = e }
t.is(denied && denied.code, 'EDENIED', 'an unmarked method still refuses swarm peers')
})
test('dump-keys stays closed over HTTP despite the opt-in', async (t) => {
// Opting dump-keys in grants swarm + UDS. Its own handler blocks HTTP, and
// that block is what keeps bootstrap keys off the internet — the anonymous
// gate must not be the only thing standing between them and a curl.
const { port } = await makeNode(t)
const res = await rpc(port, 'dump-keys')
t.not(res.status, 200, 'dump-keys is not served over HTTP (got ' + res.status + ')')
t.ok(/not available over HTTP/.test((res.body && res.body.error) || ''),
'refused by its own transport gate: ' + (res.body && res.body.error))
})