OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge / test / admin-auth.test.js

293 lines · javascript ref:
// The gate on the operator surface.
//
// ⛔ WHY THIS EXISTS AT ALL. The surface has to be reachable from the network, and browsers.js is
// explicit that plain first-claim TOFU is safe only while the surface binds loopback — "exposing it
// before claiming it hands it to whoever is on the network." So the first claim is bound to a bootstrap
// token printed in the service log, and everything else is bound to a live session.

import test from 'brittle'
import Corestore from 'corestore'
import fs from 'bare-fs'
import os from 'bare-os'
import { join } from 'bare-path'
import { an } from 'anproto'
import { Browsers } from 'favela-peer/lib/browsers.js'
import { MemoryRegistrations } from '../lib/registrations.js'
import { serveAdmin } from '../lib/admin-surface.js'
import { Sessions, COOKIE } from '../lib/admin-auth.js'

const ROOT = new URL('..', import.meta.url).pathname
const TOKEN = 'b7f4c1a9e2d05638b7f4c1a9e2d05638'
const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'ap-admin-'))

async function ctx ({ bootstrap = TOKEN } = {}) {
  const browsers = new Browsers(new Corestore(tmp()), { bootstrap })
  await browsers.ready()
  const registrations = new MemoryRegistrations({ policy: 'open' })
  await registrations.claim({ username: 'fleeky', root: 'a'.repeat(64) })
  return {
    browsers,
    registrations,
    sessions: new Sessions(),
    inbound: null,
    root: ROOT,
    config: { host: 'ap.7rnx.net', registration: 'open', inboundCap: 500 }
  }
}

async function browser () {
  const keypair = await an.gen()
  return {
    publicKey: an.publicKey(keypair),
    async proof (c) {
      const { challenge } = c.browsers.challenge()
      return { publicKey: an.publicKey(keypair), challenge, signature: await an.sign(await an.hash(challenge), keypair) }
    }
  }
}

const req = (method, path, extra = {}) => ({ method, path, headers: {}, ...extra })

test('★★★ every page redirects to sign-in without a session', async (t) => {
  const c = await ctx()
  for (const path of ['/admin', '/admin/', '/']) {
    const res = await serveAdmin(req('GET', path), c)
    t.is(res.status, 303, path + ' is gated')
    t.ok(res.headers.location.startsWith('/admin/login'), path + ' goes to sign-in')
    // ⚠ And carries where they were going. Sessions die with the process, so a deploy bounces an
    // operator out of the page they were reading; losing their place on top of that is gratuitous.
    t.ok(res.headers.location.includes('next=' + encodeURIComponent(path)), 'with the destination kept')
  }
})

test('★★★ a proved key alone cannot claim the bridge — the token is required', async (t) => {
  const c = await ctx()
  const who = await browser()
  const res = await serveAdmin(req('POST', '/admin/session', { form: await who.proof(c) }), c)
  t.is(res.status, 403)
  t.ok(/bootstrap token/.test(res.body), res.body)
  t.absent(res.headers['set-cookie'], 'and no session was handed out')
})

test('★★★ the token claims it, and the cookie is HttpOnly + SameSite=Strict', async (t) => {
  const c = await ctx()
  const who = await browser()
  const res = await serveAdmin(req('POST', '/admin/session', { form: { ...(await who.proof(c)), bootstrap: TOKEN } }), c)
  t.is(res.status, 200)
  const cookie = res.headers['set-cookie']
  t.ok(cookie && cookie.startsWith(COOKIE + '='))
  t.ok(/HttpOnly/.test(cookie), 'a page cannot read it')
  t.ok(/SameSite=Strict/.test(cookie), 'and it does not ride cross-site requests')
})

test('★★★ with the session the surface opens, and shows the operator their users', async (t) => {
  const c = await ctx()
  const who = await browser()
  const login = await serveAdmin(req('POST', '/admin/session', { form: { ...(await who.proof(c)), bootstrap: TOKEN } }), c)
  const id = login.headers['set-cookie'].split(';')[0].split('=')[1]

  const res = await serveAdmin(req('GET', '/admin', { headers: { cookie: COOKIE + '=' + id } }), c)
  t.is(res.status, 200)
  t.ok(/fleeky@ap\.7rnx\.net/.test(res.body), 'the sanity check the surface exists for')
})

