OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web commits / c231358

auth: a session is not a licence to write as someone else — gate every mutating route
THE HOLE. `Writer` speaks to ONE hpp control socket (the primary DID's), so writer.post() authors as THAT DID regardless of who is signed in. `auth.login()` meanwhile admits ANY did that proves a delegation from its own #atproto key, registering unknown DIDs on sight. Composed: any atproto account could sign in here and post as wj62. 0.3.54 added a sign-in check to /api/post after an anonymous curl did exactly that; being signed in was never the missing property — being the OWNER of the repo was. - lib/check.js: requireRepoOwner(ctx, { repoDid }) — 403 unless ctx.identity.did === repoDid, exact match (a DID is opaque and case-sensitive). Unknown repoDid DENIES: not knowing whose history you are about to append to is the worst moment to guess. Revocation is checked first, fail-CLOSED. - bin/favela-web.js: ONE gate before route dispatch, default-DENY, replacing 22 routes' worth of hand-rolled (and once forgotten) `if (!viewer)`. pgv3 puts its read gate at one point above ~14 routes for this reason: per-route checks are per-route chances to forget, and a route added later defaults to open. Here a new POST route is treated as a repo write until deliberately classified. OPEN — login/pair/logout/challenge: they establish the session, gating them is circular. IDENTITY — devices/* (the caller's own sub-keys) + track/bookmark/mute (node-local state only). default — authors into the repo: post/edit/delete/like/repost/follow/profile/list/list-member/ block/upload/banner/avatar. Requires repo ownership. repoDid comes from writer.state().did, cached, never assumed; unresolved stays null so an instance with no write transport (the stortron read-only lens) refuses writes rather than guessing a target. Refusals are logged with path + viewer + reason — a silent 403 is indistinguishable from a bug. Behaviour: signing in as wj62 on tabertron works exactly as before (viewer.did === repoDid). Any other DID now gets 403 instead of silently authoring as wj62. NOT included: co-owner writes to a community DID. That needs per-DID socket routing (hpp already exposes a socket per DID and returns its path; nothing to change there). Until then posting AS loser-club from the web is refused explicitly rather than misattributed to wj62. Tests: 40 assertions in check.test.mjs (6 new). Boot-verified on a spare port — anonymous post/like 401 from the gate, bookmark 401, /api/auth/login still reaches its handler ("Invalid atproto proof", not the gate's error), GET / unaffected. Full node chain green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
c231358e7973f0334d375fc12b1d28c0fb4dbe0f
by plankatron <fleeky@gmail.com> · 2026-08-16T20:45:51.000Z

Changed files (3) +108 -4

bin/favela-web.js +59 -2
……@@ -52,7 +52,7 @@ import crypto from 'hypercore-crypto'
5252import bareWs from 'bare-ws'
5353import { timelinePage, profilePage, tagPage, renderAtom, esc } from '../lib/render.js'
5454import { FavelaAuth, handoffDecision } from '../lib/auth.js'
55import { extractToken, extractIdentity } from '../lib/check.js'
55import { extractToken, extractIdentity, requireIdentity, requireRepoOwner } from '../lib/check.js'
5656import { joinPairInvite, createPairInvite } from '../lib/pairing.js'
5757
5858// Live device-invite tunnels, did -> { inviteHex, state, done, src }. In memory because a blind-pairing
……@@ -971,9 +971,35 @@ const COOKIE = 'favela_session'
971971// from the query string, never from here.
972972const parseCookie = (req) => extractToken(req)
973973const viewerOf = (req) => extractIdentity(req, auth.sessions)
974
975// WHICH repo does this node write to? Asked of the hpp, never assumed — and cached, because the write
976// gate consults it on every mutating request and it is a property of the node, not of the request.
977// Unresolved stays null so the gate DENIES: an instance with no write transport (the stortron lens)
978// must refuse writes, not guess a target.
979let _repoDid = null
980const repoDidOnce = async () => {
981 if (_repoDid) return _repoDid
982 try { _repoDid = ((await writer.state()) || {}).did || null } catch { _repoDid = null }
983 return _repoDid
984}
974985const SESSION_COOKIE = (token) => `${COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${30 * 24 * 3600}`
975986const CLEAR_COOKIE = `${COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`
976987
988// Write-gate tiers (see the gate itself, at the top of the request handler). Listed here so the
989// classification of a route is one grep away, not buried mid-file.
990//
991// OPEN: these ESTABLISH a session — gating them on having one would be circular.
992const OPEN_POST = new Set(['/api/auth/login', '/api/auth/pair', '/api/auth/logout', '/api/auth/challenge'])
993// IDENTITY: self-scoped or node-local. /api/devices/* act on the caller's OWN delegated sub-keys;
994// track/bookmark/mute mutate this node's local index and stores only — nothing is signed into a repo,
995// so repo ownership is not the relevant question, but anonymity still is.
996const IDENTITY_POST = new Set([
997 '/api/devices/pair', '/api/devices/invite', '/api/devices/revoke',
998 '/api/track', '/api/bookmark', '/api/mute'
999])
1000// Everything else POSTed authors a record (or a blob) into the repo and falls through to the
1001// repo-owner check. Deliberately NOT enumerated: an unlisted route must fail closed.
1002
9771003const html = (res, status, body, extra) => { res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', ...(extra || {}) }); res.end(body) }
9781004const json = (res, status, body, extra) => { res.writeHead(status, { 'content-type': 'application/json', ...(extra || {}) }); res.end(body) }
9791005const xml = (res, status, body, type) => { res.writeHead(status, { 'content-type': type }); res.end(body) }
……@@ -982,7 +1008,38 @@ const server = http.createServer(async (req, res) => {
9821008 try {
9831009 const url = new URL(req.url, 'http://localhost')
9841010 const path = url.pathname
985 const viewer = viewerOf(req) // { did } from the session cookie, or null (anonymous)
1011 const viewer = viewerOf(req) // { did, subKey } from the session cookie/Bearer, or null (anonymous)
1012
1013 // ── WRITE GATE — one choke point, default-deny ──────────────────────────────────────────────
1014 //
1015 // Every mutating route used to carry (or forget) its own `if (!viewer)`. /api/post forgot, and on
1016 // 2026-08-11 an anonymous request created a post in wj62; the images branch of that same handler
1017 // had the check, which is why inspection missed it. pgv3 puts its read gate at ONE point above
1018 // ~14 routes for exactly this reason: per-route checks are per-route chances to forget, and a
1019 // route added later defaults to open. Here the default is DENY — a new POST route is gated as a
1020 // repo write until someone deliberately lists it otherwise.
1021 //
1022 // Tiers, most open first:
1023 // OPEN — establish the session; requiring one would be circular.
1024 // IDENTITY — self-scoped: acts on the caller's own devices, or on state that never leaves
1025 // this node. A live, non-revoked session is the whole requirement.
1026 // (default) — AUTHORS A RECORD INTO THE REPO. Requires that the caller OWN that repo.
1027 //
1028 // ⛔ The repo owner check is the load-bearing one. `Writer` speaks to a single hpp control socket,
1029 // so a write authors as THAT DID whoever is signed in — and sign-in admits any DID that can prove
1030 // a delegation from its own #atproto key. Without this, any atproto account posts as wj62.
1031 if (req.method === 'POST' && !OPEN_POST.has(path)) {
1032 const ctx = { identity: viewer }
1033 const isDeviceRevoked = (k) => writer.isDeviceRevoked(k)
1034 try {
1035 if (IDENTITY_POST.has(path)) await requireIdentity(ctx, { isDeviceRevoked, failOpen: false })
1036 else await requireRepoOwner(ctx, { repoDid: await repoDidOnce(), isDeviceRevoked })
1037 } catch (e) {
1038 const status = e.status || 403
1039 console.log(JSON.stringify({ msg: 'write refused', path, viewer: viewer ? viewer.did : null, status, reason: e.message }))
1040 return json(res, status, JSON.stringify({ error: e.message }))
1041 }
1042 }
9861043
9871044 // ── auth (the browser client drives these; favela verifies keylessly) ──
9881045 if (path === '/api/auth/challenge') {
lib/check.js +27 -1
……@@ -135,6 +135,32 @@ export async function requireMember (ctx, opts = {}) {
135135 if (!member) throw new AuthError('membership required', 403)
136136}
137137
138/**
139 * Require that the caller OWNS the repo being written to.
140 *
141 * ⛔ THE HOLE THIS CLOSES. `Writer` connects to ONE hpp control socket — the primary DID's — so
142 * `writer.post()` authors as THAT DID regardless of who is signed in. Meanwhile `auth.login()` admits
143 * any DID that proves a delegation from its own #atproto key, and unknown DIDs are registered on
144 * sight. Composed, those two facts mean any atproto account could sign in here and post as wj62.
145 * A session proves WHO YOU ARE; it must never be mistaken for a right to write as SOMEONE ELSE.
146 *
147 * `repoDid` is the DID the writer is actually bound to — ask the hpp (`writer.state().did`), never
148 * assume. An unknown target DENIES: not knowing whose history you are about to append to is the
149 * worst possible moment to guess.
150 *
151 * This does not YET permit a co-owner to write to a community DID — that needs per-DID socket routing
152 * (hpp already exposes a socket per DID). It is the safety property; routing is additive on top.
153 */
154export async function requireRepoOwner (ctx, opts = {}) {
155 await requireIdentity(ctx, { ...opts, failOpen: opts.failOpen === undefined ? false : opts.failOpen })
156 const repoDid = opts.repoDid
157 if (!repoDid) throw new AuthError('cannot determine which repo this node writes to', 403)
158 // Exact match. A DID is an opaque, case-sensitive identifier; near-misses are not owners.
159 if (ctx.identity.did !== repoDid) {
160 throw new AuthError('you may only write to your own repo', 403)
161 }
162}
163
138164/**
139165 * Non-throwing probe: may this caller write? For SSR — decide whether to RENDER the composer at all,
140166 * rather than rendering it and letting the POST fail. Mirrors pgv3's isAdmin(). Never throws.
……@@ -148,4 +174,4 @@ export async function mayWrite (ctx, opts = {}) {
148174 }
149175}
150176
151export default { extractToken, extractIdentity, requireIdentity, requireMember, mayWrite, isRevoked, AuthError, COOKIE_NAME }
177export default { extractToken, extractIdentity, requireIdentity, requireMember, requireRepoOwner, mayWrite, isRevoked, AuthError, COOKIE_NAME }
test/check.test.mjs +22 -1
……@@ -9,7 +9,7 @@
99import { SessionStore } from '../lib/sessions.js'
1010import {
1111 AuthError, COOKIE_NAME, extractToken, extractIdentity,
12 isRevoked, requireIdentity, requireMember, mayWrite
12 isRevoked, requireIdentity, requireMember, requireRepoOwner, mayWrite
1313} from '../lib/check.js'
1414
1515let pass = 0
……@@ -89,6 +89,27 @@ const ID = { did: DID, subKey: 'beef' }
8989 403, 'a membership lookup that ERRORS denies rather than admits')
9090}
9191
92// ── requireRepoOwner — write ATTRIBUTION ─────────────────────────────
93// The hole this closes: Writer talks to ONE hpp socket (the primary DID's), so writer.post() authors
94// as that DID no matter who is signed in — while auth.login() accepts ANY did that proves a
95// delegation from its own #atproto key. Composed: any atproto account could sign in and post as wj62.
96{
97 const OTHER = 'did:plc:someoneelse999999999999'
98 await rejects(() => requireRepoOwner({ identity: ID }, { repoDid: OTHER }), 403,
99 'a signed-in stranger may NOT write to the repo this node signs for')
100 await rejects(() => requireRepoOwner({ identity: null }, { repoDid: DID }), 401,
101 'anonymous is refused first')
102 await requireRepoOwner({ identity: ID }, { repoDid: DID })
103 ok(true, 'the repo owner may write to their own repo')
104 // If we cannot establish WHICH repo the writer is bound to, we must not guess — an unknown target
105 // is the case where a wrong answer writes into someone else's history.
106 await rejects(() => requireRepoOwner({ identity: ID }, {}), 403, 'unknown repoDid → deny, never assume')
107 await rejects(() => requireRepoOwner({ identity: ID }, { repoDid: null }), 403, 'null repoDid → deny')
108 // DIDs are case-sensitive identifiers; a near-match is not a match.
109 await rejects(() => requireRepoOwner({ identity: { did: DID.toUpperCase(), subKey: 'x' } }, { repoDid: DID }), 403,
110 'a case-variant DID does not pass as the owner')
111}
112
92113// ── mayWrite — non-throwing probe for SSR ────────────────────────────
93114{
94115 eq(await mayWrite({ identity: null }, { isMember: async () => true }), false, 'anonymous may not write')