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

owner-sessions-and-abuse-controls

Owner 会话与滥用控制:没人写文档的小阀门

父级:access-control

大阀门都有自己的页——三层 ACL(access-control list,acl-and-quota-granularity)、Sigv1(owner-keypair-auth)、embed JWT(JSON Web Token,embed-credential-never-carries-the-code)。这一页收集的是小阀门:决定一个被偷的 cookie 还能不能用、一个脚本能不能枚举邀请码、一个匿名访客能不能烧 owner 的 key、一个被滥用的动作能不能给陌生人狂发邮件的那些机制。每个都是几十行,每个都是在一次真实发现(2026-09-01 的渗透测试或 owner 报告)之后上线的,而且都没有笔记。下面每一条都引用 36789537d(v0.1.31,2026-09-07)时的代码。

Owner 侧——会话与密钥

  • Owner 会话在服务端,退出登录真的会撤销它。 登录签发一个 sms_ + 32 随机字节的 token,存在 Redis 的 session:{token}(JSON 载荷:owner id、csrf token、一个 12 字节随机的公开 idip_addressuser_agentexpires_at),24 小时滑动 TTL(time to live)——每次 Get 都把载荷和 Redis 过期一起重写(backend/internal/infra/session/owner_session.go:31-39,113-125)。RevokeDEL + 从 per-owner 索引集合 owner_sessions:{ownerID}SREM:146-156)。那个 bug(ec40ecdab,2026-09-05):退出按钮 POST/api/admin/sessions/signout——一个从未存在过的端点——吞掉 404 然后跳到 /login,Redis 里的会话还活着,截获的 cookie 在"退出"之后照样能用。现在它 POST/api/admin/me/logoutapp/src/lib/admin/sign-out.ts:11-19)。守卫:e2e/test/owner-signout-kills-session.spec.ts 点真按钮(含确认弹窗),断言同一个截获的 cookie 事后返回 401。
  • 活动会话面板列出并撤销 owner 的每一次登录d6d3f54bc,2026-09-05)。GET /api/admin/sessions 按活着的会话返回 {id, ip_address, user_agent, created_at, current}DELETE /api/admin/sessions/{id} 撤销一条(backend/internal/routes/admin/sessions.go:34-40)。原始 token 从不暴露——每行带的是那个随机公开 id,而 RevokeByID 只在发请求的 owner 自己的索引里找(owner_session.go:158-172),所以一个 owner 永远指不到别人的会话。索引集合本身没有 TTL;过期的 token 在读取时被剪掉(:174-196)。来源地址来自 middleware.ClientAddr,未知时返回空串(显示为 unknown),而不是限流桶的标签(backend/internal/infra/middleware/client_ip.go:13-19)。界面:app/src/components/admin/sections/system/SessionsPanel.tsx;守卫:e2e/test/owner-sessions-panel.spec.ts(第二次 API 登录出现在列表里,恰好一行标为当前,撤销另一行后它的 token 死掉)。
  • Owner 密钥对记录最后在哪里用过169a51d79,2026-09-05)。Migration backend/db/migrations/2026-09-06-keypair-last-used-meta.sqlowner_keypairs 加了可空的 last_used_ip + last_used_user_agent(幂等的 ADD COLUMN IF NOT EXISTS);每次 Sigv1 验签成功都尽力而为地连同 last_used_at 一起盖上;api·mcp 那一行渲染成 "device · ip",泄露或过期的 key 在撤销前一眼就能认出来。守卫:e2e/test/keypair-last-used.spec.ts(一个真签名请求盖上两者;面板行显示它们)。
  • 确认邮箱的路由从未接上,而现在启动时缺一根线就拒绝启动94aeaaf3d,2026-08-31)。POST /api/admin/confirm-email 挂在 loginGuard 分组之外:那个守卫的桶是 <prefix>+ip,由 /login/recover 共用,点几次确认链接就会烧光登录额度——而没有转发头时,会把 owner 整个锁在门外(backend/internal/routes/admin/mount_unauthed.go:32-50)。更深的原因是一张手抄的依赖表少了一行(EmailChange),表现为一个 nil 指针 500,被 UI 折叠成"这个链接无效"。修法是结构性的:depcheck.AllWired 用反射走一遍 handler 结构体,任何依赖组一个非 nil 成员都没有就报错(backend/internal/infra/depcheck/depcheck.go:35-51);mustBeWired 在启动时 panic(backend/cmd/server/boot_http.go:213-217)。守卫:e2e/test/account-email-change-needs-confirmation.spec.ts——为什么清单是错的形状,见 mechanical-guardrails

