Rust in Everything
基于 topcoat-router 0.10.0

Origin 检查、cookie 与 session

主线位置:请求主线的入口和出口。Origin 检查是 dispatch 里永远最外层的一步(从连接到分发);cookie 的 Set-Cookie 在 handler 返回、响应开始流式之前写好;session 建在 cookie 之上。三者都在 default feature 里。

flowchart TB
  req["请求"] --> safe{"GET、HEAD、OPTIONS 且不是 WebSocket 握手"}
  safe -->|是| allow["放行"]
  safe -->|否| ex{"路径在 exempt_paths 里"}
  ex -->|是| allow
  ex -->|否| tr{"Origin 在 trust_origins 里"}
  tr -->|是| allow
  tr -->|否| sfs{"有 Sec-Fetch-Site"}
  sfs -->|"same-origin 或 none"| allow
  sfs -->|"其他值"| deny["403 forbidden"]
  sfs -->|没有| org{"有 Origin"}
  org -->|"Origin 的主机部分等于 Host"| allow
  org -->|不等| deny
  org -->|没有| allow2["放行:视为非浏览器客户端"]

OriginPolicy:默认验证每个请求

RouterBuilder::new() 里 origin_policy 的初值就是 OriginPolicy::new():验证、不信任任何外部来源、不豁免任何路径。build 时把它包成 OriginLayer,dispatch 先过它再进 layer 链,所以它在所有 layer 和路由之外,连 404 都在它之后。

判定逻辑全在 check 一个函数里,就是本页开头那张图:

// Only a request that can do damage needs verification: any
// method beyond the safe ones, and a WebSocket handshake, which
// arrives as a GET but opens a credentialed two-way connection.
let upgrades_to_websocket = header(header::UPGRADE.as_str())
    .is_some_and(|value| value.trim().eq_ignore_ascii_case("websocket"));
if !upgrades_to_websocket
    && matches!(method(cx), &Method::GET | &Method::HEAD | &Method::OPTIONS)
{
    return OriginVerdict::Allow;
}
// ...
// The "sec-fetch-site" header is the browser's way to declare that the request
// comes from the same origin. If so, it is safe.
if let Some(site) = header("sec-fetch-site") {
    return if matches!(site, "same-origin" | "none") {
        OriginVerdict::Allow
    } else {
        OriginVerdict::Deny
    };
}

思路是信任浏览器自己的标注:现代浏览器在请求上带 Sec-Fetch-Site,服务端据此判断来源,不需要 CSRF token,也不需要在表单里埋隐藏字段。

实测:17 种组合

一个 #[route(POST "./submit")],用 curl 带不同的头去打(前 13 行是默认策略):

