OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / test / upload-requires-writer.test.js

154 lines · javascript ref:
// SECURITY (pear-git org #16) — POST /api/upload requires WRITE, not merely
// "some identity".
//
// The route checked only that ctx.identity existed, then called
// spoolAttachment() directly — around the gate rather than through it. But
// /api/rpc mints a session for ANY verified envelope, including a first-login
// TOFU user who gets read-only perms. So "authenticated" was a very low bar:
// any peer able to sign an envelope could write arbitrary content-addressed
// blobs into the overlay spool, which then replicate to peers by hash. It also
// sidestepped whatever policy the upload-attachment RPC enforces on the very
// same spoolAttachment call (handlers/attachments.js:53 → requireWriter), so
// the two upload doors could drift apart.

import test from 'brittle'
import { mkdtempSync, readdirSync, existsSync } from '#node:fs'
import { join } from '#node:path'
import { tmpdir } from '#node:os'
import http from '#node:http'
import b4a from 'b4a'
import crypto from 'hypercore-crypto'

import { startPearGit } from '../index.js'
import { signTestEnvelope } from '../lib/auth/envelope.js'

function tmpStorage () { return mkdtempSync(join(tmpdir(), 'pgv3-upauth-')) }
function tmpSockPath () { return join(mkdtempSync(join(tmpdir(), 'pgv3-upauth-sock-')), 'svc.sock') }

function postJson (url, body, headers = {}) {
  return new Promise((resolve, reject) => {
    const json = JSON.stringify(body || {})
    const u = new URL(url)
    const req = http.request({
      method: 'POST',
      hostname: u.hostname,
      port: u.port,
      path: u.pathname + u.search,
      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 parsed
        try { parsed = JSON.parse(text) } catch { parsed = text }
        resolve({ status: res.statusCode, body: parsed, setCookie: res.headers['set-cookie'] })
      })
      res.on('error', reject)
    })
    req.on('error', reject)
    req.write(json)
    req.end()
  })
}

function postBytes (url, bytes, headers = {}) {
  return new Promise((resolve, reject) => {
    const u = new URL(url)
    const req = http.request({
      method: 'POST',
      hostname: u.hostname,
      port: u.port,
      path: u.pathname + u.search,
      headers: { 'Content-Type': 'application/octet-stream', 'Content-Length': bytes.length, ...headers }
    }, (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 = text }
        resolve({ status: res.statusCode, body: parsed })
      })
      res.on('error', reject)
    })
    req.on('error', reject)
    req.write(bytes)
    req.end()
  })
}

// Sign in a FRESH keypair over /api/rpc and return its session cookie. Bare
// reports set-cookie as a string and Node as an array — normalise both
// (basic.test.js and attachment-upload.test.js do the same).
async function login (port) {
  const { an } = await import('anproto')
  const kp = await an.gen()
  const env = await signTestEnvelope({ keypair: kp, method: 'whoami', params: {} })
  const res = await postJson('http://127.0.0.1:' + port + '/api/rpc', env)
  if (res.status !== 200 || !res.body || !res.body.ok) {
    throw new Error('login failed: ' + res.status + ' ' + JSON.stringify(res.body))
  }
  const sc = res.setCookie
  return (Array.isArray(sc) ? sc[0] : sc).split(';')[0]
}

async function makeNode (t) {
  const storage = tmpStorage()
  const node = await startPearGit({
    storage, uds: tmpSockPath(), http: { port: 0 }, swarm: false, autoImportSSH: false
  })
  t.teardown(async () => { try { await node.close() } catch {} })
  return { node, storage, port: node.svc.http.server.address().port }
}

function spoolFiles (storage) {
  const dir = join(storage, 'attachments-spool')
  if (!existsSync(dir)) return []
  return readdirSync(dir).filter((f) => /^[0-9a-f]{64}$/.test(f))
}

test('a read-only (TOFU) session cannot write blobs into the overlay spool', async (t) => {
  const { storage, port } = await makeNode(t)

  // First sign-in bootstraps the owner. A SECOND fresh keypair is the TOFU
  // case the issue describes: a verified envelope, a real session, read-only.
  const ownerCookie = await login(port)
  const tofuCookie = await login(port)
  t.not(ownerCookie, tofuCookie, 'two distinct sessions')

  const before = spoolFiles(storage).length
  const payload = crypto.randomBytes(4096)
  const res = await postBytes('http://127.0.0.1:' + port + '/api/upload', payload, { Cookie: tofuCookie })

  t.ok(res.status === 401 || res.status === 403, 'read-only session refused (got ' + res.status + ')')
  t.absent(res.body && res.body.hash, 'no content hash handed back')
  t.is(spoolFiles(storage).length, before, 'nothing was written to the spool')
})

test('an anonymous caller still cannot upload', async (t) => {
  const { storage, port } = await makeNode(t)
  const before = spoolFiles(storage).length

  const res = await postBytes('http://127.0.0.1:' + port + '/api/upload', crypto.randomBytes(1024))
  t.ok(res.status === 401 || res.status === 403, 'anonymous upload refused (got ' + res.status + ')')
  t.is(spoolFiles(storage).length, before, 'nothing spooled')
})

test('the owner CAN still upload — the gate did not break the real path', async (t) => {
  const { storage, port } = await makeNode(t)
  const cookie = await login(port)

  const payload = crypto.randomBytes(4096)
  const res = await postBytes('http://127.0.0.1:' + port + '/api/upload', payload, { Cookie: cookie })

  t.is(res.status, 200, 'owner upload accepted')
  t.ok(res.body && /^[0-9a-f]{64}$/.test(res.body.hash || ''), 'returned a content hash')
  t.ok(spoolFiles(storage).includes(res.body.hash), 'bytes landed in the spool under that hash')
})