访客侧——按 IP 的阀门

  • "IP 是什么"只判一次。 clientaddr.Middleware 跑在 chi.RealIP 之后,把一个裁决放进 context:带 X-Forwarded-For / X-Real-IP / True-Client-IP 头时解析出的 host 就是访客;没有头且对端是私网/回环地址时就是未知(空串),绝不拿上一跳冒充(backend/internal/infra/clientaddr/clientaddr.go:93-108)。开箱即用的自托管形态(浏览器 → app → backend,没有代理头)正是未知这一种;backend 每进程告警一次,写明丢了哪些能力、怎么修(:138-146)。这就是记忆里"锁定 key 后面跟的是 app 容器"那句话的 F-F-5 来历:在此之前每个访客都被记成 app 容器的地址,所以按 IP 的锁其实是一个不吜声的全局桶。
  • 邀请码失败锁定。 CodeGuard 包了一个通用的 ipTally,key 前缀 codefail:ip:codeFailMax = 10codeFailWindow = 15 min(也是锁定时长)(backend/internal/infra/middleware/code_guard.go:36-59)。Redis key 就是字面上的 codefail:ip: + 访客地址,地址未知时则是 codefail:ip: + 一个有名字的共享桶ip_tally.go:50-55)——有意 fail-closed,因为关掉门禁等于把端点交给脚本。它只数 access.ErrCodeInvalidnoteCodeFailbackend/internal/routes/public/sessions_guard.go:133-139);一次有效兑换会 Reset 计数;Redis 出错按已锁定处理(ip_tally.go:93-102);检查同时覆盖 code 模式的 POST /api/v1/sessions 和名字选择器的 /codes/intro 预览(codeLockedsessions_guard.go:109-122)。captcha 开着时一个有效的 Turnstile token 能解锁;captcha 关着(默认)时只能等,429 的文案会说清是哪种(codeLockedEnvelope:124-131)。守卫:e2e/test/security-code-bruteforce.spec.ts(同一伪造地址 20 个错码 → 429;干净地址用有效码不受影响)。
  • IP 封禁。banned_ips(id, owner_id, ip text, reason, expires_at NULL = 永久, created_at)(owner_id, ip) 唯一索引(backend/db/schema.sql:1170-1179)。BanGuard 挂在整个 /api/v1 分组上:命中返回 403 ip_banned;检查器出错 fail open(可用性优先于锁定,和公开限流守卫一样);未知地址不查表直接放行,这样共享桶的标签永远没法被填进封禁表把所有人锁在外面(backend/internal/infra/middleware/ban_guard.go:39-69)。security 域自己声明这些 op——ip_bans.list / ip_bans.add / ip_bans.remove,两个面都是 owner 可达(backend/internal/security/ops/ip_bans.go:23-50),由出站 dispatcher 投射成 /api/admin/ip-bans 和 owner MCP 工具。守卫:e2e/test/admin-ip-bans.spec.ts(封 → 该来源 403、另一来源 201 → 解封 → 又 201)。

花费与出站——一个匿名访客能让 owner 花多少

  • public 和 BYOAI(bring-your-own-AI)的花费被计量1ca9d9564,2026-09-01)。渗透测试发现:无码会话在回合时回落到 owner 的默认 provider 并真的花钱,但它的 provider_id 一直是空的,于是按 provider 求和的 gas 记账从不算它,闸门条件 metered && provider_id != "" 也从不触发。修法:签发时把未指定的 provider 冻结成 owner 的默认 provider idGasMetered 从冻结的 public role 里读(backend/internal/conversation/usecase/visitor_public.go:92-107),经组合根的端口 OwnerGas.DefaultProviderID / Remainingbackend/cmd/server/port/gas.go:22-50);耗尽返回 403 gas_exhaustedvisitor_gas_quota.go:38-56)。同一提交还加长了邀请码熵(access/entity/code_derive.go)。守卫:e2e/test/gas-public-spend-is-metered.spec.ts——用量行带默认 provider id,把 public role 设为计量、油箱只剩 1 token 后第二个 public 回合是 403。
  • 按收件人的出站邮件节流——邮件炸弹防御9d6d10d95,2026-09-06)。mailthrottle.Throttle 是一个固定窗口计数器,key 是 mail:rcpt: + 去空格小写后地址的 sha256——从不用原始地址,Redis key 里没有 PII(personally identifiable information)——上限 每收件人每小时 30 封backend/internal/infra/mailthrottle/mailthrottle.go:18-22,48-64)。INCR,窗口首次命中时 EXPIRE;任何 Redis 错误都 fail open(限流器打嗝绝不能弄坏一封正当的邮件)。它在组合根里挡在每封内核发起的邮件前面:OutboundSenderAdapter.SendAllow(to),超额就记一条 warn 并返回 nil——调用方的流程(一次预约、一个 OTP、一次恢复)照常成功,只有那封邮件被丢掉(backend/cmd/server/port/outbound_sender.go:93-98,136-142)。地址从不进内核;邮件的类别和动词只在这里出现一次(connector-egress-guard 是连接器那一侧的对应物)。覆盖:mailthrottle_test.go(Go 单元测试,假计数器)——还没有 e2e 端到端驱动它。