no headers (curl)                                            200
Origin same (http://127.0.0.1:38206)                         200
Origin http://evil.example                                   403
Origin https://127.0.0.1:38206 (scheme differs)              200
Sec-Fetch-Site: same-origin                                  200
Sec-Fetch-Site: same-site                                    403
Sec-Fetch-Site: cross-site                                   403
Sec-Fetch-Site: none                                         200
Sec-Fetch-Site: same-origin + Origin evil                    200
GET cross-site (/me)                                         200
POST cross-site to unknown path /nope                        403
Origin accounts.example.com (default policy)                 403
POST /hook cross-site (default policy)                       403
-- loose policy
Origin accounts.example.com + cross-site                     200
Origin ACCOUNTS.example.com (case)                           200
POST /hook cross-site                                        200
POST /submit cross-site evil                                 403

(loose 策略:OriginPolicy::new().trust_origins(["https://accounts.example.com"]).exempt_paths(["/hook"])。)几行值得展开:

  • 没有任何头的请求放行。curl、服务器之间的调用、移动端 App 都不带 Origin 和 Sec-Fetch-*,CSRF 的前提是浏览器自动带上 cookie,而浏览器一定会带这些头。所以这不是漏洞,但意味着 OriginPolicy 不是鉴权,只防浏览器里的跨站请求。
  • same-site 也是 403。a.example.com 的表单提交到 b.example.com,浏览器标的是 same-site,被拒。子域之间要互相提交,用 trust_origins 把对方的完整 origin 加进来。
  • Sec-Fetch-Site 优先:它存在时,Origin 写什么都不看(第 9 行,same-origin 加一个假的 Origin 照样放行)。只有浏览器能设这个头(Sec- 前缀的头脚本改不了),非浏览器客户端伪造它没有意义,因为它们本来就被放行。
  • 没有 Sec-Fetch-Site 时的后备比较只看主机部分:Origin 去掉 scheme:// 后和 Host 比,所以第 4 行 https:// 对 http:// 也放行了。只有不发 Sec-Fetch-Site 的老浏览器会走到这里。
  • 反向代理改写 Host 也只影响这条后备路径:现代浏览器带着 Sec-Fetch-Site,代理改没改 Host 都不影响判定。
  • 跨站的 POST 打一个不存在的路径也是 403 而不是 404:检查在路由匹配之前。
  • trust_origins 比较不区分大小写,要写完整的 origin(scheme、主机、非默认端口,不带尾斜杠)。exempt_paths 用路由的路径语法,{*rest} 也行。
  • 完全关掉的方法叫 OriginPolicy::dangerous_disable(),名字里就带着警告。

WebSocket 握手虽然是 GET,也在检查范围内:05 章用 Origin: http://evil.example 握手,拿到的是 403 Forbidden。

文档里有一句容易漏掉的话:SameSite=Lax 的 cookie 会随顶层跨站导航发送,「The router's default OriginPolicy checks these requests, but does not protect a state-changing GET」。改状态的操作必须用 POST 等非安全方法,用 GET 做删除、登出,Origin 检查拦不住。

cookie:一个请求一个 jar

.cookies() 注册一个路径为 / 的 CookieLayer:

fn handle<'a>(&'a self, cx: &'a Cx, body: Body, next: Next<'a>) -> LayerFuture<'a> {
    Box::pin(async move {
        let cx = cx.with(CookieJarCell::new());

        match next.run(&cx, body).await {
            Ok(mut response) => {
                write_cookies(&cx, response.headers_mut());
                Ok(response)
            }
            // The error's response does not exist yet, so the cookies
            // wait in the router's slot and land on it once it is built.
            Err(error) => {
                let mut headers = HeaderMap::new();
                write_cookies(&cx, &mut headers);
                response_headers(&cx).extend(headers);
                Err(error)
            }
        }
    })
}
  • jar 放在请求上下文里(03 章的 cx.with),第一次调用 cookies(cx) 时才解析请求的 Cookie 头;整个请求里多次调用共享同一个 jar,读得到还没发出去的修改。
  • next.run 返回时把 jar 的增量写成 Set-Cookie。出错时也写:错误还没变成响应,就先放进 router 的响应头槽位,等 respond 生成错误响应时贴上去。实测一个先写 cookie 再返回 unauthorized() 的路由:
HTTP/1.1 401 Unauthorized
content-type: text/plain; charset=utf-8
set-cookie: flash=denied
content-length: 12
  • 写完之后 jar 被 seal。之后再 add 或 remove,assert_open 直接 panic。

一个请求从监听到 HTML 讲过:页面的 Response 在首屏就绪时就返回了,suspense 里的内容是之后 hyper poll 响应体时才产生的。CookieLayer 的 next.run 也就在首屏时返回,jar 在那一刻 seal。在 suspense 里的组件中写 cookie:

#[component]
async fn late_writer(cx: &Cx) -> Result<impl View> {
  tokio::time::sleep(Duration::from_millis(100)).await;
  cookies(cx).add(("late", "1"));
  Ok(view! { <p>"late"</p> })
}
// 页面:<h1>"Late"</h1> suspense(fallback: view! { <p>"loading"</p> }, late_writer())
[   0ms] HTTP/1.1 200 OK
...
transfer-encoding: chunked

