关于 aegis-api-token-security
用于审计互联网暴露的 HTTP API 中不透明 Bearer/API Token 全生命周期的安全技能,适合检查 API 凭证设计与运维。它按生命周期审查令牌生成、存储、传输、权限、撤销、限流和日志等环节,并要求只读检查。
主要功能
- 01检查令牌熵与生成方式
- 02审查服务端令牌存储
- 03检查传输与权限范围
- 04评估过期、轮换和撤销
- 05检查限流、日志与监控
适用场景
- 01审计机器客户端 API 凭证
- 02排查令牌明文存储风险
- 03评估令牌泄露后的撤销能力
- 04检查限流及日志中的敏感信息
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ as-foss/ aegis-skills --skill aegis-api-token-security安装后在 Claude Code 中输入/aegis-api-token-security确认已加载。
附带文件references/background.mdreferences/further-reading.md
详细介绍
范围与目标
审计一个暴露于互联网的 HTTP API 的令牌生命周期各阶段,该 API 使用不透明的持有者令牌/API 令牌对机器客户端进行身份验证(令牌以 Authorization: Bearer <token> 或 API 密钥请求头的形式传递)。按优先级排列的十个领域:
- 设计 — 熵下限(由 CSPRNG 生成,>=128 位)、格式约定(类型前缀、校验和)、禁止使用的令牌基础值。
- 服务端存储 — 经典致命缺陷:数据库中存储明文或可逆加密的令牌,而非仅存储哈希。
- 传输 — 强制仅使用 TLS、通过请求头、查询字符串还是 Cookie 传递令牌。
- 范围与权限模型 — 全能令牌与限定范围、绑定租户、读写权限分离的密钥。
- 过期与轮换 — 有效期策略、通过重叠窗口实现无停机轮换、清理过期令牌。
- 撤销与事件响应准备 — 撤销延迟(包括身份验证缓存造成的延迟)、泄露响应操作手册。
- 速率限制与滥用控制 — 每密钥配额、429 语义、与未身份验证端点分离。
- 密钥枚举防护 — 无效令牌的错误行为和响应时间保持一致。
- 日志记录与监控 — 安全的字段、绝不记录完整令牌的规则、异常告警。
- 客户端卫生与主机扩散 — 分发指南,以及令牌在主机上的藏匿位置(衔接 HSECRET)。
范围外(交叉引用):JWT 结构、算法、none/弱密钥验证 → AUTHN(skills/code/authn-session/SKILL.md);Cookie 携带凭据引发的 CSRF 后果 → WEB(skills/code/web-client/SKILL.md);一般 API 滥用、分页/枚举、未身份验证端点加固 → API(skills/code/api-security/SKILL.md);边缘层的 TLS 终止和请求头剥离 → TLS(skills/server/tls-proxy/SKILL.md);文件/代码仓库中的密钥内容 → SECRETS(skills/code/secrets-data-exposure/SKILL.md);流量洪泛阈值 → DoS(skills/code/denial-of-service/SKILL.md)。本操作手册提及日志监控模块时,如果你的副本中存在该模块,请应用其中的告警路由指南。
操作规则:
- 所有检查均为只读。修改命令仅出现在 Remediation 下,且需要 Phase 6 批准。
- 需要 root 权限的命令标记为
[ROOT];需要应用程序数据库读取凭据的命令标记为[APP-DB]。如果没有这些权限,则从配置即代码仓库进行审计,并将缺口报告为“未在主机上验证”。 - 判断实际生效状态:正在运行的中间件代码、已部署的架构、正在写入的日志。配置意图仅作为次要证据。
前置条件与术语
零背景知识入门:本模块使用的术语,每项一行。每个类别的深入通俗解释见仓库 GUIDE.md 词汇表。
- 持有者令牌:一种凭据,意思是“谁持有这个字符串,谁就已登录”
- 熵:以位为单位衡量的不可预测性;生成的令牌需要至少有 128 位熵,并由 CSPRNG 生成
- 仅哈希存储:仅保存每个令牌的加密哈希,这样数据库泄露也不会泄露可用的登录凭据
- 限定范围的密钥:仅限于指定操作和读写级别的令牌;与可授予所有权限的全能令牌相对
- 撤销延迟:从“撤销”到令牌实际停止工作之间的延迟(包括其间的任何缓存 TTL)
- 轮换重叠期:更换期间新旧令牌同时有效,以便客户端更新而不造成停机
- 每密钥速率限制:针对每个令牌独立执行的请求配额
- 污染流 / source→sink:不可信数据从入口点流向危险函数的路径
- 发现项状态:已确认 > 可能 > 需审核;证据规则见 templates/finding-report.md
心智模型
攻击者只需要一个有效令牌。生命周期中的每个薄弱环节都是获取令牌的不同途径:
Attacker goal: obtain ONE valid token ──▶ replay it from anywhere on the internet
How they get it Why it was possible (this module)
------------------------------------ -------------------------------------------------
SQLi / leaked DB backup ◀── tokens stored plaintext in DB (storage) = fatal
grep /var/log on any one box ◀── full tokens written to access/app logs
Referer header / browser history ◀── tokens carried in the query string
public CI logs / client repos ◀── hardcoded tokens, no env-var guidance to users
online guessing ◀── low-entropy minting + no per-key rate limit
ex-contractor's old key ◀── no expiry, no revocation path, no last-used signal
cached authz after revoke ◀── revocation "done" but token keeps working to TTL
两个认识影响着大多数判定,而 Agent 往往在两个方向上都判断错误:
- 哈希算法的选择取决于上下文。 密码数据库需要使用缓慢的加盐 KDF(argon2/bcrypt),因为人类会选择容易猜到的秘密;KDF 的作用仅此而已。使用 >=128 位 CSPRNG 熵生成的 API 令牌不存在可猜测的结构,因此静态存储时,
SHA-256(token)就足以提供保护:要反推出令牌,必须搜索 >=2^128 个候选值,这在计算上不可行。在这里使用 argon2 并不代表“更安全”——它只会让每个请求多花约 100 ms,还会招致针对认证端点的 DoS。反过来,SHA-256("password123")则毫无保护作用。判定依据是输入的熵,而非算法是否流行。 - 延后生效的撤销不算撤销。 令牌检查与权威数据表之间任何具有正 TTL 的缓存,都会把“已撤销”变成“将在 N 秒内撤销”。始终根据代码量化 N。
本模块的整个分类过程中均采用以下生命周期阶段视角:
| 生命周期阶段 | 缺陷 | 检测方法 | 严重程度依据 |
|---|---|---|---|
| 生成 | 熵 <128 位,或使用非 CSPRNG 随机数(Math.random、Mersenne Twister) | 查看生成令牌的代码;计算 log2(alphabet^len) | 高(如果令牌较短且无速率限制,则为严重) |
| 生成 | 仅使用 uuid4 令牌 | 在令牌生成路径中查找 randomUUID()/uuid4() | 临界情况——参见“检查事项” #1 |
| 生成 | 无类型前缀/无校验和 | 检查令牌格式示例 | 低 |
| 生成 | 根据用户 ID、电子邮件、时间戳或计数器派生 | 查看生成令牌的代码 | 高 |
| 存储 | 令牌列为明文/可逆形式 | 检查数据库架构;确认是否缺少对应的 *_hash 列 | 严重 |
| 存储 | 哈希被截断(<32 字节),或使用无盐 MD5/SHA-1 | 检查列大小/代码中的算法 | 高 |
| 传输 | 通过查询字符串传递 | 在访问日志中搜索 token= 参数 | 高 |
| 传输 | 通过普通 HTTP 接受令牌 | 检查监听器/代理配置(交叉参照 TLS) | 高 |
| 权限范围 | 集成之间共享单一万能令牌;无权限范围列 | 对照客户端清单与令牌行 | 高 |
| 到期 | 所有 expires_at 均为 NULL,且不支持撤销 | 检查 DDL 和管理界面清单 | 高 |
| 轮换 | 轮换需要同时切换(造成停机或故障) | 检查运行手册/集成事故历史 | 中 |
| 撤销 | 撤销时未使认证缓存失效 | 查看缓存 TTL 代码路径 | 高 |
| 限制 | 没有按密钥设置配额;从不返回 429 | 审查配置;检查文档端点的响应头 | 中 |
| 枚举 | 因原因不同,无效令牌响应的耗时/响应内容也不同 | 进行粗略计时循环;梳理错误类型 | 中 |
| 日志 | 日志中包含完整令牌 | 在日志中搜索 Bearer <20+ chars> | 高 |
| 分发 | 令牌硬编码在代码仓库/配置中 | 全面搜索代码仓库/主机 | 中(交叉参照 SECRETS) |
根据调查结果破坏链条中的哪个环节进行分类;如果一个中等风险缺口与相邻的入口点组合,严重程度就会升级为严重(例如,无速率限制 + 8 字符令牌 = 可通过在线猜测破解)。
检查事项
1. 令牌设计:熵、格式、禁用的生成依据
定位令牌生成代码并阅读。检查栈的两端:
rg -n "randomBytes|randomUUID|uuid4|token_hex|token_urlsafe|secrets\.choice|randbelow" server/ src/ app/
rg -n "Math\.random|random\.random|random\.randint|Random\(\)|mt19937" server/ src/ app/ # non-CSPRNG = flag
应用最低标准:使用 CSPRNG 生成 >=128 位,即至少使用 crypto.randomBytes(16),首选 32 字节。自行计算熵:bits = log2(alphabet_size ^ length)。43 个字符的 base64url 字符串约含 256 位;32 个十六进制字符的字符串含 128 位;8 个字母数字字符的字符串约含 47.6 位,单凭这一点就属于高危发现。
关于 uuid4 的判断——原样应用:UUIDv4 含有由 CSPRNG 生成的 122 个随机位。对于任何现实中的攻击者,暴力破解 2^122 与破解 2^128 一样不可行,因此,源自 uuid4 的令牌处于可接受边缘,需标记:(a) 122 < 128,低于规定的最低标准,因此绝不能不加提示地通过;(b) uuid4 字符串没有类型前缀和校验和,因此在日志和配置中无法与其他 UUID 区分,并且粘贴错误后会一直保留,直到请求到达服务端;(c) uuid4 经常被重复用作通用标识符,因此同一值往往也会出现在非机密场景中,让用户养成到处复制它的习惯。建议使用专门设计的带前缀格式;不要仅因熵不足就定为严重。
格式必须具备以下属性(缺失时为低危,但始终需要报告):
- 用于标识机密信息类型和环境的类型前缀:
sk_live_、pat_、ghp_等格式。这有助于机密信息扫描工具识别、快速目视检查日志,并安全地记录前缀。 - 尾随校验和(例如
SHA-256(body)的 8 个 base64url 字符),让输错的令牌在本地就被拒绝,而不是产生身份验证流量并使令牌在传输过程中被收集。
禁用的生成依据——标记任何源自或包含以下内容的令牌:用户 ID、电子邮件地址、时间戳、递增计数器、主机名/MAC,或使用 none/弱对称密钥签名的 JWT(请在 AUTHN 中验证最后一项,参阅 skills/code/authn-session/SKILL.md;在本模块中,将“接受 JWT 作为 Bearer 凭据”视为指向其他审计项的发现,不要重复审计)。
2. 服务端存储(经典致命缺陷)
检查已部署的架构 [APP-DB]:
SELECT table_name, column_name, data_type, character_maximum_length
FROM information_schema.columns
WHERE column_name ~* '(token|api_key|apikey|secret|credential)'
ORDER BY table_name, ordinal_position;
判定规则:
- 任何存储可用于身份验证的令牌值本身的列(名称不含
_hash/digest,且填有完整长度的令牌字符串),只要 API 接受该值,即为严重。仅抽样检查长度,绝不读取值:SELECT length(secret_col), count(*) FROM t GROUP BY 1;——与生成的令牌长度一致且分布均匀,即可佐证这一点,而不会暴露机密信息。 - 可逆加密存储(AES 等,且密钥位于应用配置中)→ 高危:解密密钥就在近旁,因此任何应用级读取原语都能使该控制失效。报告为“在密钥泄露时等同于明文”。
- 正确结构:恰好有一个权威列,名为
token_hash/token_digest,存储SHA-256(raw_token)(32 字节摘要),并由 UNIQUE 索引覆盖。为什么这里使用 SHA-256 就足够,而 argon2 不适用:一旦输入熵 >=128 位,只需具备抗原像性——无需“拖慢”任何操作,因为猜测本就不可能成功(数学推导见 Exploitation d)。在每次请求中使用慢速 KDF 还会增加最热代码路径的 CPU 开销。这种区别也适用于反向情况:如果发现对高熵令牌使用密码式哈希(bcrypt/argon2),应注明这是不必要的开销(仅供参考,不构成发现);如果发现对低熵令牌使用普通哈希,那么哈希只是装饰——严重程度仍由熵不足这一发现决定。 - 拒绝截断的摘要(SHA-256 少于 32 字节)以及任何未加盐的 MD5/SHA-1 类型哈希:高危。
3. 传输规则
- 仅限 TLS:API 必须拒绝通过纯 HTTP 发送的、携带令牌的请求。边缘证据交叉引用 TLS(
skills/server/tls-proxy/SKILL.md);在应用侧,确认没有任何代码路径会绕过限制并接受X-Forwarded-Proto: http。 - 传递方式:默认正确的选择是
Authorization: Bearer请求头(RFC 6750)。如果能始终如一地强制执行,自定义请求头(X-API-Key)也可以接受。通过查询字符串传递(?access_token=/?api_key=)属于高危发现:令牌会出现在访问日志、浏览器历史记录、任何出站链接的Referer请求头以及中间设备中。如果旧客户端强制要求这样做,则必须要求在整改措施中采取日志脱敏措施,并设定弃用期限。 - Cookie 传递:Cookie 中的令牌会继承浏览器的 CSRF 模型(交叉引用 WEB、
skills/code/web-client/SKILL.md:SameSite、CSRF 令牌、禁止使用通配符域 Cookie)。对于机器 API,这通常是设计异味;应标记为中危,并交叉引用 CSRF,除非确实需要浏览器流程。
4. 范围与权限模型
- 清点已签发的令牌
[APP-DB]:SELECT client_id, count(*), array_agg(DISTINCT scopes) FROM api_tokens GROUP BY 1;如果一行服务于每个集成,说明存在上帝令牌模式 → 高危。 - 必需模型:每个客户端使用带有明确范围(例如
read:invoices、write:invoices)的密钥,绑定租户/资源(凡对象归属于租户,tenant_id均不得为 NULL),并为敏感界面分离读写凭证。 - 必须在中间件中强制执行范围要求,绝不能仅依赖约定。标记任何从令牌内容中解析范围/租户声明、却未验证签名或服务端权威记录的代码——未经验证、由令牌自行声称的范围会导致通过构造令牌实现权限提升 → 高危(若可通过声明获取写入/管理员范围,则为严重)。
- 检查管理员端点是否要求独立范围,并确认没有任何现有令牌因“临时”需要而持有该范围。
5. 过期与轮换
- 有效期策略:优先使用有限的
expires_at。只有在具备有据可查的补偿性控制措施时,长期有效的令牌才可以接受:定期轮换策略、使用情况监控和即时撤销能力(三项缺一不可,否则为高危)。 - 测量实际情况
[APP-DB]:
SELECT count(*) FILTER (WHERE expires_at IS NULL) AS immortal_tokens, count(*) AS total FROM api_tokens;
SELECT max(last_used_at) AS stale_active FROM api_tokens WHERE revoked_at IS NULL;
- 使用时必须更新
last_used_at(可以限制写入频率,例如每个令牌最多每分钟更新一次)。缺少跟踪 → 中危(会妨碍发现泄露和清理陈旧密钥)。 - 陈旧令牌清理策略:超过 90 天未使用 → 先禁用,再于保留期限后删除;记录具体期限,并确认存在相应的作业或运行手册。
- 轮换机制必须支持重叠窗口(两个令牌可同时有效),以便集成在不中断服务的情况下切换。必须同时切换并祈祷的切换方式 → 中危流程问题;过去轮换导致的生产故障可作为佐证。
6. 撤销与事件响应手册
- 证明存在可用且可访问的撤销途径:管理界面、API 或有文档记录的运维流程。完全没有途径 → 高风险。
- 检查热认证路径中的缓存(进程内映射、Redis、CDN/认证服务缓存)。记录每一层的:键结构、TTL 值,以及撤销操作是否会删除/更新缓存。未配置失效机制的缓存属于高风险发现:被撤销的令牌会继续有效,直到 TTL 到期;根据代码中的
ttl_seconds量化这一时间窗口,并在发现项中说明。 - 泄露响应手册检查清单(是否存在 + 演练证据):
- 撤销令牌(确认撤销操作在规定时限内生效)。
- 排查使用情况:按令牌前缀和令牌 ID 搜索日志,查找泄露后的活动;确定时间线。
- 轮换依赖该令牌的集成:在重叠窗口内签发作用域相同的替代令牌。
- 评估影响范围:根据作用域、租户绑定和
last_used_at的变化确定通知义务。 - 向令牌所有者/客户通报实际发生的时间窗口及已采取的措施。
- 事后复盘:令牌如何泄露(日志?代码库?支持工单?),哪个控制措施失效。
- 值得设置告警的入侵指标(接入日志监控模块):同一令牌的 API 来源 IP 出现不可能的地理位置跳转;401 激增后出现 200(猜测成功);已知客户端 ID 使用全新的 User-Agent 或 ASN;请求量骤变。
7. 针对每个密钥的速率限制与滥用控制
- 每个经过身份验证的请求都必须能追溯到一个密钥,且每个密钥都必须有配额:突发限制(令牌桶,可吸收重试)和持续限制(每分钟和/或每天的请求数)。缺少限制 → 中风险;写入/管理端点缺少限制 → 高风险。
- 语义:超出限制时返回
429和Retry-After(秒)——检查代码/文档中的响应头;静默断开连接或返回 500 均属于发现项。 - 未经身份验证的端点(登录、签发令牌、注册)需要设置独立且更严格的限制桶,按 IP+标识符作为键,与按密钥设置的限制区分开来(交叉参考 API
skills/code/api-security/SKILL.md和 DoSskills/code/denial-of-service/SKILL.md)。 - 边缘限流(nginx
limit_req)可以作为补充,但必须基于每个客户端稳定不变的信息作为键——关于$http_authorization的注意事项,请参阅修复建议。
8. 防止枚举密钥
- 统一拒绝:无效令牌、格式错误的令牌、未知密钥、已过期令牌、已撤销令牌 → 状态码相同(401)、响应正文结构相同、响应时间无法区分。能区分“已过期”和“未知”的错误分类有助于合法调试,但会向攻击者提供枚举凭据的判别预言机——违规时要求使用通用消息,并单独记录内部日志 → 中风险。
- 恒定时间比较:将提交的令牌摘要与存储的摘要进行最终比对时,必须使用
crypto.timingSafeEqual(Node)/hmac.compare_digest(Python)/等效方法。**如实说明可利用性:**参与比较的是哈希值;攻击者的输入会产生均匀随机的摘要,因此 memcmp 的提前退出泄露的是前导字节与攻击者无法定向匹配的值相符的数量——实际上,通过网络抖动进行远程利用并不现实。仍然要求采用恒定时间比较:它只需调用一个函数,可消除一整类审计发现,并保护未来的重构(例如,有人添加前缀匹配捷径)。非恒定时间比较 → 根据其他比较内容,风险为低至中;如果比较原始前缀/用户可见标识符,则风险会更高。 - 粗略的字段检查(不要对生产环境进行大量请求):使用固定格式的无效令牌发送 20 个请求并目测时间分布——方法及注意事项见“验证”部分。
9. Token 使用的日志记录与监控
- 严禁记录完整令牌:任何访问日志、应用日志、错误跟踪器或 APM 负载都不得包含原始令牌。安全字段:令牌前缀(前约 12 个字符)、令牌数据库 ID、客户端 ID、已认证主体、源 IP、UA、路由、延迟。
- 扫描实际日志(见“查找位置”部分):匹配
Bearer [A-Za-z0-9_.=-]{20,}或access_token=→ 高危发现,因为日志的访问范围(运维人员、SIEM、日志传输器、备份)远大于数据库的访问范围。 - 告警钩子(交叉参考 logging-monitoring 模块):单个密钥的 401 速率异常、配额饱和、范围拒绝突增(
403激增表明令牌可能已泄露,正在探测权限边界)、每个客户端 ID 的新 UA/新地理位置。
10. 分发令牌的客户端侧卫生
- 公开文档和快速入门必须展示如何从环境变量加载令牌,绝不能内联明文;即使示例令牌是伪造的,包含看似真实的令牌的示例代码也会被标记,因为用户会将其原样复制到代码仓库(交叉参考 SECRETS
skills/code/secrets-data-exposure/SKILL.md)。 - Webhook 签名密钥应采用与令牌相同的生命周期:使用 CSPRNG 生成、静态加密存储、通过前缀识别、轮换时双重接受、以恒定时间验证 HMAC。此处简述;详情见 SECRETS。
- 分发渠道:令牌仅在创建时显示一次;支持工单/电子邮件中包含有效令牌 = 高危发现(令牌会进入第三方邮件存储)。
11. 主机上令牌的藏身之处(衔接 HSECRET)
已签发的令牌往往会在令牌服务之外不断积累:
- 应用配置文件:
.env、application.yml、settings.py中的常量、/opt/<app>下的 JSON 配置——检查权限(600,归服务用户所有)和内容。 - systemd 单元文件:
Environment=行会将密钥嵌入所有用户都可读的单元文件;任何本地用户都能通过单元文件本身或systemctl show <unit> -p Environment读取(在典型安装中,单元属性不受文件 DAC 限制)。/proc/<pid>/environ仅限文件所属用户(或 root)访问——因此暴露范围不同:单元文件中的Environment=会泄露给所有本地用户;进程环境变量仅对同一 UID 用户/root 可见。优先使用EnvironmentFile=,并确保文件归 root 或服务用户所有且权限为600,或使用密钥管理器。 - Shell 历史记录:运维人员执行
export API_TOKEN=...会将凭证留在~/.bash_history中;主机扫描时应检查 root 和服务用户的历史记录。 - 完整深度检查(转储搜寻、CI 工件、镜像层)→ HSECRET
skills/code/secrets-data-exposure/SKILL.md。
查找位置
证据收集:tools/sweeps/sweep-api-tokens.sh 会逐字捕获 [TOK-nn] 部分;请依据本模块的评估标准判断,而非仅依据原始输出。
| 证据 | 位置 | 访问权限 |
|---|---|---|
| 令牌生成 + 身份验证中间件源代码 | 仓库:auth/、middleware/、security/ 目录;搜索 Bearer、X-API-Key | 读取 |
| 架构事实 | 应用数据库 information_schema.columns(而非会发生漂移的迁移文件)[APP-DB] | 读取凭证 |
| 令牌行(仅元数据) | 等效于 api_tokens 的表:前缀、范围、过期时间、最后使用时间 [APP-DB] | 读取凭证 |
| 边缘配置 | /etc/nginx/nginx.conf、/etc/nginx/conf.d/*.conf、Caddy/HAProxy 等效配置 | 读取 |
| 服务环境 | /etc/systemd/system/*.service(Environment=、EnvironmentFile=)、systemctl show <unit> -p Environment | 读取;对于 root 单元,执行 show 需 [ROOT] |
| 应用配置 | /opt、/srv、应用主目录下的 .env*、application.yml、settings.py、config/*.json | 读取 |
| 进程环境(同一用户) | /proc/<app-pid>/environ | 同一 UID 用户或 [ROOT] |
| 日志 | /var/log/nginx/access.log*、应用日志目录、该服务的 journalctl | 读取 [ROOT] |
| Shell 历史记录 | /root/.bash_history、/home/*/.bash_history | 读取 [ROOT] |
可直接粘贴的只读扫描(约 15 行):
# ===== [READ-ONLY SWEEP] token-bearing configs, env, logs on one host =====
sudo grep -RInE '(api[-_]?(key|token)|bearer|access[-_]?token|auth[-_]?token|secret)[-=_ ]+["'"'"']?[A-Za-z0-9_.+-]{16,}' \
/etc/systemd/system /opt /srv /var/www 2>/dev/null | head -40
sudo grep -RHnE '^Environment=.*(TOKEN|KEY|SECRET|PASSWORD)' /etc/systemd/system 2>/dev/null | head -20
sudo find /opt /srv /etc /home -maxdepth 5 \( -name '.env' -o -name '.env.*' \) -exec stat -c '%a %U:%G %n' {} + 2>/dev/null
sudo ls -la /root/.bash_history /home/*/.bash_history 2>/dev/null
sudo grep -RhoE '(API_TOKEN|API_KEY|SECRET)[A-Z_]*=\S{8,}' /root/.bash_history /home/*/.bash_history 2>/dev/null | head
# --- full tokens hitting disk in logs? (counts per file) ---
sudo grep -REo 'Bearer [A-Za-z0-9_.=-]{20,}' /var/log/nginx /var/log/apache2 /var/log/apps 2>/dev/null | cut -d: -f1 | sort | uniq -c | head
sudo zgrep -hcoE '[?&](access_token|api_key|apikey|token)=[A-Za-z0-9_-]{8,}' /var/log/nginx/access.log* 2>/dev/null | paste -sd+ | bc
# --- DB schema introspection [REQUIRES APP-DB READ CREDS - request from owner; skip if unavailable] ---
# psql "$APP_DB_URL" -c "SELECT table_name,column_name,data_type FROM information_schema.columns WHERE column_name ~* '(token|api_key|secret)';"
# mysql -u"$DBUSER" -p"$DBPASS" -e "SELECT TABLE_NAME,COLUMN_NAME FROM information_schema.COLUMNS WHERE COLUMN_NAME REGEXP 'token|api_key|secret';"
模式与特征
规范存储架构
-- FIXED: authoritative API-token table (PostgreSQL dialect)
CREATE TABLE api_tokens (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
client_id BIGINT NOT NULL REFERENCES clients(id),
token_prefix VARCHAR(15) NOT NULL, -- e.g. 'sk_live_Xk29fQ' : display/log-safe identifier
token_hash BYTEA NOT NULL, -- SHA-256(raw token); raw value is NEVER persisted
scopes TEXT[] NOT NULL DEFAULT '{}',-- e.g. '{read:invoices,write:invoices}'
tenant_id BIGINT REFERENCES tenants(id), -- NOT NULL wherever objects are tenant-owned
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ, -- NULL only with documented compensating controls
last_used_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
rotated_from_id BIGINT REFERENCES api_tokens(id)
);
CREATE UNIQUE INDEX ux_api_tokens_token_hash ON api_tokens (token_hash); -- O(1) lookup by digest
CREATE INDEX ix_api_tokens_client ON api_tokens (client_id);
CREATE INDEX ix_api_tokens_prefix ON api_tokens (token_prefix);
前缀列技巧:唯一索引位于 token_hash 上,因此验证始终精确且无碰撞;token_prefix 仅供人工查看——用于支持工单(“我的密钥以 sk_live_Xk29… 开头”)、日志关联和撤销排查——而不会存储足以用于身份验证的材料。
令牌签发
// FIXED: 256-bit CSPRNG body + type prefix + integrity checksum (Stripe-style shape)
const crypto = require('crypto');
function mintToken(env /* 'live' | 'test' */) {
const body = crypto.randomBytes(32).toString('base64url'); // 256 bits
const raw = `sk_${env}_${body}`;
const checksum = crypto.createHash('sha256').update(raw).digest('base64url').slice(0, 8);
return { raw: `${raw}_${checksum}`, prefix: raw.slice(0, 12) }; // return raw ONCE; persist hash+prefix
}
-- VULNERABLE: any of these shapes in a mint/storage path is a finding
-- CREATE TABLE api_tokens (id ..., token TEXT NOT NULL, client_id ...); -- plaintext column
-- token = md5(user_id || timestamp) -- derivable base
-- SET token = SUBSTRING(MD5(raw), 1, 16) -- truncated digest
身份验证中间件 — Node/Express
// VULNERABLE: plaintext lookup, == compare, immortal cache, no scope check
const tok = await db.query('SELECT * FROM api_tokens WHERE token = $1',
[req.headers.authorization.slice(7)]);
if (tok && tok.rows[0].token === req.headers.authorization.slice(7)) { req.client = tok.rows[0]; next(); }
// FIXED: hash-lookup flow + constant-time verify + revocation-aware cache
const crypto = require('crypto');
const authCache = new Map(); // digestHex -> {row|null, validUntil}
const CACHE_TTL_MS = 60_000; // bounds revocation lag at <=60 s
async function authenticate(req, res, next) {
const m = /^Bearer ([A-Za-z0-9_-]{40,})$/.exec(req.get('authorization') || '');
if (!m) return res.status(401).set('WWW-Authenticate', 'Bearer').end();
const presented = m[1];
const digest = crypto.createHash('sha256').update(presented).digest(); // Buffer(32)
let entry = authCache.get(digest.toString('hex'));
if (!entry || entry.validUntil < Date.now()) {
const r = await db.query(
'SELECT * FROM api_tokens WHERE token_hash = $1 AND revoked_at IS NULL', [digest]);
entry = { row: r.rowCount ? r.rows[0] : null, validUntil: Date.now() + CACHE_TTL_MS };
authCache.set(digest.toString('hex'), entry);
}
const tok = entry.row;
if (!tok || tok.revoked_at !== null ||
(tok.expires_at && new Date(tok.expires_at) < new Date())) {
return res.status(401).set('WWW-Authenticate', 'Bearer error="invalid_token"').end();
}
if (!crypto.timingSafeEqual(digest, Buffer.from(tok.token_hash))) { // constant-time end-to-end
return res.status(401).end();
}
req.auth = { tokenId: tok.id, clientId: tok.client_id, scopes: tok.scopes, tenantId: tok.tenant_id };
next();
}
function requireScope(s) {
return (req, res, next) =>
(req.auth && req.auth.scopes.includes(s)) ? next() : res.status(403).json({ error: 'insufficient_scope' });
}
// On revoke: authCache.delete(sha256Hex(revokedToken)); redis.publish('token_events', JSON.stringify({op:'revoke', id}));
身份验证依赖项 — Python/FastAPI(Django DRF:流程相同,使用返回 (user, auth) 的 BaseAuthentication.authenticate())
# VULNERABLE: raw-value ORM filter + != None checks leaking state via exception type
def get_client(request):
raw = request.query_params.get("token", "") # query-string carriage too
t = ApiToken.objects.filter(value=raw).first() # plaintext column
if t is None:
raise NotAuthenticated("unknown token") # distinct error = enumeration oracle
return t
# FIXED: FastAPI dependency — hash lookup, constant-time compare, bounded cache
import hashlib, hmac, time
from fastapi import Request, HTTPException, status
_CACHE: dict[str, tuple[object, float]] = {}
_TTL = 60.0 # seconds; bounds revocation lag
async def bearer_auth(request: Request) -> AuthContext:
authz = request.headers.get("authorization", "")
if not authz.startswith("Bearer ") or len(authz) < 40:
raise HTTPException(status.HTTP_401_UNAUTHORIZED,
headers={"WWW-Authenticate": 'Bearer error="invalid_token"'})
presented = authz[7:].encode()
digest = hashlib.sha256(presented).digest()
now = time.monotonic()
hit = _CACHE.get(digest.hex())
if hit is None or hit[1] < now:
row = await db.fetch_row(
"SELECT * FROM api_tokens WHERE token_hash = $1 AND revoked_at IS NULL", digest)
hit = (row, now + _TTL)
_CACHE[digest.hex()] = hit
tok = hit[0]
if tok is None or tok["revoked_at"] is not None or (
tok["expires_at"] is not None and tok["expires_at"] <= now_utc()):
raise HTTPException(status.HTTP_401_UNAUTHORIZED) # identical body/timing shape for all causes
if not hmac.compare_digest(digest, bytes(tok["token_hash"])):
raise HTTPException(status.HTTP_401_UNAUTHORIZED)
return AuthContext(token_id=tok["id"], client_id=tok["client_id"],
scopes=tok["scopes"], tenant_id=tok["tenant_id"])
代码特征速查表(仓库搜索)
| 特征 | 含义 |
|---|---|
req.query.token, request.query_params["api_key"], parse_str($_GET['access_token']) | 发现通过查询字符串传输 |
针对非 _hash 列使用 WHERE token = ? | 明文存储路径 |
token === provided, == raw, 对字符串调用 .equals( | 非恒定时间比较 |
在令牌/密钥生成附近使用 Math.random(...) | 使用非 CSPRNG 签发 |
cache.set('auth:' + token, ...),但撤销代码中没有对应的删除操作 | 撤销缺口 |
logger.info(...authorization...), console.log(headers) | 记录完整令牌 |
旧版客户端强制使用查询字符串令牌时的日志脱敏模式
# MITIGATION (not cure): drop the query string from logged request lines entirely
log_format safe '$remote_addr [$time_local] "$request_method $uri $server_protocol" '
'$status $body_bytes_sent "$http_referer" "$http_user_agent"';
access_log /var/log/nginx/access.log safe;
# App-side belt-and-suspenders: redact before any logger sink
class TokenRedactor(logging.Filter):
_PAT = re.compile(r"(access_token|api_key|apikey)=([A-Za-z0-9_-]{4})[A-Za-z0-9_-]+")
def filter(self, record):
record.msg = self._PAT.sub(r"\1=\2***redacted***", str(record.getMessage()))
return True
污点追踪指南
审计仓库/配置时,将令牌从源头追踪到每个汇点,并对每一跳的表示形式进行分类。
源头(原始令牌进入系统的位置):
req.headers.authorization/request.headers["Authorization"]/request.META["HTTP_AUTHORIZATION"]— 预期来源。X-API-Key或任何自定义凭据标头。req.query.token,searchParams.get("access_token"), Flaskrequest.args, Djangorequest.GET, PHP$_GET[...]— 源头本身就存在通过查询字符串传输的问题。- 携带 API 令牌的 Cookie(参见 WEB CSRF)。
- 运维侧:CLI 标志(
--token=...)、shell 导出变量、打印到构建日志中的 CI 变量。
汇点(原始令牌绝不能到达的位置):
- 持久化:ORM
create()/update()、原始 INSERT/UPDATE、迁移默认值将可用于身份验证的值写入任何非*_hash列 → 严重级别汇点。 - 日志:任何接收标头的日志记录器调用、请求转储(
JSON.stringify(req.headers))、附带身份验证上下文的异常对象 → 高风险汇点。 - 响应/模板:在错误消息或调试页面中回显“你发送的令牌” → 枚举/材料泄露汇点。
- 出站传播:为重定向/分析构建的 URL 中包含令牌;在客户端代码中使用令牌初始化第三方 SDK。
- 配置/仓库:任何位置提交的字面值(参见 SECRETS)。
流程:
- 找出身份验证入口点(中间件/依赖项/身份验证类)——这是判断表示形式的事实依据。
- 向前追踪令牌变量:将每次赋值分类为 RAW 或 DIGEST。只要有一跳使 RAW 到达持久化或日志,就是一项发现;在报告中注明文件名:行号。
- 反向追踪签发过程:找到生成器调用;确认其使用 CSPRNG 且长度足够;标记可推导的输入。
- 检查路径上的比较操作:根据定义,字符串相等运算符不是恒定时间的。
- 检查缓存写入使用的是 RAW 还是 DIGEST 作为键,以及撤销操作是否会删除相应条目。
- 对 Webhook 签名密钥重复步骤 1–5(生命周期相同)。
利用与复现
所有演示均为只读操作。切勿在报告中粘贴发现的令牌值——使用前缀 + ID 进行引用。
(a) 通过架构转储证明明文存储。
-- [APP-DB] read-only
SELECT table_name, column_name, data_type
FROM information_schema.columns
WHERE column_name ~* '(token|api_key)' ORDER BY 1;
-- Finding shape: a column like api_tokens.token TEXT with NO sibling *_hash column,
-- while application code filters WHERE token = <presented value>.
SELECT length(token) AS len, count(*) FROM api_tokens GROUP BY 1 ORDER BY 2 DESC LIMIT 5;
无需读取具体值即可解读:长度集中在一个统一值,且与文档记载的格式相符(例如,所有行的 len=51),这表明实际生成的凭据以完整形式存储;如果存储的只是哈希值,则会看到名为 _hash、固定为 bytea / 32 字节的列。报告说明:攻击者只要拥有任意数据库读取能力(SQLi、备份暴露、副本配置错误),就能通过一次查询获取所有客户的有效凭据——无需破解,因为根本没有进行哈希处理。这就是该模块的关键严重性依据。
(b) 通过访问日志 grep 证明凭据经查询字符串传输。
sudo zgrep -hcE '[?&](access_token|api_key|apikey|token)=[A-Za-z0-9_-]{8,}' /var/log/nginx/access.log* | paste -sd+ | bc
sudo zgrep -hoE '^[^ ]+ [^ ]*\?[a-z_]*token=[A-Za-z0-9_-]{6}' /var/log/nginx/access.log | sort | uniq -c | head # prefix only!
计数大于 0 即为发现;报告中应写明计数和日期范围(“30 天内有 1,204 个请求在 URL 中携带凭据”),并说明后续影响范围:这些值也会存在于日志传输管道、SIEM 保留数据以及 /var/log 的任何备份中。当浏览器(而非 curl 客户端)使用此类 URL 时,Referer 泄漏会进一步加剧影响。
(c) 通过读取缓存代码路径证明撤销缺口。
阅读中间件(模式部分展示了两种结构)。记录:缓存层、键、TTL 常量,以及撤销处理程序中是否存在驱逐操作。使用 Redis 时,可在主机上通过只读命令进行佐证:
redis-cli --scan --pattern '*auth*' | head # key naming shows token-derived keys exist
redis-cli TTL "$(redis-cli --scan --pattern '*auth*' | head -1)" # remaining validity of a cached entry
报告说明:运维人员在 T+0 撤销被盗令牌;在 TTL 到期前,攻击者仍拥有完整访问权限(注明测得的时间,例如“3600 s”)。如果应用运行 N 个副本,且每个副本都使用进程内缓存,那么撤销传播速度仅取决于它们的 TTL——应据此相应延长时间窗口。这会将“已撤销”(事件已关闭)转变为“撤销延迟 = 数小时”(高严重性发现)。
(d) 附计算过程的暴力破解说明。
针对 API 的在线猜测攻击无需立足点——只需要耐心和缺失的速率限制。密钥空间决定一切:
8-char alphanumeric token (62-symbol alphabet):
keyspace = 62^8 = 218,340,105,858,848 ≈ 2.18e14 ≈ 2^47.6
offline : sha256 @ ~1e10 H/s (one GPU rig) -> 2.18e14 / 1e10 ≈ 21,834 s ≈ 6 h DEAD
online : @ 1,000 req/s, unthrottled -> ≈ 6,927 years alive-but-noisy
verdict : fails the 128-bit floor by a factor of ~2^80; dead the moment any hash
database leaks, and trivially scannable even in plaintext dumps.
UUIDv4 token:
keyspace = 2^122 ≈ 5.3e36
offline @ 1e10 H/s -> ≈ 1.7e19 years -> entropy is fine; borderline flag is about format/handling.
32-byte CSPRNG token (the floor case):
keyspace >= 2^128 ≈ 3.4e38; @ 1e12 H/s -> 3.4e26 s ≈ 1.1e19 years ALIVE
报告模板:“测得令牌熵约为 47.6 位,低于 128 位下限;结合缺少逐密钥限流(检查 7),攻击者可按现实速率持续在线猜测;一旦任何存储泄露,每个令牌只需约 6 GPU 小时即可离线还原。”随后应修复设计,而不只是添加速率限制。
修复
迁移计划:明文令牌 → 哈希令牌(零停机)
- 添加列(
token_hash BYTEA、token_prefix VARCHAR(15),以及缺失时的expires_at、last_used_at、revoked_at)。暂不创建唯一索引。 - 部署双重校验认证(v1): 中间件先将
SHA-256(presented)与token_hash比对;未命中时回退到旧的明文列;命中旧列时,立即写入token_hash并清空明文列(在使用时渐进式回填——大多数活跃密钥会在数小时内完成转换)。 - 为闲置密钥运行回填任务,分批处理以避免锁风暴:
-- run in batches of ~10k until 0 rows updated
UPDATE api_tokens
SET token_hash = sha256(convert_to(token, 'UTF8')), token_prefix = left(token, 12), token = ''
WHERE token <> '' AND token_hash IS NULL;
- 验证:
SELECT count(*) FROM api_tokens WHERE token <> '' OR token_hash IS NULL;必须为 0。若不为 0,则重新运行步骤 3。 - 部署仅哈希认证(v2): 从中间件中移除明文回退路径。
- 在维护窗口中删除明文列; 对任何无法承受此次转换的令牌(谨慎型客户端),按照下方轮换操作手册进行轮换。
轮换操作手册(零停机重叠窗口)
- 创建具有相同作用域和租户绑定的新令牌;记录
rotated_from_id。 - 通过带外方式将其交付给客户端(绝不通过电子邮件或工单);仅显示一次。
- 开启重叠窗口(例如 7 天):新旧令牌均可通过验证。实现方式:暂不撤销旧令牌;两条记录分别进行身份验证。
- 监控
old.last_used_at:在窗口结束前联系仍在使用旧密钥的集成。 - 窗口结束时,撤销旧令牌(
revoked_at = now());保留该记录以供审计;确认只有已联系过的遗留集成出现 401。 - 保留期结束后,按照策略清理已撤销或过期的记录。
最需要提醒的操作失误:在替换令牌启用并确认可用之前就撤销旧令牌——这会让「轮换」变成一次服务中断。重叠窗口正是为避免这种情况而设。
速率限制配置形式
// FIXED: express-rate-limit — IP gate BEFORE auth, per-key quota AFTER auth
const { rateLimit } = require('express-rate-limit');
const ipLimiter = rateLimit({ windowMs: 60_000, max: 20, standardHeaders: 'draft-7' }); // unauthenticated surface
app.use('/api', ipLimiter);
app.use(authenticate);
const keyLimiter = rateLimit({
windowMs: 60_000,
max: (req) => req.auth.quotaPerMin ?? 600, // sustained quota per key
keyGenerator: (req) => String(req.auth.tokenId), // stable per-client identity, NOT the raw token
standardHeaders: 'draft-7',
});
app.use('/api', keyLimiter);
# Edge complement — honest caveat: limit_req keyed on $http_authorization works mechanically,
# but nginx then stores RAW tokens as shared-memory keys (readable via debug/core dumps) and the
# key changes if clients vary header casing/padding. Prefer app-layer limiting keyed on token id;
# use the edge zone only as a coarse backstop, and always pair with an IP zone for junk traffic.
limit_req_zone $http_authorization zone=perkey_backstop:10m rate=600r/m;
limit_req_zone $binary_remote_addr zone=perip:10m rate=30r/m;
limit_req zone=perip burst=20 nodelay;
应强制执行的语义:超限 → 返回 429 和 Retry-After: <seconds>;突发限额(令牌桶)应足以容纳合理的重试;持续限额用于保护后端;未认证端点应使用单独且更严格的限额桶(参见 API)。
撤销缓存失效模式
选择一种方案,并在设计文档中说明其权衡:
- **事件失效(首选):**撤销处理程序在本地删除缓存,并通过 Redis pub/sub / Postgres
LISTEN-NOTIFY/ 队列发布token_events {op:'revoke', id};每个工作进程收到事件后清除缓存。撤销延迟约为毫秒级;代价是需要搭建订阅机制。 - **短 TTL 上限(可接受的后备方案):**缓存 TTL 硬编码为不超过 60 s,并记录在文档中。明确说明其权衡:任何已撤销的令牌最长仍可使用 60 s——只有在攻击者可利用的时间窗口较短且有上限时才可接受;绝不可与长 TTL 的身份验证决策 CDN 缓存搭配使用。
无论采用哪种方案,撤销代码路径都必须触及 Check 6 中确定的每一层缓存——在事件发生时写下清单。
验证与确认
修复后验证清单:
- 架构显示
*_hash(BYTEA,32 bytes)并带有 UNIQUE 索引;不再存在可用于身份验证的值列:重新运行 Check 2 中的内省查询。 - 日志中仅包含前缀:对新流量运行扫描块中的 Bearer 正则表达式,命中数为零;抽查应用日志,确认没有转储请求头。
- 身份验证流程:有效令牌可通过身份验证(正向测试);格式正确但错误的令牌返回统一的 401,其响应正文和大小与过期或已撤销的响应相同(负向测试);已撤销的令牌在文档说明的时限内返回 401(撤销测试令牌后使用该令牌轮询,测量延迟)。
- 粗略的计时一致性测试(需注意其局限):
for i in $(seq 1 30); do curl -s -o /dev/null -w '%{time_total}\n' \
-H 'Authorization: Bearer AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' https://api.example.com/v1/me; done \
| sort -n | sed -n '1p;$p'
将最小值与最大值之间的差异,与使用有效令牌运行同一循环时的结果进行比较。应如实看待测试结果:网络抖动远大于纳秒级的 memcmp 时间差;此测试用于捕捉明显的侧信道(例如数据库未命中与命中路径相差几十毫秒),而非细微差异。不存在明显的时间差异即为通过;细微差异应记录,但不判为失败。
- 作用域负向测试:只读令牌访问写入路由时返回 403(而非 404/500);租户 A 的令牌请求租户 B 的资源时,始终返回 403/404。
- 回归说明:启用单密钥限额后,确认合法的批处理作业不会因频繁触发 429 而受阻(监控 Retry-After 的遵循情况);调整 TTL/缓存后,确认身份验证 p95 延迟未变化。
CI/配置仓库中的 grep(命中即构建失败):
! git grep -InE "(Math\.random|random\.random)\(\).*([Tt]oken|[Kk]ey)" # non-CSPRNG minting
! git grep -InE "req\.(query|query_params)\.(get\()?['\"](access_token|api_key|token)['\"]"
! git grep -InE "(authorization|Bearer).*(logger\.|console\.|print\()" # full-token logging
git grep -qIn "timingSafeEqual\|compare_digest" -- server/ src/ # constant-time compare present
严重性评估
| 发现 | 锚点 | CVSS v3.1 向量 | 分数/说明 |
|---|---|---|---|
| 数据库中存储明文/可逆令牌,且数据库为互联网 API 提供服务 | 严重 | AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H | 9.8。PR:N 假设可与数据库读取原语(SQLi、备份泄露)串联;在审计证明并非如此之前,应按存在此类原语处理。如果确实不存在读取原语,应如实重新评分为 AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:N = 6.8,并保留此发现 |
| 无过期机制且无撤销路径(或撤销延迟不受缓存失效限制) | 高 | AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N | 8.1。任何一次泄露都是永久性的;PR:L 表示持有一把长期有效密钥的人 |
| 日志中记录完整令牌(访问日志/应用日志/SIEM) | 高 | AV:A/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N | 7.3。日志平面读取权限按相邻网络模型评估 |
| 互联网端点通过查询字符串传输令牌 | 高 | AV:A/AC:L/PR:N/UI:R/S:U/C:H/I:L/A:N | 7.0。可通过日志/Referer/历史记录被动捕获 |
| 令牌熵低(<128 位)或随机数生成非 CSPRNG | 高 | 根据上下文评估:可离线恢复 → 按存储方面的严重问题链评分;可在线猜测且未限速 → AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N | 未限速时为 8.1 |
| 多个集成共用万能令牌/无作用域限制 | 高 | 权限范围过宽的问题;最接近的锚点为:当管理员作用域共用同一密钥时,AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N = 8.1 | 明确报告权限范围过宽的问题 |
| 无单密钥速率限制/缺少 429 语义 | 中 | AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:L/A:N | 4.8 |
| 缺少最近使用时间跟踪/陈旧密钥清理机制 | 中 | 定性评估——检测控制缺失;CVSS 无法体现暴露时长延长 | 按策略评为中:这会悄然延长其他所有缺陷的潜伏时间 |
| 仅使用 uuid4 令牌;令牌中包含可推导的元数据 | 临界/低至中 | 由熵计算决定(122 位可抵御暴力破解;格式/处理问题仍需标记) | 标记即可,不要误报为严重 |
| 令牌格式中没有类型前缀/校验和 | 低 | 定性评估——运维识别/可扫描性问题;不会直接影响机密性 | 按策略评为低 |
常见误报
- 以 mTLS 证书身份验证取代令牌。 客户端证书属于不同的身份验证器模型;不要要求使用令牌哈希/前缀。仍需核实类似的生命周期机制:能够在发生泄露时实际缩短信任有效期的 CRL/OCSP 路径、证书轮换操作手册,以及将 CN/SAN 映射到作用域的机制。如果运行时无法访问 CRL/OCSP,那才是真正的问题。
- 位于 VPN/网格后方的仅限内部 API。 令牌问题依然存在,但对于依赖暴露面的锚点(传输、枚举、速率限制),严重性应下调:记录网络边界这一补偿控制;对于存储问题,最多下调一个级别——明文至少仍为高,因为内部人员和备份都可能造成泄露。
- 被误认为 API 令牌的不透明会话 Cookie。 浏览器会话 Cookie(
connect.sid、sessionid、框架默认值)属于 Web 会话模型,应在 AUTHN/WEB 下审计(skills/code/authn-session/SKILL.md、skills/code/web-client/SKILL.md),而不是在此处审计。不要针对以随机 Cookie ID 为键的服务端会话存储提交“令牌以明文存储”的问题。 - 被误认为不透明令牌的 JWT。 如果“API 令牌”在结构上是 JWT(三段以点分隔),其风险属于签名/验证问题,应由 AUTHN 审计(
skills/code/authn-session/SKILL.md);从本模块仅沿用传输/作用域/日志检查。 - 静态加密的令牌列。 可逆加密是一项缓解措施声明,并不代表没有问题:在接受将严重性下调至低于高之前,先核实密钥的保管情况(参见检查 2 的判定规则)。
参考资料
- RFC 6750 — OAuth 2.0 授权框架:Bearer 令牌用法(传输规则、
WWW-Authenticate错误语义):https://www.rfc-editor.org/rfc/rfc6750 - NIST SP 800-63B — 数字身份指南:身份验证和生命周期管理(身份验证器熵、存储、验证方要求):https://pages.nist.gov/800-63-3/sp800-63b.html
- OWASP 身份验证备忘单:https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
- OWASP REST 安全备忘单(令牌传递、仅限 TLS、身份验证决策缓存):https://cheatsheetseries.owasp.org/cheatsheets/REST_Security_Cheat_Sheet.html
- OWASP 密钥管理备忘单(分发、轮换、Webhook 签名密钥):https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html
- OWASP API 安全项目(A02 身份验证失效类别背景):https://owasp.org/www-project-api-security/
- CWE-522 凭据保护不足:https://cwe.mitre.org/data/definitions/522.html
- CWE-307 对过多身份验证尝试的限制不当:https://cwe.mitre.org/data/definitions/307.html
内部交叉引用:JWT/会话深入解析 → skills/code/authn-session/SKILL.md;Cookie 携带凭据带来的 CSRF 后果 → skills/code/web-client/SKILL.md;API 范围内的滥用防护措施 → skills/code/api-security/SKILL.md;主机端密钥扩散 → skills/code/secrets-data-exposure/SKILL.md;边缘 TLS 强制措施 → skills/server/tls-proxy/SKILL.md;流量阈值 → skills/code/denial-of-service/SKILL.md。