Webhook 允许租户让服务器向外部 URL 发请求,因此本质上是一条受用户配置影响的出站网络通道。只校验 https:// 远远不够:DNS、重定向、代理、端口、云元数据和多租户公平性都属于安全边界。
1. 生产配置与 fail-closed
Application 组合器默认注册:
FailClosedWebhookIpAllowlistPolicy;FailClosedWebhookRateLimitPolicy;DeliveryLogWebhookDeadLetterQueue。
只有 Webhook:Delivery:Enabled=true 时,Infrastructure 才追加生产 CIDR 与 Redis policy。若最终解析到 fail-closed 实现,所有 delivery 都会在 Guard 阶段变成 DeadLettered,不会静默放行。
{ "Webhook": { "Delivery": { "Enabled": true, "RotationOverlapSeconds": 300, "IpAllowlist": { "Enabled": true, "RequireSubscriptionAllowlist": true }, "RateLimit": { "Enabled": true, "PermitLimit": 60, "WindowSeconds": 60, "KeyPrefix": "bitzorcas:webhooks:delivery" } } }}KeyPrefix 没有 [ConfigKey] 不影响 options binding,但配置目录自动发现可能不会展示它。Redis 连接并不在该 section 内,由宿主统一注册 IConnectionMultiplexer。
2. 健康检查实际检查什么
webhook-delivery-production 带 ready/dependency/webhook tags。它要求:
- Delivery.Enabled=true;
- IpAllowlist.Enabled=true;
- RateLimit.Enabled=true;
- PermitLimit 与 WindowSeconds 是正整数;
- 注入的 Redis multiplexer 已连接。
它不检查 DNS、外网 egress、证书信任、DataProtection key ring、CAP consumer、数据库、HTTP timeout、事件 topic 对齐或实际目标端。因此 Healthy 只表示“生产 policy 基础配置可用”,不是端到端投递成功。
# readiness 只验证依赖基线,不能替代 synthetic delivery。curl --fail --silent https://api.example/health/ready | jq .
# 还应从 Operations adapter report 确认不是 fail-closed/null/in-memory 实现。curl --fail --silent \ -H "Authorization: Bearer $OPS_TOKEN" \ https://api.example/api/operations/adapters | jq '.[] | select(.name|test("Webhook"))'3. CIDR policy 的精确算法
虽然方法 XML 曾写“至少一个解析地址允许”,实现实际更严格:找到任何不在 allowlist 的地址就拒绝。IPv4-mapped IPv6 会归一为 IPv4;不写前缀等于单 IP /32 或 /128。
4. CIDR 不等于完整 SSRF 防护
当前检查与发送分离:
- Guard 用
Dns.GetHostAddressesAsync得到 A/AAAA; - 判断全部地址在 CIDR;
DefaultWebhookHttpClient用普通 HttpClient 对同一 hostname 发请求;- transport 可能重新 DNS 解析;
- handler 默认可能跟随 30x 到另一个 URL。
攻击者可利用低 TTL/DNS rebinding 在检查后改变解析结果;目标也可 302 到 loopback、link-local 或云 metadata。代理环境还可能让连接目的与本地 DNS 结果不同。
5. 推荐的 URL 安全策略
除 HTTPS 外至少明确:
- 禁止 userinfo、fragment 和非允许端口;
- 规范化 IDN/Punycode 后再匹配域名策略;
- 默认拒绝 loopback、unspecified、multicast、link-local、RFC1918/ULA 与云 metadata;
- 若业务必须访问私网,使用管理员批准的 egress profile,不让租户任意填 CIDR;
- 限制 DNS answer 数量与解析超时;
- 禁止 redirect,或逐跳验证且限制跳数;
- 禁止自动代理或只允许平台受控代理;
- 每次连接记录最终远端 IP、TLS protocol、证书摘要和目标 profile,而不是完整 payload。
订阅 IpAllowlist 由租户提交:允许租户自己把 127.0.0.1/32 加入列表,无法保护平台内网。平台还需要不可被租户覆盖的 global deny ranges。
6. HttpClient 当前缺少的边界
DefaultWebhookHttpClient:
- 使用 DI 提供的通用 HttpClient;
- 构造 POST + UTF-8
application/json; - 原样追加签名头;
SendAsync后只读取 status code;- 把 HttpRequestException 和非调用方取消的 TaskCanceledException 归为 network failure。
源码没有模块专用 handler、timeout、connect timeout、最大连接数、response headers read mode、redirect policy、proxy policy、TLS 最低版本、证书策略或 payload size。通用 AddHttpClient() 的默认 timeout 通常很长,不适合在 CAP consumer 内同步等待大量慢目标。
// Webhook 使用独立连接池和短超时,不继承宿主的通用 HttpClient 行为。services.AddHttpClient<IWebhookHttpClient, DefaultWebhookHttpClient>(client =>{ client.Timeout = TimeSpan.FromSeconds(15); client.DefaultRequestHeaders.UserAgent.ParseAdd("BitzOrcas-Webhooks/1");}).ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler{ // redirect 必须回到 URL/CIDR policy 逐跳判断,不能由 handler 自动跟随。 AllowAutoRedirect = false, // 30x 交给策略显式判断。 UseProxy = false, // 或只配置受控 egress proxy。 ConnectTimeout = TimeSpan.FromSeconds(3), MaxConnectionsPerServer = 20, PooledConnectionLifetime = TimeSpan.FromMinutes(2)});示例仍未解决 DNS pinning;需要自定义 ConnectCallback 或 egress gateway,并正确处理 TLS SNI。
7. Redis 固定窗口限流
Redis Lua 脚本对一个 key 原子 INCR,第一次写时 PEXPIRE(window)。Key:
{prefix}:{tenantId}:{clientId}:{subscriptionId}:{eventType}:{bucket}bucket = unixSeconds / windowSeconds每个订阅/事件独立计数,默认 60/60s。多个 API 实例共享状态。计数超过 permit 后仍继续 INCR,直到 bucket 过期。Redis 不可用、未连接、脚本异常或配置关闭都会 fail closed。
固定窗口在边界可产生近 2× 瞬时突发;key 中包含 raw tenant/client/subscription/event 字符串,若这些值含 : 不影响唯一性安全但降低可读性,也没有 hash/tag 方案用于 Redis Cluster slot 控制。
8. 限流发生得太晚的成本
服务先创建 delivery row、计算签名,再执行 rate policy。被限流的事件被直接标 DeadLettered,而不是安排到窗口后重试。突发业务量会制造大量人工死信,限流成为丢失/运维放大机制。
更合理的策略是 tenant ingress quota + 持久化队列 + 公平 scheduler。限流决定 NextAttemptAt,不应立即把正常峰值当终态死信。还应有全局、租户、目标 host 和 subscription 多级并发限制,防止一个慢目标耗尽连接池。
9. 生产指标与日志
最低指标:
| 指标 | 维度建议 |
|---|---|
webhook_delivery_total | tenant tier、event、status class;不带 URL |
webhook_delivery_duration_seconds | event、result class |
webhook_guard_denied_total | tenant、reason=tenant/client/scope/ip/rate |
webhook_retry_scheduled_total | event、attempt |
webhook_dead_letter_age_seconds | tenant tier、event |
webhook_dns_resolution_seconds | resolver/result |
webhook_inflight | host pool/tier |
webhook_secret_unprotect_failure_total | key ring/version;绝不记录材料 |
当前 CIDR 与 Redis policy 已写结构化日志,包括 TenantId、ClientId、SubscriptionId、EventType 和解析地址/配额。ResolvedAddresses 可能暴露客户网络拓扑,日志访问与保留要分级。不要把 TargetUrl query、payload、secret 或完整签名加入日志。
10. 发布与故障 Runbook
发布前:
- readiness 健康,Redis 多实例共享测试通过;
- DataProtection key ring 持久化、共享、备份并完成恢复演练;
- egress firewall/proxy policy 与订阅 CIDR 双层生效;
- 专用 HttpClient timeout/redirect/proxy 已固定;
- CAP topic consumer contract 通过;
- 测试订阅完成 challenge,不发送真实业务副作用;
- dead-letter 查询、重放、暂停、轮换权限与审计已演练。
Redis 故障:当前所有新投递进入 DeadLettered。先恢复 Redis,评估死信规模,再按 tenant/事件分批重放,避免恢复风暴。DNS/证书异常时先暂停受影响订阅,不要全局关闭 allowlist。密钥环丢失时禁止把 Unprotect fallback 当恢复方案,应从备份恢复 key ring 或与客户重新轮换。
11. 必测网络矩阵
- IPv4/IPv6/mapped IPv6、多个 A/AAAA 中一个越界;
- 解析空、NXDOMAIN、timeout、answer 变化和 DNS rebinding;
- redirect 到公有、私有、loopback、metadata、HTTP;
- userinfo、IDN、fragment、端口、超长 URL;
- 受控 proxy 与绕过 proxy;
- TLS 过期、hostname mismatch、unknown CA、握手 timeout;
- Redis 未注入、断线、脚本异常、窗口边界、跨实例;
- 每租户公平性、慢目标连接耗尽和恢复突发;
- readiness 只在全部必要 adapter 就绪时通过;
- 日志/指标无高基数 URL、payload 或密钥泄露。
12. 审查命令
# 先确认组合根最终没有解析到 fail-closed 开发 adapter。# 检查生产 adapter 是否仍为 fail closed,以及健康检查覆盖项。rg -n "FailClosedWebhook|AddBitzOrcasProductionWebhookDelivery|HealthCheck" \ src/Platform/Webhooks src/Hosts -g '*.cs'
# 暴露 DNS 检查与实际发送的分离、redirect 配置缺失。rg -n "GetHostAddressesAsync|SendAsync|AllowAutoRedirect|ConnectCallback" \ src/Platform/Webhooks src/Hosts -g '*.cs'
# Redis key 和 Lua 改动必须保持跨实例 contract test。# 跨实例 Redis contract 是固定窗口全局一致性的关键证据。rg -n "IncrementScript|BuildKey|ScriptEvaluateAsync" \ src/Platform/Webhooks tests -g '*Webhook*.cs'