9B
<h1>Late</h1><!--::topcoat::region::start(a0a5e7b6...)--><p>loading</p><!--::topcoat::region::end(a0a5e7b6...)-->

[ 106ms] <EOF>
cannot add a cookie after the response headers have been sent. Pending cookies are written when the handler returns, so work that outlives it, such as a streaming body or a WebSocket task, can only read cookies

客户端收到 200 和 loading,106 毫秒时连接被关掉,没有结尾的 0 长度块:panic 发生在 hyper poll 响应体的时候,这时候 catch_unwind 已经变不出 500 了(状态码早发出去了),只能中止响应。把 cookies(cx).add 换成 session::start(cx),结果一模一样,panic 原文也一样:session 的写入就是 cookie 的写入。

规则因此很简单:会写 cookie 的代码(登录、登出、记住偏好)放在 handler 本身里,在构造 view! 之前跑完,不要放进 suspense、live!、shard 的流式部分,也不要放进 tokio::spawn 的任务。用 SuspenseMode::Wait(04 章)可以让整个页面不流式,代价是首屏等最慢的组件。

前缀、签名、加密

jar 上有一组组合子:default_* 只在 cookie 没设该属性时补上,override_* 强制覆盖;override_prefix_host() 加 __Host- 前缀并强制 Secure、Path=/、不带 Domain,读的时候再把前缀去掉,代码里还是用裸名字。签名和加密直接用 cookie crate 的 Key:

jar.add(cookie!("theme" = "dark"; Path = "/"));
signed_cookies(cx).add(cookie!("uid" = "42"; Path = "/"));
private_cookies(cx).add(cookie!("secret" = "s3cr3t"; Path = "/"));
set-cookie: theme=dark; Path=/
set-cookie: uid=cShJmJKKuQBJ1BTNldZa%2FmH+YqJDNrQpZpjG3+ykWBU%3D42; Path=/
set-cookie: secret=2J1rsqpPk55KjF9LM+WNOCDWkdc7K7flZzYk5wdSXAKANw%3D%3D; Path=/

签名 cookie 是「MAC + 明文」,42 就挂在末尾,客户端看得见、改不了;加密 cookie 看不到明文。signed_cookies / private_cookies 从应用上下文取 Key,没注册就是 03 章那种 app_context panic。

上游文档的示例写的是 .app_context(Key::generate()):每次启动生成一个新 key。把同一个 curl cookie 文件拿去请求重启后的服务:

theme=Some("dark") signed=None private=None

普通 cookie 还在,签名和加密的全部失效(验证失败,返回 None,不报错)。多实例部署时每个实例的 key 也不同。生产环境要从配置或密钥管理里加载一个固定的 Key。

session:只给你哈希

.sessions(SessionConfig::default()) 往应用上下文放一份配置、注册一个 SessionLayer,token 默认用 cookie 承载,所以也要先 .cookies()。topcoat 只管 token,不管存储:会话记录存在哪、怎么查、怎么过期,是应用自己的事,没有内置的内存或数据库存储。

start:

pub async fn start(cx: &Cx) -> Result<Session> {
    let token = Token::random();
    let session = Session {
        token_hash: token.hash(),
        expires_at: expires_at(cx),
    };
    token_store(cx)
        .write(cx, token.clone(), config(cx).lifetime)
        .await?;
    state(cx).set(Some(token)).await;
    Ok(session)
}
  • Token::random():getrandom 填 32 字节;编码是 URL 安全的 base64。Token 的 Debug 实现不打印内容。
  • 返回给应用的 Session 只有 token_hash(SHA-256)和 expires_at。应用存哈希,数据库泄露了也拿不到可用的 token。32 字节随机数不需要加盐或慢哈希。
  • 永远生成新 token,不复用请求里带来的,这就防住了会话固定。
  • refresh 用同一个 token 重新写 cookie,延长 Max-Age(滑动过期);rotate 换一个新 token,返回 revoked(旧哈希)和新 Session,应用负责删旧存新;stop 让客户端删 cookie,返回旧哈希。

