Rust in Everything
基于 topcoat-runtime 0.10.0

页面要登录,shard 却谁都能请求

主线位置:交互主线的后半段。shard 内联渲染时是页面的一部分,重渲染时却是一条独立的路由:浏览器把参数 POST 到 shard 自己的端点,路由器只跑这个 shard(signal、shard 与 procedure)。这一页不报错,后果是越权。

sequenceDiagram
  participant A as 攻击者
  participant O as OriginLayer
  participant P as 页面 ledger_page
  participant S as shard ledger
  A->>O: GET /ledger(没登录)
  O->>P: 放行
  P-->>A: 401:require_auth 拦下
  A->>O: POST /shards/ledger,args 随便填
  O->>S: 同源检查不看登录,curl 不带头也放行
  S-->>A: 200:页面的守卫一行都没跑

这是文档化的设计

上游文档在三处用粗体写了这件事。docs/runtime/shard.md 的 Guards 一节:

A request to a shard's endpoint runs the shard without its page or layouts. Their guards do not
protect it. Check authorization inside the shard, including whether the user may access the data
selected by its arguments.

docs/runtime.md 的 shard 一节:「A shard has its own HTTP endpoint. Validate its arguments and authorize access inside the shard. Its page and layout guards do not run for requests to that endpoint.」procedure 的文档第一段也是同样的要求:「Validate its arguments and authorize access inside the procedure, since callers can send their own requests.」

所以这一页不是在报 bug,而是把文档里的这句话落到可复现的请求上,再给出该怎么写。

复现

依赖:topcoat =0.10.0 的 full、tokio =1.54.1;.discover().assets(...).runtime()。用 03 章 的「函数而不是中间件」写一个守卫,实验里用一个 x-user 头充当登录:

#[memoize(as_ref)]
fn current_user(cx: &Cx) -> Option<User> {
  let name = headers(cx).get("x-user")?.to_str().ok()?;
  Some(User(name.to_owned()))
}

fn require_auth(cx: &Cx) -> Result<&User, UnauthorizedError> {
  current_user(cx).ok_or_unauthorized()
}

#[shard("/shards/ledger")]
async fn ledger(cx: &Cx, account: String) -> Result<impl View> {
  Ok(view! { <p>"rows of " (account)</p> })
}

#[page("./ledger")]
async fn ledger_page(cx: &Cx) -> Result<impl View> {
  let user = require_auth(cx)?;
  let account = signal(cx, || "acct-1".to_owned());
  Ok(view! { <p>"hello " (user.0.clone())</p> ledger(account: $(account.get())) })
}
### GET /ledger
HTTP/1.1 401 Unauthorized

unauthorized

### POST /shards/ledger  -d '{"args":["acct-9"],"signals":{}}'
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8

<p>rows of acct-9</p>

服务器打印 ledger shard: account="acct-9" x-user=None。不需要 x-topcoat-identity 头,也不需要先打开页面;参数 acct-9 是随便填的,页面上从来没出现过。OriginLayer 照常在外面,但它只拦浏览器里的跨站请求,curl 不带 Origin 和 Sec-Fetch-* 头本来就放行(403 forbidden)。

修法一:在 shard 里调守卫(上游推荐)

#[shard("/shards/ledger-checked")]
async fn ledger_checked(cx: &Cx, account: String) -> Result<impl View> {
  let user = require_auth(cx)?;
  // 还要检查 user 能不能看 account:参数是用户输入
  Ok(view! { <p>"rows of " (account) " for " (user.0.clone())</p> })
}
### POST /shards/ledger-checked  (不带 x-user)
HTTP/1.1 401 Unauthorized

unauthorized

### POST /shards/ledger-checked  -H 'x-user: bob'
HTTP/1.1 200 OK

<p>rows of acct-9 for bob</p>

#[memoize] 让守卫在同一个请求里只算一次:shard 内联渲染时,页面已经调过 require_auth,shard 里再调一次拿的是缓存;单独请求 shard 端点时,这一次就是真正的检查。实测带 x-user: bob 打开 /ledger,页面和三个内联 shard 里一共调了两次 require_auth,current_user 的函数体只跑了一次。所以「每个 shard 第一行调守卫」在内联渲染时几乎没有额外开销。

登录只是第一步。文档那句话的后半句是「including whether the user may access the data selected by its arguments」:account 是浏览器送来的,登录了的 bob 也可以把它改成别人的账号。shard 里 signal 恢复出来的值同样是用户输入,类型对不上时还会被悄悄换成初值。

修法二:带路径的 layer 包住 shard 的固定路径

路由表与 layer 链 讲过:带路径的 layer 包住注册路径以它开头的所有路由,shard 端点也是路由。给 shard 一个固定的绝对路径,放在一个受保护的前缀下:

#[shard("/admin-shards/ledger")]
async fn ledger_layered(account: String) -> Result<impl View> {
  Ok(view! { <p>"rows of " (account)</p> })
}

#[layer("/admin-shards")]
async fn admin_guard(cx: &Cx, body: Body, next: Next<'_>) -> Result<Response> {
  require_auth(cx)?;
  next.run(cx, body).await
}
### POST /admin-shards/ledger  (不带 x-user)
HTTP/1.1 401 Unauthorized

### POST /admin-shards/ledger  -H 'x-user: bob'
HTTP/1.1 200 OK

<p>rows of acct-9</p>

要点和限制:

  • 不写路径的 shard 挂在 /_topcoat/runtime/shards/ 下,路径由名字和源码位置生成;挂在 /admin 这类业务前缀上的 layer 包不到它。用这个办法必须给 shard 写固定路径。
  • layer 只能做「这一类端点都要登录」这种粗粒度的检查;「这个用户能不能看这个 account」仍然要在 shard 里做。
  • 页面重跑(POST 页面 URL + X-Topcoat-Runtime)不在此列:它被 RuntimeLayer 改写成 GET 后完整重跑页面和 layout,页面上的守卫照常生效。

procedure 同理

#[procedure] 也是一条独立的 POST 路由,参数是带类型标签的 JSON 数组。05 章用 curl 直接调过一个 procedure,传一个 u64::MAX 让 debug 构建溢出 panic。修法和 shard 一样:函数体第一行调守卫,再校验参数。procedure 返回 Err 时浏览器端的表达式直接失败,拿不到错误内容,需要客户端处理的失败要做成数据返回。

和 Dioxus 的同类问题

Dioxus 的 server function 也是公开路由,dioxus 卷的 server-fn-public-endpoint 复现了「没登录也能 curl」。两边的结论相同:页面上的检查保护不了从页面里发出去的请求,鉴权写在被请求的函数里。

小结

  • shard 和 procedure 都有自己的端点,请求它们时页面和 layout 不跑;上游文档在 shard、runtime、procedure 三处明确写了这一点,是设计而不是漏洞。
  • curl 不带登录直接 POST /shards/ledger,参数随便填,拿到 200。
  • 修法:shard 第一行调守卫函数(#[memoize] 让内联渲染时几乎零开销),再检查参数对应的数据是否属于当前用户;或者给 shard 固定路径、用带路径的 layer 做粗粒度拦截。
  • 页面重跑会完整经过页面和 layout,不受影响。

💬 关联讨论

发起讨论
加载中...