OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / test / anonymous-rpc-gate.test.js

204 lines · javascript ref:
// 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))
})