默认的 cookie 由 CookieTokenStore 写,属性全部 override_*,应用改不掉:

fn cookies(cx: &Cx) -> impl Cookies {
    topcoat_cookie::cookies(cx)
        .override_same_site(SameSite::Lax)
        .override_http_only(true)
        .override_secure(true)
        .override_path("/")
        .override_prefix_host()
}

一次登录、轮换、登出的响应头(默认寿命 30 天,Max-Age=2592000):

== login
HTTP/1.1 303 See Other
location: /me
set-cookie: __Host-session=460hWb8ykpaW45jFkrFgbJ7CWKKfrHzKpcIx5Bf1vjs%3D; HttpOnly; SameSite=Lax; Secure; Path=/; Max-Age=2592000

== rotate
set-cookie: __Host-session=03UFRQPFJjy7lgc3hiO_LmepzWk7Mi73QuplYF5OKpE%3D; HttpOnly; SameSite=Lax; Secure; Path=/; Max-Age=2592000
Some(Rotation { revoked: TokenHash([112, 40, 53, ...]), session: Session { ... } })

== logout
set-cookie: __Host-session=; HttpOnly; SameSite=Lax; Secure; Path=/; Max-Age=0; Expires=Fri, 31 Oct 2025 14:05:42 GMT

服务器在登录时打印的 TokenHash([112, 40, 53, ...]) 和随后 /me 用这个 cookie 查到的哈希、rotate 返回的 revoked 是同一个值。logout 的 Expires 是一年前的此刻:cookie crate 删除 cookie 的写法。

本地开发:http://127.0.0.1 能登录吗

__Host- 前缀要求 Secure,Secure 的 cookie 按规范只能在安全上下文里设置。本地开发用的是明文 HTTP,会不会登录不了?一个页面用 fetch('/login', { method: 'POST' }) 登录,再 fetch('/me'),把结果写进 DOM,无头 Chrome(156.0.8078.74)打开:

login status=0 type=opaqueredirect
me=session hash TokenHash([142, 215, 63, ...])
document.cookie=""
isSecureContext=true

http://127.0.0.1 和 http://localhost 两个地址结果相同:Chrome 把回环地址当作安全上下文(isSecureContext=true),接受并回传了 __Host-session;document.cookie 为空是因为 HttpOnly。Firefox 和 Safari 本卷未实测。用局域网 IP 或自定义域名走明文 HTTP 访问开发机时,浏览器不再把它当安全上下文,这个 cookie 会被拒收,表现是「登录后还是未登录」。

小结

  • OriginPolicy 默认开启,在所有 layer 和路由之外:安全方法放行;有 Sec-Fetch-Site 时只认 same-origin 和 none(same-site 也拒);没有时比较 Origin 与 Host(只比主机部分);两者都没有的非浏览器请求放行。trust_origins、exempt_paths 放宽,dangerous_disable 关掉;它不保护改状态的 GET。
  • cookie jar 每请求一个,CookieLayer 在 next.run 返回时写 Set-Cookie(出错、重定向也写),随即 seal;流式开始后再写 cookie 或调 session::start,panic 原文是「cannot add a cookie after the response headers have been sent...」,客户端看到的是被截断的 200。
  • 签名 cookie 明文可见、不可改,加密 cookie 不可见;Key::generate() 每次启动都换 key,重启后签名和加密的 cookie 全部失效。
  • session:32 字节随机 token,应用只拿到 SHA-256 哈希和过期时间、自己存储;start 永远发新 token,rotate 换 token,refresh 滑动过期;默认 cookie 强制 __Host-、Secure、HttpOnly、SameSite=Lax、Path=/,寿命 30 天。Chrome 在 127.0.0.1 和 localhost 的明文 HTTP 下照常保存。

💬 关联讨论

发起讨论
加载中...