3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

RustのTower + Axumでフォーム送信時のCSRF対策を施す

Last updated at Posted at 2024-05-17

こんにちは!お久しぶりです。筆者は日本を縦断してまた北海道に戻ってきたこと、プロジェクト立ち上げの猛集中もあって、中々記事を書いていませんでした。

概要

フォーム送信時にユーザーのリクエストのヘッダーに付けるCookieと、フォームのボディに擬似無作為的な値を含めることでCSRF対策を行う

CSRFとは

Cross-site Request Forgeryとは、とある正式なWebサイトにログインしているユーザーに、悪意あるハッカーなどが、その正式Webサイトに似せたページに招き込んで、その偽物のページからリクエストを正式Webサイトに送信して、個人情報などを盗もうとするような攻撃のことです。XSRFとも呼ばれますが、同じ意味です。

CSRF経由のハッキングは後を絶えず、大手も中小企業も被害に遭っている(起こしているというべきでしょうか?)のです。

CSRF対策、どのようにすればいいのか?

いくつかやり方がありますが、筆者はサーバー側でフォームのHTMLを生成してユーザーに送る時に、擬似ランダムな値を作って、それをCookieとしても、フォームの値としても送って、ユーザーが入力を終えて送信した時に、Cookieの値とフォームのボディに含まれている値が合っているかどうかをチェックするのが一番いいと思います。合っていなければ403か400を返せばいいのです。

Cookieにもいくつか厳しい設定を設ける必要がありますので、後々解説します。

筆者は以下の記事もお勧めします。こちらを引用して対策を設計したものです。

AxumサーバーでCSRF対策を行う

今回は、AxumのWebサーバーでCSRF対策を実践でお見せしたいと思います。僕はAxumでも、Towerよりの使い方を好むので、CSRFトークンを生成、もしくは読み取るtower::Serviceを書いていきます。

実現するには以下の部品を作る必要があります。

  1. struct CsrfCookie
  2. struct CsrfCookieService

struct CsrfCookie

AxumでCookieを使うためのツールもaxum_extraに含まれていますが、筆者はCookieをより直接的に扱うのを好むので、使うのはRustのcookieクレートのみです。

今回のCsrfCookieの値にはUUID V4から生成したものを使おうと思います。Uuid V4なら十分な擬似ランダム性は担保できるでしょう。uuidのクレートのv4とserdeフィーチャーを有効にするとOKです。

また、Cookieの値のシリアライズにはserde_jsonを使います。Base64化したり、暗号化していたりする必要はないと考えますが、たいしたパフォーマンスの打撃もないと思いますので、したい人はどうぞ:wink:

コード

実際に書いたコードが以下の通りです。

use serde::{Deserialize, Serialize};

use super::LbJhCookie;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CsrfCookie(uuid::Uuid);

impl CsrfCookie {
    const COOKIE_NAME: &'static str = "my_csrf_token";

    // 'aは実際には'staticまで生きられるのですが、'aにしておくとRustが喜ぶらしい
    // 詳しくはVariance・Covarianceについてお読みください
    // https://doc.rust-lang.org/nomicon/subtyping.html
    fn to_cookie<'a>(&self) -> Result<cookie::Cookie<'a>, serde_json::Error> {
        let cookie_body = serde_json::to_string(self)?;

        let domain = "my-domain-here.co";

        let cookie = cookie::Cookie::build((Self::COOKIE_NAME, cookie_body.as_str()))
            .path("/")
            .http_only(true)
            .domain(domain)
            .same_site(cookie::SameSite::String)
            .expires({
                let mut time = cookie::time::OffsetDateTime::now_utc();
                time += cookie::time::Duration::minutes(30);

                time
            });

        Ok(cookie.build().into_owned())
    }

    fn from_headers<'a>(
        headers: &'a http::HeaderMap,
    ) -> Option<Result<Self, serde_json::Error>> {
        headers
            .get_all("Cookie")
            .into_iter()
            .flat_map(|cookie_header| cookie_header.to_str().ok())
            .map(|cookies_str| {
                cookie::Cookie::split_parse(cookies_str)
                    .into_iter()
                    .filter_map(|c| c.ok())
            })
            .flatten()
            .find(|cookie| cookie.name() == Self::COOKIE_NAME)
            .map(|cookie| serde_json::from_str(cookie.value()))
    }
}