test('★★★ a revoked browser loses the surface on its NEXT request', async (t) => {
  // ⛔ The cookie is not the authority. Holding a session must never outlive the grant behind it, so
  // the registry is re-asked per request rather than trusted at login.
  const c = await ctx()
  const who = await browser()
  const login = await serveAdmin(req('POST', '/admin/session', { form: { ...(await who.proof(c)), bootstrap: TOKEN } }), c)
  const id = login.headers['set-cookie'].split(';')[0].split('=')[1]
  const cookie = { cookie: COOKIE + '=' + id }

  t.is((await serveAdmin(req('GET', '/admin', { headers: cookie }), c)).status, 200, 'in')
  await c.browsers.revoke(who.publicKey)
  t.is((await serveAdmin(req('GET', '/admin', { headers: cookie }), c)).status, 303, 'and out again immediately')
})

test('★★ the browser identity client is served, and nothing else is', async (t) => {
  const c = await ctx()
  const ok = await serveAdmin(req('GET', '/static/anproto-client.js'), c)
  t.is(ok.status, 200)
  t.ok(/ANProtoClient/.test(String(ok.body)))

  // ⛔ A fixed list, not a directory join — a path off the wire joined to a root is a traversal bug.
  for (const evil of ['/static/../lib/admin.js', '/static/../../../etc/passwd', '/static/index.js']) {
    t.is((await serveAdmin(req('GET', evil), c)).status, 404, evil + ' is refused')
  }
})

test('★★ sign-in is reachable without a session, and says what it needs', async (t) => {
  const c = await ctx()
  const res = await serveAdmin(req('GET', '/admin/login'), c)
  t.is(res.status, 200)
  t.ok(/bootstrap token/i.test(res.body), 'an unclaimed bridge asks for the token')
  t.ok(/anproto-client\.js/.test(res.body), 'and loads the same identity client the other apps use')
})

test('★★★ the operator page lists operators, and you cannot revoke yourself', async (t) => {
  const c = await ctx()
  const who = await browser()
  const login = await serveAdmin(req('POST', '/admin/session', { form: { ...(await who.proof(c)), bootstrap: TOKEN } }), c)
  const cookie = { cookie: COOKIE + '=' + login.headers['set-cookie'].split(';')[0].split('=')[1] }

  const page = await serveAdmin(req('GET', '/admin/profile', { headers: cookie }), c)
  t.is(page.status, 200)
  t.ok(/this browser/.test(page.body), 'it says which key you are holding')

  // ⛔ Revoking the browser you are using locks the bridge to whatever other operators exist — and
  // with none, to nobody, because the bootstrap token gates only the FIRST claim and this instance is
  // already claimed. There would be no way back in.
  await serveAdmin(req('POST', '/admin/revoke-browser', { headers: cookie, form: { publicKey: who.publicKey } }), c)
  const after = await serveAdmin(req('GET', '/admin', { headers: cookie }), c)
  t.is(after.status, 200, 'still signed in — the self-revoke was refused')
})

test('★★★ an invite is how the second operator gets in, not a second bootstrap token', async (t) => {
  const c = await ctx()
  const first = await browser()
  const login = await serveAdmin(req('POST', '/admin/session', { form: { ...(await first.proof(c)), bootstrap: TOKEN } }), c)
  const cookie = { cookie: COOKIE + '=' + login.headers['set-cookie'].split(';')[0].split('=')[1] }

  const issued = await serveAdmin(req('POST', '/admin/invite-browser', { headers: cookie }), c)
  t.is(issued.status, 200)
  const token = (issued.body.match(/<code>([0-9a-f]{16,})<\/code>/) || [])[1]
  t.ok(token, 'an invite token is shown once')

  // ⛔ THE BOOTSTRAP TOKEN IS SPENT. It gates the first claim only; re-arming it would reopen the
  // instance to whoever reached it first, which is the hole it was introduced to close.
  const second = await browser()
  const refused = await serveAdmin(req('POST', '/admin/session', { form: { ...(await second.proof(c)), bootstrap: TOKEN } }), c)
  t.is(refused.status, 403, 'the bootstrap token no longer admits anybody')

  const third = await browser()
  const ok = await serveAdmin(req('POST', '/admin/session', { form: { ...(await third.proof(c)), invite: token } }), c)
  t.is(ok.status, 200, 'the invite does')
  t.ok(ok.headers['set-cookie'], 'and hands out a session')
})

