Skip to content
bitzorcas
中EN

Guide

Webhooks 网络安全、限流与生产装配

深入说明 HTTPS URL、CIDR 目标白名单、DNS/重定向 SSRF 边界、Redis 分布式限流、fail-closed 注册、健康检查、HttpClient 配置与生产 Runbook。

Last updated

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
# 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 的精确算法

noyesnoyesnoyesno/erroryesyesno

Delivery + IP policy enabled?

subscription allowlist required
and non-empty?

every entry parses as IPv4/IPv6 CIDR?

Dns.GetHostAddressesAsync(TargetUrl.Host)

at least one address?

every resolved address belongs
to at least one CIDR?

allow

deny + structured warning

虽然方法 XML 曾写“至少一个解析地址允许”,实现实际更严格:找到任何不在 allowlist 的地址就拒绝。IPv4-mapped IPv6 会归一为 IPv4;不写前缀等于单 IP /32 或 /128。

4. CIDR 不等于完整 SSRF 防护

当前检查与发送分离:

  1. Guard 用 Dns.GetHostAddressesAsync 得到 A/AAAA;
  2. 判断全部地址在 CIDR;
  3. DefaultWebhookHttpClient 用普通 HttpClient 对同一 hostname 发请求;
  4. transport 可能重新 DNS 解析;
  5. 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 内同步等待大量慢目标。

目标专用 HttpClient 装配示例
// 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_totaltenant tier、event、status class;不带 URL
webhook_delivery_duration_secondsevent、result class
webhook_guard_denied_totaltenant、reason=tenant/client/scope/ip/rate
webhook_retry_scheduled_totalevent、attempt
webhook_dead_letter_age_secondstenant tier、event
webhook_dns_resolution_secondsresolver/result
webhook_inflighthost pool/tier
webhook_secret_unprotect_failure_totalkey ring/version;绝不记录材料

当前 CIDR 与 Redis policy 已写结构化日志,包括 TenantId、ClientId、SubscriptionId、EventType 和解析地址/配额。ResolvedAddresses 可能暴露客户网络拓扑,日志访问与保留要分级。不要把 TargetUrl query、payload、secret 或完整签名加入日志。

10. 发布与故障 Runbook

发布前:

  1. readiness 健康,Redis 多实例共享测试通过;
  2. DataProtection key ring 持久化、共享、备份并完成恢复演练;
  3. egress firewall/proxy policy 与订阅 CIDR 双层生效;
  4. 专用 HttpClient timeout/redirect/proxy 已固定;
  5. CAP topic consumer contract 通过;
  6. 测试订阅完成 challenge,不发送真实业务副作用;
  7. dead-letter 查询、重放、暂停、轮换权限与审计已演练。

Redis 故障:当前所有新投递进入 DeadLettered。先恢复 Redis,评估死信规模,再按 tenant/事件分批重放,避免恢复风暴。DNS/证书异常时先暂停受影响订阅,不要全局关闭 allowlist。密钥环丢失时禁止把 Unprotect fallback 当恢复方案,应从备份恢复 key ring 或与客户重新轮换。

11. 必测网络矩阵

  1. IPv4/IPv6/mapped IPv6、多个 A/AAAA 中一个越界;
  2. 解析空、NXDOMAIN、timeout、answer 变化和 DNS rebinding;
  3. redirect 到公有、私有、loopback、metadata、HTTP;
  4. userinfo、IDN、fragment、端口、超长 URL;
  5. 受控 proxy 与绕过 proxy;
  6. TLS 过期、hostname mismatch、unknown CA、握手 timeout;
  7. Redis 未注入、断线、脚本异常、窗口边界、跨实例;
  8. 每租户公平性、慢目标连接耗尽和恢复突发;
  9. readiness 只在全部必要 adapter 就绪时通过;
  10. 日志/指标无高基数 URL、payload 或密钥泄露。

12. 审查命令

Terminal window
# 先确认组合根最终没有解析到 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'

返回 Webhooks 总览 · 订阅与授权 · 事件与 GA

100%

滚轮或按钮缩放 · 放大后拖动画面 · 双击切换 100% / 200%