| … | … | @@ -0,0 +1,125 @@ |
| 1 | // Anonymous-dispatch gate (pear-git org #12). |
| 2 | // |
| 3 | // Every registered handler used to be callable by anyone, over HTTP and over |
| 4 | // swarm-rpc, with no allowlist and no auth gate. `POST /rpc/<method>` builds |
| 5 | // `ctx = { transport:'http', identity: null }` and hands the method straight |
| 6 | // to dispatch, and a daemon behind a public reverse proxy therefore exposed |
| 7 | // its whole RPC surface to the internet. Each new svc.handle() was silently |
| 8 | // world-callable. |
| 9 | // |
| 10 | // The gate is opt-IN at registration: under anonymousPolicy 'deny', a handler |
| 11 | // must declare `{ anonymous: true }` to be reachable unauthenticated. A |
| 12 | // handler nobody classified is DENIED — drift produces a visible 403, never a |
| 13 | // silent hole. |
| 14 | // |
| 15 | // Dispatch is exercised directly; no transport is needed, and that keeps this |
| 16 | // file off the sibling-package import chain the other suites depend on. |
| 17 | |
| 18 | import test from 'brittle' |
| 19 | import { Service } from '../index.js' |
| 20 | |
| 21 | function svcWith (opts = {}) { |
| 22 | return new Service({ name: 'gate-test', ...opts }) |
| 23 | } |
| 24 | |
| 25 | // The four ctx shapes this stack actually produces. Only the first is a real |
| 26 | // authenticated user; three of four are non-null, which is why a check shaped |
| 27 | // as `if (!ctx.identity) deny` admits swarm peers and misreads UDS. |
| 28 | const HTTP_SESSION = { transport: 'http', identity: { publicKey: 'abc123' } } |
| 29 | const HTTP_ANON = { transport: 'http', identity: null } |
| 30 | const SWARM_RPC = { transport: 'swarm-rpc', identity: { type: 'swarm', remotePublicKey: 'ff', trust: 'network' } } |
| 31 | const UDS = { transport: 'uds', identity: { trust: 'local' } } |
| 32 | |
| 33 | test('default policy is unchanged — existing consumers keep working', async (t) => { |
| 34 | const svc = svcWith() // no anonymousPolicy |
| 35 | svc.handle('anything', async () => 'ok') |
| 36 | |
| 37 | t.is(await svc.dispatch('anything', {}, HTTP_ANON), 'ok', 'anonymous HTTP still allowed by default') |
| 38 | t.is(await svc.dispatch('anything', {}, SWARM_RPC), 'ok', 'swarm-rpc still allowed by default') |
| 39 | t.is(svc.listAnonymous().length, 0, 'nothing marked when nothing opted in') |
| 40 | }) |
| 41 | |
| 42 | test('deny policy blocks an unmarked handler for anonymous callers', async (t) => { |
| 43 | const svc = svcWith({ anonymousPolicy: 'deny' }) |
| 44 | svc.handle('secret', async () => 'leaked') |
| 45 | |
| 46 | await t.exception(svc.dispatch('secret', {}, HTTP_ANON), /anonymous access denied/, 'anonymous HTTP denied') |
| 47 | t.is(await svc.dispatch('secret', {}, UDS), 'leaked', 'UDS still allowed — local operator holds the socket') |
| 48 | t.is(await svc.dispatch('secret', {}, HTTP_SESSION), 'leaked', 'an authenticated session is not anonymous') |
| 49 | }) |
| 50 | |
| 51 | test('swarm-rpc is treated as anonymous despite a NON-NULL identity', async (t) => { |
| 52 | // The trap: swarm-rpc sets identity to { type:'swarm', trust:'network' } — |
| 53 | // truthy, but not a user. A gate written as `if (!ctx.identity) deny` would |
| 54 | // wave every network peer through. |
| 55 | const svc = svcWith({ |
| 56 | anonymousPolicy: 'deny', |
| 57 | isAnonymous: (ctx) => { |
| 58 | if (!ctx) return true |
| 59 | if (ctx.transport === 'uds') return false |
| 60 | if (!ctx.identity) return true |
| 61 | return ctx.identity.trust === 'network' |
| 62 | } |
| 63 | }) |
| 64 | svc.handle('secret', async () => 'leaked') |
| 65 | |
| 66 | t.ok(SWARM_RPC.identity, 'precondition: the swarm ctx identity is NOT null') |
| 67 | await t.exception(svc.dispatch('secret', {}, SWARM_RPC), /anonymous access denied/, 'swarm peer denied') |
| 68 | }) |
| 69 | |
| 70 | test('an opted-in handler stays reachable anonymously', async (t) => { |
| 71 | const svc = svcWith({ anonymousPolicy: 'deny' }) |
| 72 | svc.handle('public-read', async () => 'fine', { anonymous: true }) |
| 73 | svc.handle('gated', async () => 'nope') |
| 74 | |
| 75 | t.is(await svc.dispatch('public-read', {}, HTTP_ANON), 'fine', 'marked handler serves anonymous HTTP') |
| 76 | t.is(await svc.dispatch('public-read', {}, SWARM_RPC), 'fine', 'and anonymous swarm') |
| 77 | await t.exception(svc.dispatch('gated', {}, HTTP_ANON), /anonymous access denied/, 'its neighbour is still denied') |
| 78 | |
| 79 | t.ok(svc.allowsAnonymous('public-read'), 'introspection reports the opt-in') |
| 80 | t.absent(svc.allowsAnonymous('gated'), 'and reports the denial') |
| 81 | t.alike(svc.listAnonymous(), ['public-read'], 'listAnonymous enumerates the surface') |
| 82 | }) |
| 83 | |
| 84 | test('an unknown method still reports ENOMETHOD, not a denial', async (t) => { |
| 85 | // Otherwise every typo becomes a 403 and the API is undebuggable. The |
| 86 | // handler NAMES are already discoverable via listHandlers. |
| 87 | const svc = svcWith({ anonymousPolicy: 'deny' }) |
| 88 | let err = null |
| 89 | try { await svc.dispatch('no-such-thing', {}, HTTP_ANON) } catch (e) { err = e } |
| 90 | t.ok(err, 'it threw') |
| 91 | t.is(err && err.code, 'ENOMETHOD', 'ENOMETHOD, not EDENIED') |
| 92 | t.ok(/unknown method/.test(err.message), 'and says so') |
| 93 | }) |
| 94 | |
| 95 | test('denials are reported to onDeny with the method and ctx', async (t) => { |
| 96 | const seen = [] |
| 97 | const svc = svcWith({ anonymousPolicy: 'deny', onDeny: (d) => seen.push(d) }) |
| 98 | svc.handle('gated', async () => 'nope') |
| 99 | |
| 100 | await t.exception(svc.dispatch('gated', {}, HTTP_ANON), /anonymous access denied/) |
| 101 | t.is(seen.length, 1, 'one denial recorded') |
| 102 | t.is(seen[0].method, 'gated', 'method captured') |
| 103 | t.is(seen[0].reason, 'anonymous', 'reason captured') |
| 104 | t.is(seen[0].ctx.transport, 'http', 'ctx passed through for the audit trail') |
| 105 | }) |
| 106 | |
| 107 | test('the denial carries EDENIED/403 so transports can map it', async (t) => { |
| 108 | const svc = svcWith({ anonymousPolicy: 'deny' }) |
| 109 | svc.handle('gated', async () => 'nope') |
| 110 | let err = null |
| 111 | try { await svc.dispatch('gated', {}, HTTP_ANON) } catch (e) { err = e } |
| 112 | t.ok(err, 'it threw') |
| 113 | t.is(err && err.code, 'EDENIED', 'code is EDENIED') |
| 114 | t.is(err && err.status, 403, 'status is 403') |
| 115 | }) |
| 116 | |
| 117 | test('an isAnonymous predicate that THROWS fails closed', async (t) => { |
| 118 | const svc = svcWith({ |
| 119 | anonymousPolicy: 'deny', |
| 120 | isAnonymous: () => { throw new Error('predicate blew up') } |
| 121 | }) |
| 122 | svc.handle('gated', async () => 'leaked') |
| 123 | await t.exception(svc.dispatch('gated', {}, HTTP_SESSION), /anonymous access denied/, |
| 124 | 'a broken predicate denies rather than admits') |
| 125 | }) |