Owner sessions and abuse controls: the small valves nobody documents
Parent: access-control
The big valves have pages — the three-layer ACL (access-control list, acl-and-quota-granularity), Sigv1 (owner-keypair-auth), the embed JWT (JSON Web Token, embed-credential-never-carries-the-code). This page collects the small ones: the mechanisms that decide whether a stolen cookie keeps working, whether one script can enumerate codes, whether an anonymous visitor can burn the owner's key, whether an abused action can email-bomb a stranger. Each is a few dozen lines, each shipped after a real finding (pentest 2026-09-01 or an owner report), and none had a note. Every claim below cites the code as of 36789537d (v0.1.31, 2026-09-07).
Owner side — sessions and keys
- Owner sessions are server-side, and sign-out revokes them. A login issues a
sms_+ 32-random-byte token stored in Redis atsession:{token}(JSON payload: owner id, csrf token, a random 12-byte publicid,ip_address,user_agent,expires_at), with a 24-hour sliding TTL (time to live) — everyGetre-persists the payload and the Redis expiry together (backend/internal/infra/session/owner_session.go:31-39,113-125).RevokeisDEL+SREMfrom the per-owner index setowner_sessions:{ownerID}(:146-156). The bug (ec40ecdab, 2026-09-05): the sign-out button posted to/api/admin/sessions/signout, an endpoint that never existed, swallowed the 404 and navigated to/login— the Redis session stayed alive and a captured cookie kept working after "logging out". It now posts to/api/admin/me/logout(app/src/lib/admin/sign-out.ts:11-19). Guard:e2e/test/owner-signout-kills-session.spec.tsdrives the real button (confirm modal included) and asserts the same captured cookie returns 401 afterwards. - The active-sessions panel lists and revokes every owner login (
d6d3f54bc, 2026-09-05).GET /api/admin/sessionsreturns{id, ip_address, user_agent, created_at, current}per live session,DELETE /api/admin/sessions/{id}revokes one (backend/internal/routes/admin/sessions.go:34-40). The raw token is never exposed — the row carries the random publicid, andRevokeByIDonly searches the requesting owner's own index (owner_session.go:158-172), so one owner can never name another's session. The index set has no TTL of its own; expired tokens are pruned from it on read (:174-196). The source address comes frommiddleware.ClientAddr, which returns an empty string (shown as unknown) rather than a rate-limit bucket label (backend/internal/infra/middleware/client_ip.go:13-19). Face:app/src/components/admin/sections/system/SessionsPanel.tsx; guard:e2e/test/owner-sessions-panel.spec.ts(a second API login shows up, exactly one row is marked current, revoking the other kills its token). - Owner keypairs record where they were last used (
169a51d79, 2026-09-05). Migrationbackend/db/migrations/2026-09-06-keypair-last-used-meta.sqladds nullablelast_used_ip+last_used_user_agenttoowner_keypairs(idempotentADD COLUMN IF NOT EXISTS); every successful Sigv1 verify stamps them best-effort alongsidelast_used_at; the api·mcp row renders "device · ip" so a leaked or stale key is recognizable before revoking. Guard:e2e/test/keypair-last-used.spec.ts(a real signed request stamps both; the panel row shows them). - The confirm-email route was never wired, and the boot now refuses to start on a missing wire (
94aeaaf3d, 2026-08-31).POST /api/admin/confirm-emailis mounted outside theloginGuardgroup: that guard's bucket is<prefix>+ip, shared by/loginand/recover, so clicking a confirmation link a few times would have burned the login quota — and with no forwarding header, locked the owner out entirely (backend/internal/routes/admin/mount_unauthed.go:32-50). The deeper cause was a hand-copied dependency table missing one line (EmailChange), which surfaced as a nil-pointer 500 that the UI collapsed into "this link is invalid". The fix is structural:depcheck.AllWiredwalks the handler struct by reflection and fails if any dep group has zero non-nil members (backend/internal/infra/depcheck/depcheck.go:35-51);mustBeWiredpanics at startup (backend/cmd/server/boot_http.go:213-217). Guard:e2e/test/account-email-change-needs-confirmation.spec.ts— see mechanical-guardrails for why a checklist would have been the wrong shape.
Visitor side — the per-IP valves
- What "IP" means is decided once.
clientaddr.Middlewareruns afterchi.RealIPand puts one verdict in context: with anX-Forwarded-For/X-Real-IP/True-Client-IPheader the resolved host is the visitor; with none and a private/loopback peer it is unknown (empty string), never the previous hop (backend/internal/infra/clientaddr/clientaddr.go:93-108). The out-of-the-box self-host shape (browser → app → backend, no proxy header) is exactly the unknown case; the backend warns once per process, naming the lost capabilities and the fix (:138-146). This is the F-F-5 history behind the memory that the lockout key "was the app container": before this, every visitor was recorded as the app container's address, so the per-IP lock was one silent global bucket. - The code-fail lockout.
CodeGuardwraps a genericipTallywith key prefixcodefail:ip:,codeFailMax = 10,codeFailWindow = 15 min(also the lock duration) (backend/internal/infra/middleware/code_guard.go:36-59). The Redis key is literallycodefail:ip:+ the visitor address, orcodefail:ip:+ a named shared bucket when the address is unknown (ip_tally.go:50-55) — fail-closed by design, since turning the gate off would hand the endpoint to scripts. It counts onlyaccess.ErrCodeInvalid(noteCodeFail,backend/internal/routes/public/sessions_guard.go:133-139); a valid redemptionResets the count; a Redis error reads as locked (ip_tally.go:93-102); the check applies to bothPOST /api/v1/sessionsin code mode and the name picker's/codes/intropeek (codeLocked,sessions_guard.go:109-122). With captcha on, a valid Turnstile token lifts the lock; with captcha off (the default), it is a pure wait, and the 429 message says which (codeLockedEnvelope,:124-131). Guard:e2e/test/security-code-bruteforce.spec.ts(20 wrong codes from one spoofed address → 429; a clean address with the valid code is unaffected). - IP bans. Table
banned_ips(id, owner_id, ip text, reason, expires_at NULL = permanent, created_at)with a unique(owner_id, ip)index (backend/db/schema.sql:1170-1179).BanGuardis mounted on the whole/api/v1group: a hit returns 403ip_banned; a checker error fails open (availability over lockout, like the public rate guard); an unknown address is allowed through without a lookup, so the shared bucket label can never be typed into the ban table to lock everyone out (backend/internal/infra/middleware/ban_guard.go:39-69). The security domain declares the ops itself —ip_bans.list/ip_bans.add/ip_bans.remove, all owner-reach on both faces (backend/internal/security/ops/ip_bans.go:23-50), projected to/api/admin/ip-bansand the owner MCP tools by the outbound dispatcher. Guard:e2e/test/admin-ip-bans.spec.ts(ban → 403 from that source, 201 from another → unban → 201 again).
Spend and outbound — what an anonymous visitor can cost the owner
- Public and BYOAI (bring-your-own-AI) spend is metered (
1ca9d9564, 2026-09-01). Pentest finding: a no-code session fell back to the owner's default provider at turn time and really spent money, but itsprovider_idstayed empty, so per-provider gas accounting never summed it and the gate conditionmetered && provider_id != ""never fired. Fix: at issue time, an unspecified provider is frozen to the owner's default provider id andGasMeteredis read from the frozen public role (backend/internal/conversation/usecase/visitor_public.go:92-107), through the composition-root portOwnerGas.DefaultProviderID/Remaining(backend/cmd/server/port/gas.go:22-50); exhaustion returns 403gas_exhausted(visitor_gas_quota.go:38-56). The same commit lengthened access-code entropy (access/entity/code_derive.go). Guard:e2e/test/gas-public-spend-is-metered.spec.ts— the usage row carries the default provider id, and with the public role metered and the tank at 1 token the second public turn is 403. - Per-recipient outbound email throttle — the email-bomb defense (
9d6d10d95, 2026-09-06).mailthrottle.Throttleis a fixed-window counter keyed bymail:rcpt:+ sha256 of the trimmed, lowercased address — never the raw address, no PII (personally identifiable information) in Redis keys — with 30 sends per recipient per hour (backend/internal/infra/mailthrottle/mailthrottle.go:18-22,48-64).INCRthenEXPIREon the first hit of a window; any Redis error fails open (a limiter hiccup must never break a legitimate send). It sits in front of every kernel-originated mail in the composition root:OutboundSenderAdapter.SendchecksAllow(to)and, over budget, logs a warning and returnsnil— the caller's flow (a booking, an OTP (one-time password), a recovery) still succeeds; only the email is dropped (backend/cmd/server/port/outbound_sender.go:93-98,136-142). The address never reaches the kernel; only the mail category and verb are named here, once (connector-egress-guard is the connector-side counterpart). Coverage:mailthrottle_test.go(Go unit, fake counter) — no e2e drives it end to end yet.
Deployment edges
- Internal services bind to 127.0.0.1; only the app is outward-facing, behind
APP_BIND_HOST(fc841f41f, 2026-09-01). Docker's bare"5532:5432"binds every interface; the pentest found Redis on:6479with no password, where oneSCANreads the owner's session key — whose name is the plaintext token — for a zero-guess instance takeover.docker-compose.prod.ymlnow publishes backend/db/redis/minio on127.0.0.1:(:188,273,306,366-367) and the app on${APP_BIND_HOST:-127.0.0.1}:38227:3000(:45); an operator with the TLS (transport layer security) proxy on another machine opens it explicitly. The gateinfra/scripts/check-prod-ports-bound-local.shruns inmake env-lint(Makefile:38): every published port ofdb redis minio backend meilisearchmust have127.0.0.1or a${...}variable on the host side, and the script first plants a0.0.0.0publication into a temp file to prove it can see one (:56-67) — a scanner that cannot fail is not a gate (chain-sovereignty). - Referrer-Policy keeps the code out of
Referer(same commit). The access code rides the URL (/<handle>?code=ABC, printed on résumé QR codes); the entry hook wipes it withhistory.replaceState, but on the first paint a cross-origin subresource request would already have put the full URL in theRefererheader.next.config.tssetsReferrer-Policy: strict-origin-when-cross-originon/:path*(app/next.config.ts:116-126): same-origin still sends the full URL, cross-origin sends only the origin. Guard:e2e/test/security-referrer-policy.spec.ts.
The honest ceiling
- Per-IP is only real behind a proxy that sets a forwarding header. Without it every visitor shares one lock bucket (ten wrong guesses by anyone locks everyone for fifteen minutes) and bans cannot target anybody. The instance says so once in its logs; it cannot fix the deployment for you.
- The forwarding header is trusted as given. A proxy that passes a client-supplied
X-Forwarded-Forthrough unstripped turns the lock and the ban into per-claimed-address controls. Same standing caveat as the login guard. - The mail throttle drops silently. Over budget returns
nil, so the calling flow reports success while the recipient got nothing — right for a booking confirmation, less obviously right for an OTP; and it fails open on Redis errors. It has a unit test, not an e2e. - Revocation lives only in Redis. A Redis flush signs every owner out (acceptable); the per-owner index set has no expiry and relies on prune-on-read; a sign-out click that cannot reach the server redirects anyway and may leave the session alive.
Referrer-Policyprotects the wire, not the browser. The code is still in the tab's history and in whatever the visitor pastes; the sessions guard, the embed JWT and revocation absorb what leaks that way.
Built 2026-08-31 → 2026-09-06. Confirm-email wiring + depcheck (94aeaaf3d); gas metering for the no-code tier + code entropy (1ca9d9564); prod ports bound local + Referrer-Policy (fc841f41f); sign-out revokes (ec40ecdab); active-sessions panel (d6d3f54bc); keypair last-used device + ip (169a51d79, migration 2026-09-06-keypair-last-used-meta.sql); per-recipient mail throttle (9d6d10d95). The code-fail lockout (#169), clientaddr (F-F-5) and IP bans (#58) predate that window and are cited from the live tree. Specs: owner-signout-kills-session, owner-sessions-panel, keypair-last-used, account-email-change-needs-confirmation, security-code-bruteforce, admin-ip-bans, gas-public-spend-is-metered, security-referrer-policy.
Origin: pentest 2026-09-01 findings + owner reports 2026-09-05; verified against standmeet-new main 36789537d on 2026-09-07. Design seeds (docs/design/email-recipient-throttle.md, constrained-reachback.md) are not cited as evidence.