部署边缘

  • 内部服务绑定到 127.0.0.1;只有 app 朝外,由 APP_BIND_HOST 控制fc841f41f,2026-09-01)。Docker 的裸 "5532:5432" 绑到所有网卡;渗透测试发现 :6479 的 Redis 没有密码,一次 SCAN 就读到 owner 的会话 key——它的名字就是明文 token——零猜测接管实例。docker-compose.prod.yml 现在把 backend/db/redis/minio 发布在 127.0.0.1::188,273,306,366-367),app 发布在 ${APP_BIND_HOST:-127.0.0.1}:38227:3000:45);TLS(transport layer security)代理在另一台机器上的运维者要显式打开。闸门 infra/scripts/check-prod-ports-bound-local.sh 跑在 make env-lint 里(Makefile:38):db redis minio backend meilisearch 的每一个发布端口,宿主侧必须是 127.0.0.1 或一个 ${...} 变量,而脚本先往临时文件里种一个 0.0.0.0 发布来证明自己看得见(:56-67)——不可能红的扫描器不是闸门(chain-sovereignty)。
  • Referrer-Policy 让邀请码不随 Referer 出门(同一提交)。邀请码坐在 URL 里(/<handle>?code=ABC,印在简历二维码上);入口 hook 用 history.replaceState 抹掉它,但首屏那一瞬跨源子资源请求已经会把完整 URL 放进 Referer 头。next.config.ts/:path* 上设 Referrer-Policy: strict-origin-when-cross-originapp/next.config.ts:116-126):同源照常发完整 URL,跨源只发 origin。守卫:e2e/test/security-referrer-policy.spec.ts

诚实的天花板

  • 按 IP 只有在设置了转发头的代理后面才成立。 没有它,所有访客共用一个锁桶(任何人猜错十次就把所有人锁十五分钟),封禁也指不到任何人。实例会在日志里说一次;它没法替你修部署。
  • 转发头是照单全收的。 一个把客户端自带的 X-Forwarded-For 原样透传的代理,会把锁和封禁变成"按声称的地址"控制。和登录守卫一样的长期告讫。
  • 邮件节流是静默丢弃。 超额返回 nil,调用流程报告成功而收件人什么都没收到——对预约确认合适,对 OTP 就不那么显然;而且它在 Redis 出错时 fail open。它有单元测试,没有 e2e。
  • 撤销只活在 Redis 里。 一次 Redis 清空会把每个 owner 都登出(可接受);per-owner 索引集合没有过期、靠读时剪枝;一次够不到服务器的退出点击照样跳转,可能把会话留在活着的状态。
  • Referrer-Policy 保护的是线路,不是浏览器。 邀请码还在标签页历史和访客粘贴的任何地方;sessions guard、embed JWT 和撤销兜住从那条路漏出去的部分。

已实现 2026-08-31 → 2026-09-06。 确认邮箱接线 + depcheck94aeaaf3d);无码层 gas 计量 + 邀请码熵(1ca9d9564);生产端口绑定本地 + Referrer-Policyfc841f41f);退出真撤销(ec40ecdab);活动会话面板(d6d3f54bc);密钥对最后使用的设备 + ip(169a51d79,migration 2026-09-06-keypair-last-used-meta.sql);按收件人邮件节流(9d6d10d95)。邀请码失败锁定(#169)、clientaddr(F-F-5)和 IP 封禁(#58)早于这个窗口,按活代码引用。Spec:owner-signout-kills-sessionowner-sessions-panelkeypair-last-usedaccount-email-change-needs-confirmationsecurity-code-bruteforceadmin-ip-bansgas-public-spend-is-meteredsecurity-referrer-policy

来源:2026-09-01 渗透测试发现 + 2026-09-05 owner 报告;2026-09-07 对照 standmeet-new main 36789537d 核验。设计种子(docs/design/email-recipient-throttle.mdconstrained-reachback.md)不作为证据引用。

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 →