site logo

Marico's space

我审计了自己的开源库,发现 9 个安全漏洞

编程技术 2026-04-26 17:42:10 null

之前我写过一篇文章介绍 layercache——一个 Node.js 多层缓存库,把 Memory → Redis → Disk 封装在一个统一的 get() 接口里,带,防雪崩打孔、标签失效、熔断等生产级特性。

这次发 v1.3.3,没什么新功能,没有 benchmark 数据,也没有花里胡哨的 API 改动。

只有九个我自己找到的 bug。跟大家一样一样过一遍——分别是什么、怎么发生的、怎么修的。

有些挺丢人的。都是真的。

为什么要做这次全面安全审计

当你的开源项目真的有人在用了,你看待代码的眼光就不一样了。我重新审视了内部实现,问了自己一个具体问题:在真实生产压力下,什么地方会出问题?

结果:还挺多的。

按严重程度说说我的发现。

VULN-1(HIGH):keyEpochs 无限内存增长

CacheStackMaintenance 用了一个 Map<string, number> 叫 keyEpochs,用来追踪写失效——每次删或更新一个 key,就把它的 epoch 往上加,这样陈旧的 write-behind 操作就知道要跳过它。

问题是:这个 map 只会往里加,永不删除。长时间运行的服务写入大量不同 key,内存就会慢慢漏,而且越来越严重。

修复:加了 MAX_KEY_EPOCHS = 50_000,每次 bumpKeyEpochs() 之后做一次剪枝。当 map 超过限制,把最老的 10%(epoch 值最低的)踢出去。

这个 bug 特别阴的地方在于:测试里根本看不出来——只有进程跑了好几天,内存曲线开始往上走的时候才会发现。

VULN-2(MED-HIGH):FetchRateLimiter 队列无限增长

FetchRateLimiter 在触发限流时,会在每个 bucket 下排队等请求。队列本身没有上限。在同一个缓存 key 持续被高并发访问时,这个队列会无限增长——最终耗尽内存,而且背压(backpressure)会无限堆积。

修复:加了 MAX_QUEUE_PER_BUCKET = 10_000。当某个 bucket 的队列满了,新的请求直接绕过限流器而不是继续排队(这个故障模式下,可用性 > 严格限流)。

这个绕过次数有指标暴露出来,方便在生产环境看到什么时候触发了。

VULN-3(MEDIUM):CLI 在请求 Redis 前未校验输入

admin CLI(npx layercache keys --pattern "..."invalidate --tag "..." 等)在把参数传给 Redis 操作之前,没有校验 key、pattern、tag。运行时的 CacheStack 对所有输入都有严格校验——CLI 就是没做。

修复:现在 CLI 在任何 Redis 操作前,同样调用运行时那套 validateCacheKey()、validatePattern()、validateTag()。

运行时早就在 v1.2.x 加固了。CLI 这边就是一直没收通知。

VULN-4(MEDIUM):invalidate 可能一键清空整个缓存,无需确认

运行 npx layercache invalidate 不带 --pattern 或 --tag,默认是 *——匹配缓存里所有 key。没有确认步骤。在终端里一个手滑,你的整个生产缓存就没了。

修复:现在如果不带定向 flag 而且有待删除的 key,CLI 会拒绝执行,让你必须传 --force。

这个挺丢人的。因为加 CLI 本意是方便生产操作,结果留了个手滑就能核弹的东西。还好自己先发现了。

VULN-5(MEDIUM):TagIndex 剪枝实际上是坏的

TagIndex 用 knownKeys 集合来追踪哪些 key 存在,这样按前缀和通配符失效时能找到它们。从 v1.2.0 开始有 maxKnownKeys 限制来防止无限增长——但这是 Set<string>,而 Set 没有访问新鲜度的顺序。剪枝代码排序后驱逐的是……没什么意义的排序。实际效果就是随机删除,不是 LRU。热 key 和冷 key 被驱逐的概率是一样的。

