Owner 会话与滥用控制:没人写文档的小阀门
大阀门都有自己的页——三层 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 字节随机的公开id、ip_address、user_agent、expires_at),24 小时滑动 TTL(time to live)——每次Get都把载荷和 Redis 过期一起重写(backend/internal/infra/session/owner_session.go:31-39,113-125)。Revoke是DEL+ 从 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/logout(app/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)。Migrationbackend/db/migrations/2026-09-06-keypair-last-used-meta.sql给owner_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 = 10,codeFailWindow = 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.ErrCodeInvalid(noteCodeFail,backend/internal/routes/public/sessions_guard.go:133-139);一次有效兑换会Reset计数;Redis 出错按已锁定处理(ip_tally.go:93-102);检查同时覆盖 code 模式的POST /api/v1/sessions和名字选择器的/codes/intro预览(codeLocked,sessions_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分组上:命中返回 403ip_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 id,GasMetered从冻结的 public role 里读(backend/internal/conversation/usecase/visitor_public.go:92-107),经组合根的端口OwnerGas.DefaultProviderID/Remaining(backend/cmd/server/port/gas.go:22-50);耗尽返回 403gas_exhausted(visitor_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.Send先Allow(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-origin(app/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。 确认邮箱接线 + depcheck(94aeaaf3d);无码层 gas 计量 + 邀请码熵(1ca9d9564);生产端口绑定本地 + Referrer-Policy(fc841f41f);退出真撤销(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-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。
来源:2026-09-01 渗透测试发现 + 2026-09-05 owner 报告;2026-09-07 对照 standmeet-new main 36789537d 核验。设计种子(docs/design/email-recipient-throttle.md、constrained-reachback.md)不作为证据引用。