上記の二つのメソッドがあれば、HTMLフォームを送信するレスポンスに含めるCookieを生成することができ(CsrfCookie::to_cookie)、そしてフォームを送信してもらった時にそのCookieをヘッダーから解読することもできます(CsrfCookie::from_headers)。

Cookieの重要なところは、ドメインを設定していること、SameSiteも設定していること、HttpOnlyを有効にしていることです。
もしフォーム送信のエンドポイントまでも決まっていてお手数でなければ、Cookieのパスも設定していいかもしれません。

SameSiteは必ずStrictにしましょう。Strictにすることで、ブラウザは絶対に異なる(CSRF攻撃の場合は偽物の)ドメインからSet-Cookie時のドメインへのリクエストを送る時に、このmy_csrf_cookieを含めないのです。SameSiteについてはMDNのドキュメントを読んでいただければと思います。

Cookieの有効期限も30分後に切れるようにしていますが、もしかしてもっと長かったり、短かったりした方がいいかもしれません。読者の都合に合わせて設定してください。ただ、有効期限があった方がいいのは間違いなくて、長くとも数時間で切れるようにしましょう。

また、HttpOnlyを有効にすると、JavaScriptからそのクッキーの値が読めなくなるので、XSS攻撃からも守れます。

tower::SerivceのCsrfCookieServiceを書く

次はtower::Servieを実装したCsrfCookieServiceを書きます。このServiceには二つのモードがあります:Cookieを設定するモードと帰ってきたCookieを読み取るモードです。

まずモードをEnumとして表現しましょう。

#[derive(Debug, Clone, Copy)]
pub enum CsrfCookieServiceMode {
    /// Consumes cookie (API)
    Consumer,
    /// Sets cookie (Views)
    Producer,
}

それからServiceを実装します

use axum::{extract::Request, response::Response};
use http::header::{CACHE_CONTROL, SET_COOKIE};
use std::{future::Future, pin::Pin};
use tower::{Layer, Service};

#[derive(Debug, Clone)]
pub struct CsrfCookieService<S> {
    inner: S,
    mode: CsrfCookieServiceMode,
}

impl<S> Service<Request> for CsrfCookieService<S>
where
    S: Service<Request, Response = Response> + Send + 'static,
    S::Future: Send + 'static,
{
    type Response = S::Response;
    type Error = S::Error;
    type Future = Pin<Box<dyn Future<Output = Result<S::Response, S::Error>> + Send + 'static>>;

    // No backpressure needed
    fn poll_ready(
        &mut self,
        cx: &mut std::task::Context<'_>,
    ) -> std::task::Poll<Result<(), Self::Error>> {
        self.inner.poll_ready(cx)
    }

    fn call(&mut self, mut req: Request) -> Self::Future {
        match self.mode {
            CsrfCookieServiceMode::Consumer => {
                let current_csrf_cookie = <CsrfCookie as LbJhCookie>::from_headers(req.headers())
                    .map(|res| res.ok())
                    .flatten();

                let extensions = req.extensions_mut();

                // Axumハンドラーで取り出して、なかった場合、フォームの値と合わなかった場合は
                // 400などで弾く
                if let Some(csrf_cookie) = current_csrf_cookie {
                    extensions.insert(csrf_cookie);
                }

                let response_fut = self.inner.call(req);

                Box::pin(response_fut)
            }
            CsrfCookieServiceMode::Producer => {
                let next_csrf_cookie = CsrfCookie::new();

                // Serde_jsonがuuidをシリアライズできないことはないはずなので、ExpectでOKです。
                let mut cookie = next_csrf_cookie
                    .to_cookie()
                    .expect("failed to build csrf cookie");
                
                let extensions = req.extensions_mut();

                // Extensionsに入れることで、Axumの中でも取り出してHTMLに含めることができます。
                // 筆者はTeraを使っていますので、Extensionsではなく、tera::Contextに入れてしまって、テンプレートにレンダーしています。
                extensions.insert(next_csrf_cookie);

                cookie.set_same_site(cookie::SameSite::Strict);

                // Uuidでこれは起きないはずなので、ExpectでOKです。
                let csrf_cookie_header_value =
                    http::HeaderValue::from_bytes(cookie.to_string().as_bytes())
                        .expect("could not convert csrf cookie to header value");

                let response_fut = self.inner.call(req);

                Box::pin(async move {
                    let mut response = response_fut.await?;

                    let headers = response.headers_mut();

                    headers.append(SET_COOKIE, csrf_cookie_header_value);

                    // これをやらないとブラウザバックした時は大変!
                    headers.append(
                        CACHE_CONTROL,
                        http::header::HeaderValue::from_static("no-store"),
                    );

                    Ok(response)
                })
            }
        }
    }
}