test('★★★ recovery is NOT on the web surface — it is on the control socket', async (t) => {
  // ⛔ An earlier version gated recovery on the request's remote address. Right intent, weaker gate:
  // loopback is reachable by ANY process or user on the box, a unix socket only by one that can open
  // its path. Keeping both would have left the weaker door open beside the stronger one.
  const c = await ctx()
  for (const addr of ['127.0.0.1', '::1', '192.168.1.50']) {
    const res = await serveAdmin(req('POST', '/admin/recover', { remoteAddress: addr }), c)
    t.is(res.status, 303, 'no recovery route on the web surface from ' + addr)
  }
})

test('★★★ the control socket recovers a bridge whose operators are all lost', async (t) => {
  // The lockout: the bootstrap token gates the FIRST claim only, and browsers.js never reopens the
  // instance — not even when the claimant is revoked. Without this the recovery is deleting the
  // identity store, which throws away every operator grant in order to get one back.
  const c = await ctx()
  const first = await browser()
  await serveAdmin(req('POST', '/admin/session', { form: { ...(await first.proof(c)), bootstrap: TOKEN } }), c)

  const { adminControlHandlers } = await import('../lib/admin-control.js')
  const h = adminControlHandlers({ browsers: c.browsers })

  const status = await h['admin-status']()
  t.absent(status.unclaimed, 'the bridge is claimed')
  t.is(status.operators.length, 1, 'and has exactly one operator')

  const { token } = await h['admin-invite']()
  t.ok(token, 'the operator on the machine can mint an invite with no session at all')

  const fresh = await browser()
  const back = await serveAdmin(req('POST', '/admin/session', { form: { ...(await fresh.proof(c)), invite: token } }), c)
  t.is(back.status, 200, 'and a new browser is back in')
})

test('★★★ recovery issues an invite — it does NOT re-arm the bootstrap token', async (t) => {
  const c = await ctx()
  const first = await browser()
  await serveAdmin(req('POST', '/admin/session', { form: { ...(await first.proof(c)), bootstrap: TOKEN } }), c)

  const { adminControlHandlers } = await import('../lib/admin-control.js')
  await adminControlHandlers({ browsers: c.browsers })['admin-invite']()

  // ⛔ Re-arming would reopen the instance to whoever reached the port first, on a surface bound to
  // 0.0.0.0 — the hole the bootstrap token exists to close. Recovery must not punch it.
  const stranger = await browser()
  const refused = await serveAdmin(req('POST', '/admin/session', { form: { ...(await stranger.proof(c)), bootstrap: TOKEN } }), c)
  t.is(refused.status, 403, 'the spent bootstrap token still admits nobody')
})

