先说结论
在 Odoo 里,Webhook 或第三方回调控制器最容易被误解的地方,是大家把:
auth='public'methods=['POST']csrf=False
直接等同于“不安全”。
其实源码给出的答案更细:
机器到机器的回调入口,关闭浏览器式 CSRF 往往是合理的;真正决定安全性的,是你有没有补上‘请求真实性验证’与‘重复请求治理’。
从 /home/ubuntu/odoo-temp/addons/payment_xendit/controllers/main.py 和 mail_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() 的代码不长,但非常值得学。
它做了几件非常典型的事:
- 从请求体解析 JSON;
- 从请求头取
x-callback-token; - 根据回调数据先定位本地 transaction;
- 调
_verify_notification_token()验证 token; - 验证通过后才
_process(); - 最后返回一个简单 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 会发现,控制器本身并不做很多重逻辑。
这其实非常正确。
公开入口最稳的模式通常是:
- 快速验签 / 验 token
- 快速定位对象
- 快速把请求交给内部处理链
- 尽快返回明确响应
这样做的好处是:
- 外部平台重试时行为更可预测;
- 入口层更容易审计;
- 出问题时更容易定位是“入口信任问题”还是“内部业务问题”;
- 不会把一个公网入口做成又重又复杂的事务中心。
Peppol webhook 那类“收到回调后只触发 cron,再由内部任务拉取状态”的设计,也是同一个思想。
webhook 安全里最常被漏掉的是幂等
很多人把安全只理解成“别让陌生人打进来”,但对 webhook 来说,另一个同样关键的问题是:
- 可信平台会不会重复投递同一条消息?
现实答案通常是:会。
因为外部平台会:
- 超时重试;
- 失败重放;
- 网络抖动后重新推;
- 以最终一致性方式多次通知同一状态。
所以 webhook 入口要解决两件事:
- 请求是不是可信的;
- 就算可信,它是不是已经处理过。
如果只做第一条,不做第二条,系统一样会出事故,例如:
- 重复创建记录;
- 重复扣款后续流程;
- 状态机重复推进;
- 通知或邮件被刷爆。
机器入口不要沿用浏览器思维
很多定制之所以脆弱,是因为开发者在做机器回调时,还沿用浏览器表单的脑回路:
- 觉得登录态足够;
- 觉得 referer 能说明来源;
- 觉得关掉 CSRF 就只是少一层保护;
- 觉得只要 URL 难猜就差不多。
这些都不够。
机器入口真正应该围绕的是:
- secret / 签名
- 时间窗口或 nonce(如果对方协议支持)
- 请求体真实性
- 幂等键或业务状态去重
- 最小化控制器职责
实战里的排查顺序
如果你在 Odoo 项目里碰到“webhook 能通但总觉得不稳”或“第三方有时重复回调导致业务异常”,我建议按这个顺序审:
- 先确认机器入口是否明确
csrf=False且不是误用浏览器 CSRF 方案; - 检查是否有 webhook token / HMAC / 签名校验,而不是只验 payload 字段;
- 确认 secret 比较是否使用
consteq/compare_digest这类安全比较; - 确认控制器是否足够短,只做入口校验和分发;
- 确认内部状态推进是否天然幂等,或至少有重复通知去重机制;
- 最后再看日志与错误响应是否足以支持重试和审计。
一句话记忆
Odoo webhook 的关键从来不是“要不要
csrf=False”,而是“关掉浏览器式信任之后,你是否用签名、secret、幂等和最小入口设计,把机器式信任补完整”。
DISCUSSION
评论区