OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-svc / test / anonymous-gate.test.js

126 lines · javascript ref:
// Anonymous-dispatch gate (pear-git org #12).
//
// Every registered handler used to be callable by anyone, over HTTP and over
// swarm-rpc, with no allowlist and no auth gate. `POST /rpc/<method>` builds
// `ctx = { transport:'http', identity: null }` and hands the method straight
// to dispatch, and a daemon behind a public reverse proxy therefore exposed
// its whole RPC surface to the internet. Each new svc.handle() was silently
// world-callable.
//
// The gate is opt-IN at registration: under anonymousPolicy 'deny', a handler
// must declare `{ anonymous: true }` to be reachable unauthenticated. A
// handler nobody classified is DENIED — drift produces a visible 403, never a
// silent hole.
//
// Dispatch is exercised directly; no transport is needed, and that keeps this
// file off the sibling-package import chain the other suites depend on.

import test from 'brittle'
import { Service } from '../index.js'

function svcWith (opts = {}) {
  return new Service({ name: 'gate-test', ...opts })
}

// The four ctx shapes this stack actually produces. Only the first is a real
// authenticated user; three of four are non-null, which is why a check shaped
// as `if (!ctx.identity) deny` admits swarm peers and misreads UDS.
const HTTP_SESSION = { transport: 'http', identity: { publicKey: 'abc123' } }
const HTTP_ANON = { transport: 'http', identity: null }
const SWARM_RPC = { transport: 'swarm-rpc', identity: { type: 'swarm', remotePublicKey: 'ff', trust: 'network' } }
const UDS = { transport: 'uds', identity: { trust: 'local' } }

test('default policy is unchanged — existing consumers keep working', async (t) => {
  const svc = svcWith() // no anonymousPolicy
  svc.handle('anything', async () => 'ok')

  t.is(await svc.dispatch('anything', {}, HTTP_ANON), 'ok', 'anonymous HTTP still allowed by default')
  t.is(await svc.dispatch('anything', {}, SWARM_RPC), 'ok', 'swarm-rpc still allowed by default')
  t.is(svc.listAnonymous().length, 0, 'nothing marked when nothing opted in')
})

test('deny policy blocks an unmarked handler for anonymous callers', async (t) => {
  const svc = svcWith({ anonymousPolicy: 'deny' })
  svc.handle('secret', async () => 'leaked')

  await t.exception(svc.dispatch('secret', {}, HTTP_ANON), /anonymous access denied/, 'anonymous HTTP denied')
  t.is(await svc.dispatch('secret', {}, UDS), 'leaked', 'UDS still allowed — local operator holds the socket')
  t.is(await svc.dispatch('secret', {}, HTTP_SESSION), 'leaked', 'an authenticated session is not anonymous')
})

test('swarm-rpc is treated as anonymous despite a NON-NULL identity', async (t) => {
  // The trap: swarm-rpc sets identity to { type:'swarm', trust:'network' } —
  // truthy, but not a user. A gate written as `if (!ctx.identity) deny` would
  // wave every network peer through.
  const svc = svcWith({
    anonymousPolicy: 'deny',
    isAnonymous: (ctx) => {
      if (!ctx) return true
      if (ctx.transport === 'uds') return false
      if (!ctx.identity) return true
      return ctx.identity.trust === 'network'
    }
  })
  svc.handle('secret', async () => 'leaked')

  t.ok(SWARM_RPC.identity, 'precondition: the swarm ctx identity is NOT null')
  await t.exception(svc.dispatch('secret', {}, SWARM_RPC), /anonymous access denied/, 'swarm peer denied')
})

test('an opted-in handler stays reachable anonymously', async (t) => {
  const svc = svcWith({ anonymousPolicy: 'deny' })
  svc.handle('public-read', async () => 'fine', { anonymous: true })
  svc.handle('gated', async () => 'nope')

  t.is(await svc.dispatch('public-read', {}, HTTP_ANON), 'fine', 'marked handler serves anonymous HTTP')
  t.is(await svc.dispatch('public-read', {}, SWARM_RPC), 'fine', 'and anonymous swarm')
  await t.exception(svc.dispatch('gated', {}, HTTP_ANON), /anonymous access denied/, 'its neighbour is still denied')

  t.ok(svc.allowsAnonymous('public-read'), 'introspection reports the opt-in')
  t.absent(svc.allowsAnonymous('gated'), 'and reports the denial')
  t.alike(svc.listAnonymous(), ['public-read'], 'listAnonymous enumerates the surface')
})

test('an unknown method still reports ENOMETHOD, not a denial', async (t) => {
  // Otherwise every typo becomes a 403 and the API is undebuggable. The
  // handler NAMES are already discoverable via listHandlers.
  const svc = svcWith({ anonymousPolicy: 'deny' })
  let err = null
  try { await svc.dispatch('no-such-thing', {}, HTTP_ANON) } catch (e) { err = e }
  t.ok(err, 'it threw')
  t.is(err && err.code, 'ENOMETHOD', 'ENOMETHOD, not EDENIED')
  t.ok(/unknown method/.test(err.message), 'and says so')
})

test('denials are reported to onDeny with the method and ctx', async (t) => {
  const seen = []
  const svc = svcWith({ anonymousPolicy: 'deny', onDeny: (d) => seen.push(d) })
  svc.handle('gated', async () => 'nope')

  await t.exception(svc.dispatch('gated', {}, HTTP_ANON), /anonymous access denied/)
  t.is(seen.length, 1, 'one denial recorded')
  t.is(seen[0].method, 'gated', 'method captured')
  t.is(seen[0].reason, 'anonymous', 'reason captured')
  t.is(seen[0].ctx.transport, 'http', 'ctx passed through for the audit trail')
})

test('the denial carries EDENIED/403 so transports can map it', async (t) => {
  const svc = svcWith({ anonymousPolicy: 'deny' })
  svc.handle('gated', async () => 'nope')
  let err = null
  try { await svc.dispatch('gated', {}, HTTP_ANON) } catch (e) { err = e }
  t.ok(err, 'it threw')
  t.is(err && err.code, 'EDENIED', 'code is EDENIED')
  t.is(err && err.status, 403, 'status is 403')
})

test('an isAnonymous predicate that THROWS fails closed', async (t) => {
  const svc = svcWith({
    anonymousPolicy: 'deny',
    isAnonymous: () => { throw new Error('predicate blew up') }
  })
  svc.handle('gated', async () => 'leaked')
  await t.exception(svc.dispatch('gated', {}, HTTP_SESSION), /anonymous access denied/,
    'a broken predicate denies rather than admits')
})