OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / test / debug-handlers-uds-only.test.js

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