Skip to content

每号码 OTP 发送预算(#2780)也只在进程内计数 —— 与 #4772 的限流洞同类,多节点下可按节点数倍增 #4790

Closed

Description

@os-zhuang

在 #4772(限流计数器惰性解析 kernel cache)的实施过程中发现,未认领,不在那个 PR 的范围内。由 PM 代为立单 —— dev 在报告里记录了这条,但没有开 issue,而它是 Prime Directive #10 该立的那种。

现象

AuthManager.getOtpSendGuard() 实现的 #2780「每号码 OTP 发送预算」,只在宿主显式提供 secondaryStorage 时才跨节点共享;标准 serve 组合下没有宿主提供它,于是预算是每进程一份。

这与 #4772 修掉的限流计数器洞是同一类(计数器落在进程内存储,声明的「全局限额」在多节点下不成立),但是独立的一处 —— #4788 修的是 better-auth 的 rateLimit 计数器,走的是新的 rateLimit.customStorage;OTP 预算是 ObjectStack 自己在 AuthManager 里实现的另一套计数,行为未被 #4788 改变。

为什么值得单独修

后果比限流洞更直接地带成本:

  1. 短信费用。每次 OTP 发送是真金白银。预算按节点数倍增,意味着一个 N 节点部署的实际发送上限是声明值的 N 倍。feat(sms): 短信全局/每租户日发送配额(成本总量闸) #2814(短信全局/每租户日发送配额)正是在处理这条成本线 —— 如果那个闸也建立在同一套进程内计数上,它上线即失效,建议一并核对。
  2. OTP 暴力面。每号码预算是 OTP 的核心反滥用手段之一;跨节点轮换即可放大尝试次数。
  3. 声明 ≠ 强制(ADR-0049)。这是 bug(plugin-auth): [auth] no cache service registered 在 CacheServicePlugin 注册前 21ms 就喊了 —— 误报,且把人引向「你需要 Redis」 #4772 那条的翻版:配置里能写一个每号码预算,运行时在多节点下不兑现,而没有任何信号告诉你它没兑现 —— bug(plugin-auth): [auth] no cache service registered 在 CacheServicePlugin 注册前 21ms 就喊了 —— 误报,且把人引向「你需要 Redis」 #4772 至少还打了一条(误导性的)warn,这里连 warn 都没有。

修法方向

#4788 已经把正确姿势建好了:packages/plugins/plugin-auth/src/rate-limit-storage.ts 的 createLazyCacheRateLimitStorage() —— 计数器被消费时才解析 cache 服务(必然晚于 kernel:ready,与插件启动顺序无关),解析不到则降级为进程内计数并响亮告知。OTP 预算应当复用同一条路径,而不是再写一份。

incrementFixedWindow 也已经被抽出来在两个计数入口间共用(#4788),第三个入口接进去是自然的。

⚠️ 接手前先等 #4788 合并,否则会与它的新文件冲突。当前状态:PR #4788 已复核通过,CI 中。

要先确认的一点

这条来自 #4772 dev 的顺带观察,我(PM)没有独立验证过 getOtpSendGuard() 的具体代码路径。接手的第一件事请核实:该预算的存储确实取自 AuthManagerOptions.secondaryStorage(而非别的地方),且默认组合下确实为空。如果实际情况不同,如实回报并关掉本单 —— 不要为了让 issue 成立而修一个不存在的问题。

关联

Activity

  1. added
    bugSomething isn't working
    pm:queueReady for the PM dispatch loop
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    on Aug 3, 2026
  2. self-assigned this
    on Aug 3, 2026
  3. os-zhuang commented on Aug 3, 2026

    @os-zhuang
    ContributorAuthor

    认领:PM 循环第 4 轮
    会话:session_015Br2xsJsczFsTR9bvbh2Ny
    分支:claude/issue-4790-otp-budget-shared-cache
    Worktree:objectstack-issue-4790

    阻塞已解除:#4788 已合并,createLazyCacheRateLimitStorage() 与 incrementFixedWindow 现在在 main 上,直接复用。

    分诊:落地 objectstack(packages/plugins/plugin-auth)。修法已由 #4788 确立,无需额外裁定。第一件事仍是核实(issue 正文里写了):确认 OTP 预算的存储确实取自 AuthManagerOptions.secondaryStorage 且默认组合下为空 —— 如果不是,如实回报并建议关单,不要为了让 issue 成立而修一个不存在的问题。


    Generated by Claude Code

  4. os-zhuang commented on Aug 3, 2026

    @os-zhuang
    ContributorAuthor

    PM 复核:ACCEPT(待 CI 全绿) —— PR #4806。

    一、核实做了,结论是洞真实存在

    我在派发时明确写了「第一件事是核实,不是修 …… 如果实际情况不同,如实回报并建议关掉本单」。dev 给了四条证据,其中两条是决定性的:

    所以标准 serve 组合下 storage === undefined,预算落在每进程一份的 Map 里。这条不是推断,是穷举。

    二、它偏离了我的指令,而且它是对的

    我写的是「复用 createLazyCacheRateLimitStorage() / incrementFixedWindow,不要再写第二份计数实现」。dev 复用了前者的解析路径(抽出 createLazyCounterStore()),但拒绝把 OTP 计数改成 incrementFixedWindow,理由:

    定窗计数器表达不了「距上一次发送满 N 秒」这个冷却语义;把「每滚动小时 5 条」改成定窗,会在窗口边界放行 2 倍突发(59:59 五条 + 60:01 五条)。本单的主题正是「预算被倍增」,用一种倍增换另一种倍增不划算。

    这个判断比我的指令准确。 我当时只看到「两处计数应当共用实现」这个整洁性目标,没有意识到 OTP 预算的语义(滚动窗口 + 冷却)和限流的语义(定宽窗口)根本不是一回事。照我说的做,会在修掉「按节点倍增」的同时引入「按窗口边界倍增」,并且静默改掉 #2780 已有测试钉住的语义 —— 那是把一个 bug 换成另一个 bug,还顺手弱化了契约。

    正确的做法正是它做的:共用「怎么拿到共享存储」,不共用「怎么计数」。前者是本单的缺陷所在,后者不是。

    这是本轮第二次 dev 用证据推翻我的裁定(上一次是 #4771 选 kernel:bootstrapped 而非我说的 kernel:ready)。两次都在报告里明说了偏离并给出理由,而不是默默照做或默默不做 —— 这正是我要的。

    三、#2814 的核对给出了比"立单"更好的答案

    我要求核对短信配额闸是否也建立在同一套进程内计数上,并说"若是,另立 issue"。dev 查了 service-sms 下的 quota / daily,零命中 —— #2814 尚未实施,所以不存在第二个"上线即失效"的闸。

    这个结论比立一个投机性的 issue 有价值得多。而且它顺带确认了一件事:本 PR 抽出的 createLazyCounterStore() 正是 #2814 落地时该走的路径,#2814 正文第 4 点要求的降级策略与它一致。等于这个 PR 提前把 #2814 的地基铺好了,而不是给它留了个坑。

    四、其余复核

    CI 全绿后我标 ready 并送合并队列。


    Generated by Claude Code

  5. os-zhuang commented on Aug 3, 2026

    @os-zhuang
    ContributorAuthor

    补充证据:文档早就承诺了这个能力,而运行时从未兑现

    我按 docs-drift 提示逐篇核了受影响文档,查到一条直接相关的:

    content/docs/permissions/authentication.mdx:350-352

    • Per-number admission guard (always on): 60s cooldown + 5 sends per rolling hour per number, shared across the sign-in and reset flows and — in a cluster — across nodes via the shared KV.

    这句话在本 PR 之前是假的。 「in a cluster — across nodes via the shared KV」正是本 issue 证伪的那件事:标准 serve 组合下没有任何宿主提供 secondaryStorage,预算落在每进程一份的 Map 里,集群里每个节点各算各的。

    两点意义:

    1. 这条 issue 不是理论风险,是已经写进面向用户文档的能力承诺。 一个照着这段文档做集群部署的运维,会相信每号码限额是全局的 —— 而实际上它是 N 倍。短信按条计费,这个差额直接是钱。
    2. 不需要改文档 —— fix(plugin-auth): 每号码 OTP 发送预算改用惰性解析的共享计数存储 —— 多节点下不再按节点数倍增 (#4790) #4806 让这句话第一次成真。

    一个值得记下的模式:今晚这是第二次

    两次都是文档描述了设计意图,运行时静默没做到,而没有任何东西在比对这两者。仓里有 docs-accuracy-audit 工作流,但它显然抓不到这一类 —— 因为文档和代码各自都"自洽",不一致只存在于两者之间的语义层。

    两次都是靠"恰好有人去修运行时"才发现的。这个发现路径不可靠:如果没人碰这两处代码,这两句假承诺会一直挂在文档上。这一点我会单独记进 #4776 的讨论,那条正在裁定的结构性防线应当把「声明面」的范围从代码内的 schema 扩到面向用户的能力承诺。


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingpm:dispatchedDispatched to a dev agent by /pm-dispatchpm:queueReady for the PM dispatch loopsecurity

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions