框架深潜

Odoo Webhook/Controller 安全真正难的不是 `csrf=False`,而是你拿什么补上信任:公开路由、签名校验、常量时间比较与幂等边界讲透

很多 Odoo 对接把 webhook 能收消息当成成功,却忽略了控制器在关闭 CSRF 之后必须自己补上的安全义务。结合 payment_xendit、mail_plugin 等源码,可以更清楚地看到:公开 POST 入口不是问题本身,问题是有没有做签名、有没有做常量时间比对、有没有把重活放到异步处理、有没有处理重复投递。

框架
进阶 开发者 2 分钟阅读
0 评论 0 点赞 0 收藏 6 阅读

先说结论

在 Odoo 里,Webhook 或第三方回调控制器最容易被误解的地方,是大家把:

  • auth='public'
  • methods=['POST']
  • csrf=False

直接等同于“不安全”。

其实源码给出的答案更细:

机器到机器的回调入口,关闭浏览器式 CSRF 往往是合理的;真正决定安全性的,是你有没有补上‘请求真实性验证’与‘重复请求治理’。

/home/ubuntu/odoo-temp/addons/payment_xendit/controllers/main.pymail_plugin/controllers/authenticate.py 可以看到,官方真正依赖的不是 CSRF token,而是:

  • 明确的 webhook secret / callback token
  • 签名校验
  • consteq / compare_digest 这类常量时间比较
  • 控制器只做轻入口,真实处理下沉到内部流程
  • 对重复通知保持幂等

为什么 webhook 常常必须 csrf=False

先把一个常见误区讲清楚。

Odoo 默认的 CSRF 防护,本来就是为:

  • 浏览器表单
  • 已登录用户会话
  • 同站内状态修改请求

设计的。

而 webhook 回调通常来自:

  • 支付平台
  • 快递平台
  • 签约平台
  • 邮件服务
  • IoT 设备

它们不是浏览器,也不会自动带你网站会话里的 CSRF token。

所以如果你把机器回调也强行要求标准 CSRF,常见结果不是更安全,而是:

  • 正常 webhook 全部打不进来;
  • 最后开发者为了“先通”写出更糟糕的绕过逻辑。

因此像 payment_xendit 这种官方实现直接写:

  • @http.route(..., auth='public', csrf=False)

并不奇怪。

真问题不在于关 CSRF,而在于关掉之后有没有补边界

如果你关闭了浏览器式信任校验,就必须改用 机器式信任校验

否则才是真的在裸奔。

payment_xendit 给出了一个很标准的 webhook 安全范式

xendit_webhook() 的代码不长,但非常值得学。

它做了几件非常典型的事:

  1. 从请求体解析 JSON;
  2. 从请求头取 x-callback-token
  3. 根据回调数据先定位本地 transaction;
  4. _verify_notification_token() 验证 token;
  5. 验证通过后才 _process()
  6. 最后返回一个简单 accepted 响应。

这里最关键的不是“能收到消息”,而是:

  • 先定位本地对象,再用本地已知 secret 验证请求头。

这和很多项目里“先信 payload,再决定怎么处理”完全是两种成熟度。

为什么 _verify_notification_token() 要用 consteq

_verify_notification_token() 里,官方没有写简单的:

if saved_token != received_token:

而是使用:

  • consteq(...)

这背后的安全意义是:

它在防时间侧信道

普通字符串比较在某些实现下,可能因为首个不匹配字符就提前退出,导致比较耗时随匹配程度变化。

对很多内部系统来说,这不一定会成为现实攻击点; 但对于公网暴露、长期存在、可被反复试探的 webhook 入口,官方显然选择了更稳的做法。

这种细节很能体现成熟度。

因为它说明控制器安全不是“我大概校验一下就行”,而是:

  • 连比较 secret 的方式都尽量收紧。

mail_plugin 说明“签名”比“能解出数据”更重要

mail_plugin/controllers/authenticate.py 里,可以看到另一种风格:

  • 先把 auth code 拆成 data 和 signature;
  • 再用 HMAC 重新计算签名;
  • 最后用 hmac.compare_digest() 比较。

这进一步说明,公开控制器的关键不是:

  • 请求参数格式对不对;
  • 你能不能把它 decode 出来;

而是:

  • 这段数据到底是不是由可信方生成且未被篡改。

也就是说,签名在这里回答的是“真实性”,不是“可解析性”。

很多项目之所以脆弱,就是因为把“我能读懂这个 JSON”误以为“这个 JSON 值得信”。

公开路由不等于公开业务能力

这是另一个很容易被忽略的边界。

Webhook 控制器经常是 auth='public',但这不代表它开放的是“公共业务能力”。

更准确地说,它开放的是:

  • 一个接收第三方通知的网络入口。

真正能不能触发业务写入,还要继续经过:

  • secret 校验;
  • 交易对象定位;
  • 状态机判断;
  • 幂等检查;
  • 内部业务规则。

所以成熟设计里,“路由公开”与“业务放开”是两件事。

把这两层混在一起,才会出现那种危险代码:

  • 只要有人 POST 到这个 URL,就真的把订单改状态了。

为什么好的 webhook 控制器都很短

payment_xendit 会发现,控制器本身并不做很多重逻辑。

这其实非常正确。

公开入口最稳的模式通常是:

  1. 快速验签 / 验 token
  2. 快速定位对象
  3. 快速把请求交给内部处理链
  4. 尽快返回明确响应

这样做的好处是:

  • 外部平台重试时行为更可预测;
  • 入口层更容易审计;
  • 出问题时更容易定位是“入口信任问题”还是“内部业务问题”;
  • 不会把一个公网入口做成又重又复杂的事务中心。

Peppol webhook 那类“收到回调后只触发 cron,再由内部任务拉取状态”的设计,也是同一个思想。

webhook 安全里最常被漏掉的是幂等

很多人把安全只理解成“别让陌生人打进来”,但对 webhook 来说,另一个同样关键的问题是:

  • 可信平台会不会重复投递同一条消息?

现实答案通常是:会。

因为外部平台会:

  • 超时重试;
  • 失败重放;
  • 网络抖动后重新推;
  • 以最终一致性方式多次通知同一状态。

所以 webhook 入口要解决两件事:

  1. 请求是不是可信的;
  2. 就算可信,它是不是已经处理过。

如果只做第一条,不做第二条,系统一样会出事故,例如:

  • 重复创建记录;
  • 重复扣款后续流程;
  • 状态机重复推进;
  • 通知或邮件被刷爆。

机器入口不要沿用浏览器思维

很多定制之所以脆弱,是因为开发者在做机器回调时,还沿用浏览器表单的脑回路:

  • 觉得登录态足够;
  • 觉得 referer 能说明来源;
  • 觉得关掉 CSRF 就只是少一层保护;
  • 觉得只要 URL 难猜就差不多。

这些都不够。

机器入口真正应该围绕的是:

  • secret / 签名
  • 时间窗口或 nonce(如果对方协议支持)
  • 请求体真实性
  • 幂等键或业务状态去重
  • 最小化控制器职责

实战里的排查顺序

如果你在 Odoo 项目里碰到“webhook 能通但总觉得不稳”或“第三方有时重复回调导致业务异常”,我建议按这个顺序审:

  1. 先确认机器入口是否明确 csrf=False 且不是误用浏览器 CSRF 方案
  2. 检查是否有 webhook token / HMAC / 签名校验,而不是只验 payload 字段
  3. 确认 secret 比较是否使用 consteq / compare_digest 这类安全比较
  4. 确认控制器是否足够短,只做入口校验和分发
  5. 确认内部状态推进是否天然幂等,或至少有重复通知去重机制
  6. 最后再看日志与错误响应是否足以支持重试和审计。

一句话记忆

Odoo webhook 的关键从来不是“要不要 csrf=False”,而是“关掉浏览器式信任之后,你是否用签名、secret、幂等和最小入口设计,把机器式信任补完整”。

DISCUSSION

评论区

想参与讨论?先 登录 再发表评论。
还没有评论,你可以成为第一个留言的人。