// 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')
}
})