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 | 怎么放行 |
|---|---|---|
子域名之间互相提交表单、fetch | same-site | trust_origins 写对方的完整 origin |
OAuth / OIDC 用 response_mode=form_post,身份提供方的页面自动提交表单到你的回调 | cross-site | exempt_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 防护了,函数名就是这么提醒的。
排查顺序
- 浏览器开发者工具里看被拒请求的
Origin和Sec-Fetch-Site。 same-site或cross-site:确认这个来源是不是你想放行的;是,就按上表选trust_origins或exempt_paths。same-origin还是 403:看看是不是前面的反向代理把Sec-Fetch-Site去掉了、又改写了Host,这时走的是Origin对Host的后备比较。- 页面加载的 WebSocket(运行时的连接、
websocket路由)握手被拒,Origin与页面不同源时同样按这张表处理。
小结
forbidden来自默认开启的OriginPolicy,在所有 layer 之外,服务端没有日志;curl 不带头测不出来。same-site也被拒,子域名之间要trust_origins写完整 origin。- 身份提供方的
form_post回调是跨站POST,用exempt_paths只放那一个路径,回调自己校验state。 - 不要用
dangerous_disable解决单个路由的问题。
💬 关联讨论
发起讨论加载中...