Rust in Everything
基于 topcoat-router 0.10.0

403 forbidden:跨站的 POST 被拦下

主线位置:请求主线的入口。dispatch 先过 OriginLayer,再进 layer 链和路由(从连接到分发),所以被拒的请求连你自己写的日志 layer 都到不了。

flowchart LR
  form["admin.example.com 上的表单"] -->|"POST,Sec-Fetch-Site: same-site"| origin["OriginLayer"]
  idp["身份提供方的自动提交表单"] -->|"POST,Sec-Fetch-Site: cross-site"| origin
  origin -->|"不在 trust_origins、不在 exempt_paths"| deny["403 forbidden"]
  origin -->|"放行"| layers["你的 layer、路由"]

现象

浏览器里提交表单,得到一个纯文本的 forbidden,服务端什么也没打印。最小复现:一个什么都不做的路由,默认的 RouterBuilder。

#[route(POST "./comments")]
async fn post_comment() -> Result<&'static str> { Ok("comment saved") }

#[route(POST "./auth/callback")]
async fn auth_callback() -> Result<&'static str> { Ok("callback ok") }

用 curl 模拟浏览器会带的头(依赖:topcoat =0.10.0 的 full、tokio =1.54.1):

### POST /comments  -H 'Sec-Fetch-Site: same-origin'
HTTP/1.1 200 OK

comment saved

### POST /comments  -H 'Sec-Fetch-Site: same-site' -H 'Origin: https://admin.example.com'
HTTP/1.1 403 Forbidden
content-type: text/plain; charset=utf-8
content-length: 9

forbidden

### POST /auth/callback  -H 'Sec-Fetch-Site: cross-site' -H 'Origin: https://appleid.apple.com'
HTTP/1.1 403 Forbidden

forbidden

### POST /comments  (不带任何头)
HTTP/1.1 200 OK

comment saved

最后一行容易让人误判:用 curl 直接测永远是 200,因为不带 Origin 和 Sec-Fetch-* 的请求被当成非浏览器客户端放行。只有在浏览器里、而且是跨站时才会 403。

原因

OriginPolicy::check 的规则(全表和 17 种组合的实测见 Origin 检查、cookie 与 session):

  • 安全方法(GET、HEAD、OPTIONS)且不是 WebSocket 握手,放行。
  • 路径在 exempt_paths 里、或 Origin 在 trust_origins 里,放行。
  • 有 Sec-Fetch-Site 时,只认 same-origin 和 none。same-site 也是 403:admin.example.com 提交到 example.com 在浏览器看来是同站,但不同源。
  • 没有 Sec-Fetch-Site 时比较 Origin 和 Host 的主机部分。

常见的触发场景:

场景浏览器标的 Sec-Fetch-Site怎么放行
子域名之间互相提交表单、fetchsame-sitetrust_origins 写对方的完整 origin
OAuth / OIDC 用 response_mode=form_post,身份提供方的页面自动提交表单到你的回调cross-siteexempt_paths 只放这一个回调路径,回调自己校验 state
开放给其他站点的 WebSocket 端点握手按跨站处理exempt_paths(这正是上游文档举的例子)
第三方服务器发来的 webhook不带这些头不需要放行,本来就通过

修法

// 子域名
module_router!()
  .origin_policy(OriginPolicy::new().trust_origins(["https://admin.example.com"]))
  .build()

// form_post 回调
module_router!()
  .origin_policy(OriginPolicy::new().exempt_paths(["/auth/callback"]))
  .build()

两种策略分别启动,同一组请求:

== trust_origins(["https://admin.example.com"])
POST /comments       same-site  Origin https://admin.example.com   200
POST /comments       same-site  Origin https://blog.example.com    403
POST /auth/callback  cross-site Origin https://appleid.apple.com   403
POST /comments       cross-site Origin https://appleid.apple.com   403
== exempt_paths(["/auth/callback"])
POST /comments       same-site  Origin https://admin.example.com   403
POST /comments       same-site  Origin https://blog.example.com    403
POST /auth/callback  cross-site Origin https://appleid.apple.com   200
POST /comments       cross-site Origin https://appleid.apple.com   403

两种放行都只开了自己那一个口子。要注意的细节:

  • trust_origins 和 Origin 头逐字比较(不分大小写),要写 scheme、主机、非默认端口,不带尾斜杠;写成 admin.example.com 不会匹配。
  • exempt_paths 用路由的路径语法,{param}、{*rest} 都行。豁免的路径对任何来源都不检查,所以那个路由要自己防伪造,OAuth 回调靠的是 state 参数。
  • 把 origin_policy 换成 OriginPolicy::dangerous_disable() 也能消掉 403,但整个应用就没有 CSRF 防护了,函数名就是这么提醒的。

排查顺序

  1. 浏览器开发者工具里看被拒请求的 Origin 和 Sec-Fetch-Site。
  2. same-site 或 cross-site:确认这个来源是不是你想放行的;是,就按上表选 trust_origins 或 exempt_paths。
  3. same-origin 还是 403:看看是不是前面的反向代理把 Sec-Fetch-Site 去掉了、又改写了 Host,这时走的是 Origin 对 Host 的后备比较。
  4. 页面加载的 WebSocket(运行时的连接、websocket 路由)握手被拒,Origin 与页面不同源时同样按这张表处理。

小结

  • forbidden 来自默认开启的 OriginPolicy,在所有 layer 之外,服务端没有日志;curl 不带头测不出来。
  • same-site 也被拒,子域名之间要 trust_origins 写完整 origin。
  • 身份提供方的 form_post 回调是跨站 POST,用 exempt_paths 只放那一个路径,回调自己校验 state。
  • 不要用 dangerous_disable 解决单个路由的问题。

💬 关联讨论

发起讨论
加载中...