没登录也能直接 curl server function
主线位置:调用主线的服务端入口。
#[post("/api/admin/delete")]在启动时变成 axum 路由表里的一条POST /api/admin/delete(server function 的「登记」一节)。路由不知道调用者是不是你自己的 wasm,也不知道调用者有没有登录:从按钮里调和从命令行调,到服务端是同一个 HTTP 请求。
sequenceDiagram participant W as 自己的 wasm participant C as curl 或别的站 participant R as axum 路由 participant F as 函数体 W->>R: POST /api/admin/delete C->>R: POST /api/admin/delete R->>F: 两个请求一样处理 F-->>C: 200 deleted
实验应用 pit2-fs(dioxus = "=0.7.10",dx 0.7.9,debug 构建,PORT=18820),M2 Max、macOS 27、rustc 1.99.0。curl 输出去掉了 date、vary、content-length。
现象
一个只在管理后台按钮里调用的函数:
#[post("/api/admin/delete")]
pub async fn delete_post(id: u32) -> Result<String, ServerFnError> {
info!("delete_post({id}) executed");
Ok(format!("deleted post {id}"))
}
不登录,直接 curl;再带上 dioxus 客户端会加的头;最后模拟别的网站上一个 enctype="text/plain" 的表单(字段名 {"id":3,"pad":"、值 "},浏览器拼出的正文正好是一段 JSON):
### curl -X POST /api/admin/delete -d {"id":1}
HTTP/1.1 200 OK
content-type: application/json
"deleted post 1"
### curl -X POST /api/admin/delete -d {"id":2} -H X-Request-Client: dioxus
HTTP/1.1 200 OK
content-type: application/json
"deleted post 2"
### curl -X POST /api/admin/delete -H content-type: text/plain -d {"id":3,"pad":"="} -H origin: http://evil.example -H referer: http://evil.example/page -H accept: text/html
HTTP/1.1 302 Found
content-type: application/json
location: http://evil.example/page
"deleted post 3"
三次都执行了。第三种:服务端不看 Content-Type,text/plain 的正文照样按 JSON 解,多出来的 pad 字段被 serde 忽略;请求带 Accept: text/html 和 Referer,成功后被改成 302 跳回对方页面。浏览器发这种跨站表单时会不会带上你站点的 cookie,取决于 cookie 的 SameSite,dioxus 不管。
出处
- 路由:
register_server_functions把inventory里的每个函数route到Router上,没有任何前置检查;路径在启动日志里,也在 wasm 里。#[server]不写路径时那串数字只为避免重名(server function 的「路径怎么定」)。 X-Request-Client:客户端在new_reqwest_request和new_gloo_request里加上它,dioxus 0.7.10 的服务端代码里没有一处读这个头。它不是防护,任何人都能加。- 不看
Content-Type:取参数的Bytes::from_request(request, &())读出字节后直接serde_json::from_slice。 - 302:
make_handler里「Accept含text/html、有Referer、响应成功」就改写成 302,本意是给不用 JS 的表单回到原页面。
修法
鉴权写在函数体里。用 server 参数拿请求头(只出现在服务端那一半的签名里,客户端调用时不用传),失败返回带状态码的 HttpError:
#[cfg(feature = "server")]
fn session_user(headers: &HeaderMap) -> Option<String> {
let cookie = headers.get("cookie")?.to_str().ok()?;
let token = cookie.split(';').find_map(|kv| kv.trim().strip_prefix("session="))?;
// stand-in for a session table lookup
(token == "s3cr3t-admin").then(|| "admin".to_string())
}
#[post("/api/admin/delete2", headers: HeaderMap)]
pub async fn delete_post_checked(id: u32) -> Result<String, HttpError> {
// 1. who is calling
let user = session_user(&headers).or_unauthorized("login required")?;
// 2. cross-site requests carry a foreign Origin
if let Some(origin) = headers.get("origin").and_then(|v| v.to_str().ok()) {
(origin == "http://127.0.0.1:18820").or_forbidden("cross-site request")?;
}
info!("delete_post_checked({id}) by {user}");
Ok(format!("deleted post {id}"))
}
### curl -X POST /api/admin/delete2 -d {"id":4}
HTTP/1.1 401 Unauthorized
content-type: application/json
{"message":"login required","code":401}
### curl -X POST /api/admin/delete2 -d {"id":5} -H cookie: session=s3cr3t-admin -H origin: http://evil.example
HTTP/1.1 403 Forbidden
content-type: application/json
{"message":"cross-site request","code":403}
### curl -X POST /api/admin/delete2 -d {"id":6} -H cookie: session=s3cr3t-admin
HTTP/1.1 200 OK
content-type: application/json
"deleted post 6"
- 每个需要身份的函数都要自己检查,最好收进一个函数(
require_user(&headers)?、require_admin(&headers)?),函数体第一行调用。页面上把按钮藏起来不算检查。 - 也可以在函数体任意位置写
let headers: HeaderMap = FullstackContext::extract().await?;(server function 的「在函数体里拿请求信息」)。SSR 时函数被组件直接调用,拿到的是页面请求的头,检查照样成立。 - 要统一挡一整个前缀(比如
/api/admin/),可以在dioxus::serve返回的Router外面套axum::middleware::from_fn(写法见 axum 卷的 from_fn)。中间件只看到 HTTP 请求,挡不住 SSR 时组件对函数体的直接调用,所以函数体里的检查不能省。 - 跨站:会话 cookie 设
SameSite=Lax或Strict,跨站 POST 就不带 cookie,上面第 1 步就会挡住;需要SameSite=None的站点再加Origin检查。 - 参数一律当作不可信输入校验:客户端代码里算出来的参数,攻击者可以随便改。
怎么确认没中招
- 列出启动日志里所有
Registering:行,逐个确认:这个函数谁能调?没登录调会怎样? - 对每个需要身份的函数写一个不带 cookie 的请求测试,断言 401 / 403,而不是 200 或 500。
- 再用
content-type: text/plain、外站Origin发一次写操作,断言不会执行。
小结
- server function 就是一条公开的 HTTP 路由,路径不是秘密,客户端加的
X-Request-Client服务端从不检查。 - 服务端不看
Content-Type,跨站text/plain表单能拼出合法 JSON;Accept: text/html加Referer的成功响应还会被改成 302。 - 鉴权和来源检查写在函数体里(server 参数
headers: HeaderMap+HttpError),中间件只能做补充;cookie 用SameSite=Lax/Strict。
💬 关联讨论
发起讨论加载中...