ここで工夫が必要なのは、ブラウザキャッシュです。そのままブラウザキャッシュをONにしてしまうと、ブラウザバックにフォームに戻った時、先に閲覧したページにもCSRFトークンが発行された場合は、フォームの値とクッキーの値が不一致になってしまいます。なのでCache-Controlを使って、ブラウザにこのCSRFトークンが使われているページをキャッシュに入れないで毎回フレッシュな状態で取りに行ってもらうようにしましょう。

それから、使いやすいようにtower::Layerも実装します。

#[derive(Debug, Clone)]
pub struct CsrfCookieLayer {
    mode: CsrfCookieServiceMode,
}

impl<S> Layer<S> for CsrfCookieLayer {
    type Service = CsrfCookieService<S>;

    fn layer(&self, inner: S) -> Self::Service {
        Self::Service {
            inner,
            mode: self.mode,
        }
    }
}

impl CsrfCookieLayer {
    pub fn new(mode: CsrfCookieServiceMode) -> Self {
        Self { mode }
    }
}

エンドポイントで使ってみる

実際にエンドポイントで使うときは以下のようにします。まずはView(HTMLを返す)コントローラーの使い方:

        Router::new()
                .route("/company", get(get_company_handler))
                .layer(
                    tower::ServiceBuilder::new()
                        .layer(SomeOtherServiceLayer)
                        .layer(CsrfCookieLayer::new(
                            CsrfCookieServiceMode::Producer,
                        ))
                )
                .with_state(state),
        )

それからAPI・POSTエンドポイントの使い方

#[derive(Deserialize)]
struct NewBody {
    csrf_token: String,
}

        Router::new()
                    .route("/new", post(|csrf_cookie: 
                        Option<Extension<CsrfCookie>>,
                        Form(body): Form<CorpoNewOfferBody>,| async move {
                            if !csrf_cookie.is_some_and(|c| 
                            c.to_string() == body.csrf_token) {
                                return StatusCode::UNAUTHORIZED.into_response();
                            }
                    ().into_response()
                }))
                .with_state(state)
                .layer(
                  CsrfCookieLayer::new(
                    CsrfCookieServiceMode::Consumer,
                    ),
                )

フォーマットが汚くて申し訳ないのですが、こんな感じで使います。HTMLは以下のような感じで、<input type="hidden">に隠します。

Screenshot 2024-05-17 at 10.49.24.png

JSONの時はどうするのでしょうか?

筆者はJSONのフォーム(JavaScriptで送信するもの)を半々で使いますが、その時はJSONのヘッダーにX-Client-Csrf-Tokenのようなヘッダーを付けて、送信するようにしています。

その時、バックエンド側でCookiesのヘッダーとX-Client-Csrf-Token`の両方の値が一致していることを確認すればOKです。

まとめ

擬似ランダムトークンを発行したCSRF対策を解説し、RustのAxumサーバー(Towerなら大体上記のような感じ)における実装の例も紹介しましたが、いかがでしょうか?

CSRF対策はさほど難しくないのですが、やらないと個人情報漏洩などのリスクがあるので、ぜひ何らかの実装をしましょう。

セキュリティ対策はイタチごっこでもあるし、筆者もまだまだ学習中なので、もし上記のやり方に付け加えたいポイントがあれば、ぜひコメントしていただければと思います:smiley:

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?