test('★★★ the timeline pages through what arrived, newest first', async (t) => {
  const { MemoryInboundStore } = await import('../lib/inbound-store.js')
  const c = await ctx()
  // 120 items so there is a third page — enough that "load 50 more" is not a no-op.
  c.inbound = new MemoryInboundStore(
    Array.from({ length: 120 }, (_, i) => ({ id: 'i' + i, type: 'Create', actor: 'https://m.example/u/' + i, content: 'post ' + i, receivedAt: 1788800000000 + i })),
    500
  )
  const first = await browser()
  const login = await serveAdmin(req('POST', '/admin/session', { form: { ...(await first.proof(c)), bootstrap: TOKEN } }), c)
  const cookie = { cookie: COOKIE + '=' + login.headers['set-cookie'].split(';')[0].split('=')[1] }

  const p1 = await serveAdmin(req('GET', '/admin/timeline', { headers: cookie, query: {} }), c)
  t.is(p1.status, 200)
  t.ok(/1–50<\/strong> of <strong>120/.test(p1.body), 'it says where you are in what it holds')
  t.ok(/post 0</.test(p1.body), 'newest first')
  t.absent(/post 50</.test(p1.body), 'and page one stops at 50')
  t.ok(/offset=50"?>load 50 more/.test(p1.body), 'with a link to the next 50')

  const p2 = await serveAdmin(req('GET', '/admin/timeline', { headers: cookie, query: { offset: '50' } }), c)
  t.ok(/post 50</.test(p2.body), 'the second page continues where the first stopped')
  t.ok(/offset=0"?>← previous/.test(p2.body), 'and can go back')

  // ⛔ The last page must NOT offer "load 50 more" — a control that returns an empty page is the page
  // lying about the data behind it.
  const p3 = await serveAdmin(req('GET', '/admin/timeline', { headers: cookie, query: { offset: '100' } }), c)
  t.ok(/post 119</.test(p3.body), 'the tail is reachable')
  t.absent(/load \d+ more/.test(p3.body), 'no next link when there is no next page')
  t.ok(/That is everything/.test(p3.body), 'it says so instead')
})

test('★★ the timeline is gated like every other page', async (t) => {
  const c = await ctx()
  const res = await serveAdmin(req('GET', '/admin/timeline', { query: {} }), c)
  t.is(res.status, 303, 'no session, no timeline')
})

test('★★ inbound content is escaped — it was written by a stranger', async (t) => {
  const { MemoryInboundStore } = await import('../lib/inbound-store.js')
  const c = await ctx()
  c.inbound = new MemoryInboundStore([{ id: 'x', type: 'Create', actor: 'https://m.example/u/1', content: '<img src=x onerror=alert(1)>hello', receivedAt: 1 }], 500)
  const first = await browser()
  const login = await serveAdmin(req('POST', '/admin/session', { form: { ...(await first.proof(c)), bootstrap: TOKEN } }), c)
  const cookie = { cookie: COOKIE + '=' + login.headers['set-cookie'].split(';')[0].split('=')[1] }

  const res = await serveAdmin(req('GET', '/admin/timeline', { headers: cookie, query: {} }), c)
  t.absent(/<img src=x/.test(res.body), 'no markup from a remote server reaches the operator page intact')
  t.ok(/hello/.test(res.body), 'but the text is still readable')
})

test('★★★ the sign-in page re-authenticates silently when it already has a key', async (t) => {
  // ⛔ THE HALF THAT MAKES AN IN-MEMORY SESSION STORE USABLE. Sessions die with the process, so every
  // deploy signs everybody out. pgv3 carries the same design and says why it is fine: "the browser
  // still has its keypair in localStorage and the auth flow is silent." Without the silent half the
  // page is technically correct and reads as "I already logged in, why am I here again".
  const c = await ctx()
  const page = await serveAdmin(req('GET', '/admin/login'), c)
  t.ok(/signIn\(\{ silent: true \}\)/.test(page.body), 'it attempts a silent sign-in')
  t.ok(/hadKey/.test(page.body), 'only when this browser already had a key — a fresh one has nothing to prove')
  t.ok(/silent \? '' :/.test(page.body),
    'and never replays the bootstrap token or an invite, which are single-use')
})

test('★★★ the destination survives sign-in, but an open redirect does not', async (t) => {
  const c = await ctx()
  const ok = await serveAdmin(req('GET', '/admin/login', { query: { next: '/admin/timeline?offset=50' } }), c)
  t.ok(/data-next="\/admin\/timeline\?offset=50"/.test(ok.body), 'a local path is carried through')

  // ⛔ `next` comes off the wire. Echoing an absolute URL into a redirect turns a link to this bridge
  // into a link somewhere else.
  for (const evil of ['https://evil.example/x', '//evil.example/x']) {
    const res = await serveAdmin(req('GET', '/admin/login', { query: { next: evil } }), c)
    t.ok(/data-next="\/admin"/.test(res.body), evil + ' is refused')
  }
})