2026-09-23·by Sijie Wang#standmeet#architecture#design

owner-sessions-and-abuse-controls

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 at session:{token} (JSON payload: owner id, csrf token, a random 12-byte public id, ip_address, user_agent, expires_at), with a 24-hour sliding TTL (time to live) — every Get re-persists the payload and the Redis expiry together (backend/internal/infra/session/owner_session.go:31-39,113-125). Revoke is DEL + SREM from the per-owner index set owner_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.ts drives 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/sessions returns {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 public id, and RevokeByID only 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 from middleware.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). Migration backend/db/migrations/2026-09-06-keypair-last-used-meta.sql adds nullable last_used_ip + last_used_user_agent to owner_keypairs (idempotent ADD COLUMN IF NOT EXISTS); every successful Sigv1 verify stamps them best-effort alongside last_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-email is mounted outside the loginGuard group: that guard's bucket is <prefix>+ip, shared by /login and /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.AllWired walks the handler struct by reflection and fails if any dep group has zero non-nil members (backend/internal/infra/depcheck/depcheck.go:35-51); mustBeWired panics 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.Middleware runs after chi.RealIP and puts one verdict in context: with an X-Forwarded-For / X-Real-IP / True-Client-IP header 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. CodeGuard wraps a generic ipTally with key prefix codefail:ip:, codeFailMax = 10, codeFailWindow = 15 min (also the lock duration) (backend/internal/infra/middleware/code_guard.go:36-59). The Redis key is literally codefail:ip: + the visitor address, or codefail: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 only access.ErrCodeInvalid (noteCodeFail, backend/internal/routes/public/sessions_guard.go:133-139); a valid redemption Resets the count; a Redis error reads as locked (ip_tally.go:93-102); the check applies to both POST /api/v1/sessions in code mode and the name picker's /codes/intro peek (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). BanGuard is mounted on the whole /api/v1 group: a hit returns 403 ip_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-bans and 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 its provider_id stayed empty, so per-provider gas accounting never summed it and the gate condition metered && provider_id != "" never fired. Fix: at issue time, an unspecified provider is frozen to the owner's default provider id and GasMetered is read from the frozen public role (backend/internal/conversation/usecase/visitor_public.go:92-107), through the composition-root port OwnerGas.DefaultProviderID / Remaining (backend/cmd/server/port/gas.go:22-50); exhaustion returns 403 gas_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.Throttle is a fixed-window counter keyed by mail: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). INCR then EXPIRE on 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.Send checks Allow(to) and, over budget, logs a warning and returns nil — 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 :6479 with no password, where one SCAN reads the owner's session key — whose name is the plaintext token — for a zero-guess instance takeover. docker-compose.prod.yml now publishes backend/db/redis/minio on 127.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 gate infra/scripts/check-prod-ports-bound-local.sh runs in make env-lint (Makefile:38): every published port of db redis minio backend meilisearch must have 127.0.0.1 or a ${...} variable on the host side, and the script first plants a 0.0.0.0 publication 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 with history.replaceState, but on the first paint a cross-origin subresource request would already have put the full URL in the Referer header. next.config.ts sets Referrer-Policy: strict-origin-when-cross-origin on /: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-For through 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-Policy protects 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.

about this entry

One of sijie's wiki entries. The AI on this site is grounded in the same corpus and answers in sijie's voice, with citations back to entries like this one — answering costs sijie money, so it waits behind a code: enter an access code →