修复:把 knownKeys 从 Set<string> 改成 Map<string, number>,值是时间戳,每次 touch() 或 track() 调用时更新。这样剪枝就能正确驱逐最不常使用的条目了。

这个限制从 v1.2.0 就有了,看起来在工作。实际上没有。

VULN-6(MEDIUM):快照文件写入的 TOCTOU 竞态

快照持久化代码(persistToFile())直接写到目标路径。如果进程在写入中途崩溃,就会留下一个部分或损坏的快照文件,而且没有恢复路径。更糟糕的是,如果两个进程同时写快照,会把彼此的文件覆盖掉。

修复:把所有快照写入统一走两个新工具:atomicWriteTempPath() 生成随机临时文件名,commitAtomicWrite() 把临时文件 rename 到目标——在所有 POSIX 兼容文件系统上都是原子操作。

写临时路径,然后 fs.rename()。在 rename 之前出问题,原始快照不受影响。rename 成功后,读取方要么看到旧文件,要么看到新文件——永远不会是不完整状态。

VULN-7(LOW):layerDegradedUntil 内存泄漏

当一个缓存层失败并进入 degraded 模式时,CacheStack 会存 layerDegradedUntil.set(layer.name, expiryTimestamp)。当降级期过期后,这个 entry 从来没被删掉。在 Redis 偶尔抖一下的服务里,这个 map 会积累每个事件每层一个 entry——永远不清理。

修复:每次检查降级状态时,如果 entry 已过期,在返回前把它删掉。

一行代码的修复,但在任何遇过 Redis 不可用的服务里,都会悄悄积累。

VULN-8(LOW):TTL jitter 用的是 Math.random()

TtlResolver.applyJitter() 用 Math.random() 来分散缓存过期时间。Math.random() 不是加密安全的——它从确定性内部状态播种。对于 TTL jitter 这点来说大多数时候无害,但用可预测的 PRNG 来算过期窗口确实不是好实践。理论上,能测量缓存未命中模式的观察者可以推断 key 什么时候会过期。

修复:把 Math.random() 换成基于 crypto.randomBytes 的等价实现。

randomBytes(4) 很快。性能影响可忽略。

VULN-9(LOW):后台刷新失败用的是 debug 级别日志

当 stale-while-revalidate 后台刷新失败——上游挂了、fetcher 抛异常、超时——错误是用 debug 级别记录的。在几乎所有生产环境里,debug 日志都是关掉的。所以这些失败被静默吃掉了。你看到 key 持续提供旧值,但没有任何日志说明为什么。

修复:一行代码。

我真的不知道这个 bug 隐藏了多久。如果你一直在用 layercache 的 staleWhileRevalidate,然后发现某些 key 好像永久持有着旧值——可能就是这个问题。

这次审计学到的东西

几个造成大多数 bug 的模式:

无上限的 Map 是沉默杀手。 VULN-1、VULN-5、VULN-7 都是同样错误的变体:我分配了一个 Map 或 Set,把边界/剪枝逻辑放在 TODO 列表里,然后就发货了。测试里根本看不出来。在生产环境里,几天后会显现在内存曲线上。

内部工具不会自动继承生产加固。 VULN-3 和 VULN-4 的原因是 CLI 是事后加的。核心库有严格的输入校验。包装它的 CLI 没有。每个接口——HTTP 端点、CLI、管理工具——都需要自己的加固审查。

"Debug 级别日志"在生产环境往往是"没有日志"。 VULN-9 是个合理的设计决策,但在实践中发现是错的。后台刷新失败是运营信号,不是调试细节。

TOCTOU bug 藏在成功背后。 VULN-6 只在崩溃或并发写入时才会出问题——这些情况在单元测试里根本不会发生。原子写入模式才是无论何时都该用的默认做法。

升级方式

v1.3.3 是完全向后兼容的升级。无需 API 改动,无需迁移。

npm install layercache@latest

完整 changelog 在 GitHub 上。

译者按:原文 https://dev.to/flyingsquirrel0419/i-audited-my-own-open-source-library-and-found-9-security-bugs-heres-every-one-3dkc