Rust in Everything
基于 dioxus 0.7.10

没登录也能直接 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。

💬 关联讨论

发起讨论
加载中...