CSRF対策を読む

この記事は[Hajimari Advent Calendar 2023](https://qiita.com/advent-calendar/2023/hajimari)の17日目の記事です

最近、ちょっとした薄いWEBフレームワークを作ろうとした時に、CSRF対策について各フレームワークの実装を調べたことがあったのでその内容を残します。

## CSRF対策とは

CSRF対策についてChatGPTに聞いてみました
```
CSRF（Cross-Site Request Forgery、クロスサイトリクエストフォージェリ）対策は、
Webアプリケーションのセキュリティ対策の一環で、特定の攻撃を防ぐための手法です。
CSRF攻撃は、攻撃者が被害者の代わりに不正なリクエストを送信し、
被害者のアカウントやセッションを悪用する攻撃手法です。この攻撃を防ぐためにCSRF対策が必要です。

CSRF攻撃の一般的なシナリオは次のようなものです：

1 ユーザーがログイン済みのWebアプリケーションにアクセスしています。
2 攻撃者が、ユーザーがログインしているWebアプリケーションにアクセスさせるような誘導を行います。
 これは、メール、悪意のあるウェブサイト、または他の手法を使用して行われます。
3 ユーザーが攻撃者の用意したリンクやフォームをクリックするか、アクセスします。
  このリンクやフォームは、攻撃者が操作した不正なリクエストを発行します。
4 ユーザーのブラウザは、不正なリクエストを自動的に送信します。
 このとき、ユーザーは攻撃が発生していることに気づかないことが多いです。
5 攻撃が成功すると、不正な操作がアプリケーション内で実行され、被害者のアカウントやデータが危険にさらされます。

```

実際の対策手法はどうやって行われているのか
```
CSRF対策は、攻撃者が不正なリクエストを送信できないようにするための方法です。主なCSRF対策手法は次のとおりです：

CSRFトークンの使用: 
サーバーは、フォームを生成する際にランダムなトークンを生成し、ユーザーに提供します。
このトークンは、フォーム送信時に一緒に送信され、サーバーで検証されます。トークンが一致しない場合、
リクエストは拒否されます。

SameSite属性の設定: 
クッキーにSameSite属性を設定することで、クロスサイトリクエストフォージェリを一部防ぐことができます。SameSite属性は、クッキーがどのように送信されるかを制御します。

HTTPリファラーヘッダーの検証: 
サーバーは、リクエストのHTTPリファラーヘッダーを検証し、信頼できないドメインからのリクエストを拒否することができます。

カスタムヘッダーの追加: 
カスタムHTTPヘッダーをリクエストに追加し、サーバーで検証することもできます。
```

CSRF対策は、リクエストが送信元として信頼できるかどうかを確認し、リクエストを受けた当事者がそのリクエストを送信していることを証明するために行われます。

## 実際にどんなコードで書かれているか
WEBフレームワークやライブラリでCSRF対策がどのように実現されているかコードを読んで理解していきます。

### LaravelでのCSRF対策
[Laravel](https://laravel.com/docs/10.x/readme)はPHPのWEBフレームワークです。
Middlewareで提供されている`VerifyCsrfToken`がリクエストの間に入ることで実現します。

[VerifyCsrfToken](https://github.com/laravel/framework/blob/10.x/src/Illuminate/Foundation/Http/Middleware/VerifyCsrfToken.php)

以下はresponseのcookieにtokenをセットしている所で、`newCookie`で`XSRF-TOKEN`のcookieを作っている。

```php

    /**
     * Add the CSRF token to the response cookies.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Symfony\Component\HttpFoundation\Response  $response
     * @return \Symfony\Component\HttpFoundation\Response
     */
    protected function addCookieToResponse($request, $response)
    {
        $config = config('session');

        if ($response instanceof Responsable) {
            $response = $response->toResponse($request);
        }

        $response->headers->setCookie($this->newCookie($request, $config));

        return $response;
    }

    /**
     * Create a new "XSRF-TOKEN" cookie that contains the CSRF token.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  array  $config
     * @return \Symfony\Component\HttpFoundation\Cookie
     */
    protected function newCookie($request, $config)
    {
        return new Cookie(
            'XSRF-TOKEN',
            $request->session()->token(),
            $this->availableAt(60 * $config['lifetime']),
            $config['path'],
            $config['domain'],
            $config['secure'],
            false,
            false,
            $config['same_site'] ?? null,
            $config['partitioned'] ?? false
        );
    }
```

リクエスト内からcsrf_tokenを取得して返すメソッド
```php
    /**
     * Get the CSRF token from the request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return string|null
     */
    protected function getTokenFromRequest($request)
    {
        $token = $request->input('_token') ?: $request->header('X-CSRF-TOKEN');

        if (! $token && $header = $request->header('X-XSRF-TOKEN')) {
            try {
                $token = CookieValuePrefix::remove($this->encrypter->decrypt($header, static::serialized()));
            } catch (DecryptException) {
                $token = '';
            }
        }

        return $token;
    }

```

blade(Laravelのテンプレートエンジン)では、form内に`@csrf`を設定することでhiddenのinputが差し込まれてリクエストと一緒に検証用のtokenが埋め込まれる。

```php
<form method="POST" action="/profile">
    @csrf
 
    <!-- Equivalent to... -->
    <input type="hidden" name="_token" value="{{ csrf_token() }}" />
</form>


```



### FastAPIのCSRF対策
[FastAPI](https://fastapi.tiangolo.com/ja/)はPythonの軽量WEBフレームワークです。
FastAPIでのcsrf対策は[Starlette](https://www.starlette.io/)(Pythonで書かれた軽量なASGIフレームワーク)に実装されている機能を使用して行います。

[Starlette CSRF Middleware](https://github.com/frankie567/starlette-csrf)

以下はresponseのcookieにtokenをセットしている所です。
```python

    async def send(self, message: Message, send: Send, scope: Scope) -> None:
        request = Request(scope)
        csrf_cookie = request.cookies.get(self.cookie_name)

        if csrf_cookie is None:
            message.setdefault("headers", [])
            headers = MutableHeaders(scope=message)

            cookie: http.cookies.BaseCookie = http.cookies.SimpleCookie()
            cookie_name = self.cookie_name
            cookie[cookie_name] = self._generate_csrf_token()
            cookie[cookie_name]["path"] = self.cookie_path
            cookie[cookie_name]["secure"] = self.cookie_secure
            cookie[cookie_name]["httponly"] = self.cookie_httponly
            cookie[cookie_name]["samesite"] = self.cookie_samesite
            if self.cookie_domain is not None:
                cookie[cookie_name]["domain"] = self.cookie_domain  # pragma: no cover
            headers.append("set-cookie", cookie.output(header="").strip())

        await send(message)
 
```

csrf tokenの検証をし、無効であればエラーを返します
```python
    async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
        if scope["type"] not in ("http", "websocket"):  # pragma: no cover
            await self.app(scope, receive, send)
            return

        request = Request(scope)
        csrf_cookie = request.cookies.get(self.cookie_name)

        if self._url_is_required(request.url) or (
            request.method not in self.safe_methods
            and not self._url_is_exempt(request.url)
            and self._has_sensitive_cookies(request.cookies)
        ):
            submitted_csrf_token = await self._get_submitted_csrf_token(request)
            if (
                not csrf_cookie
                or not submitted_csrf_token
                or not self._csrf_tokens_match(csrf_cookie, submitted_csrf_token)
            ):
                response = self._get_error_response(request)
                await response(scope, receive, send)
                return

        send = functools.partial(self.send, send=send, scope=scope)
        await self.app(scope, receive, send)

```

### GinのCSRF対策
[Gin](https://gin-gonic.com/ja/)はGo言語におけるWEBフレームワークです。


[gin-csrf](https://github.com/utrack/gin-csrf)

token生成用メソッド
```go
// GetToken returns a CSRF token.
func GetToken(c *gin.Context) string {
	session := sessions.Default(c)
	secret := c.MustGet(csrfSecret).(string)

	if t, ok := c.Get(csrfToken); ok {
		return t.(string)
	}

	salt, ok := session.Get(csrfSalt).(string)
	if !ok {
		salt = uniuri.New()
		session.Set(csrfSalt, salt)
		session.Save()
	}
	token := tokenize(secret, salt)
	c.Set(csrfToken, token)

	return token
}
```


以下のMiddlewareメソッドがcsrf tokenの検証をし、無効であればエラーを返します
```go

// Middleware validates CSRF token.
func Middleware(options Options) gin.HandlerFunc {
	ignoreMethods := options.IgnoreMethods
	errorFunc := options.ErrorFunc
	tokenGetter := options.TokenGetter

	if ignoreMethods == nil {
		ignoreMethods = defaultIgnoreMethods
	}

	if errorFunc == nil {
		errorFunc = defaultErrorFunc
	}

	if tokenGetter == nil {
		tokenGetter = defaultTokenGetter
	}

	return func(c *gin.Context) {
		session := sessions.Default(c)
		c.Set(csrfSecret, options.Secret)

		if inArray(ignoreMethods, c.Request.Method) {
			c.Next()
			return
		}

		salt, ok := session.Get(csrfSalt).(string)

		if !ok || len(salt) == 0 {
			errorFunc(c)
			return
		}

		token := tokenGetter(c)

		if tokenize(options.Secret, salt) != token {
			errorFunc(c)
			return
		}

		c.Next()
	}
}


```

GinのUseで`csrf.Middleware`をセットして使います。
GETの時にcsrftokenを渡しておき、POSTにたどり着いた場合、Middlewareを抜けているので`"CSRF token is valid"`という状態になります。
```go
package main

import (
	"github.com/gin-contrib/sessions"
	"github.com/gin-contrib/sessions/cookie"
	"github.com/gin-gonic/gin"
	"github.com/utrack/gin-csrf"
)

func main() {
	r := gin.Default()
	store := cookie.NewStore([]byte("secret"))
	r.Use(sessions.Sessions("mysession", store))
	r.Use(csrf.Middleware(csrf.Options{
		Secret: "secret123",
		ErrorFunc: func(c *gin.Context) {
			c.String(400, "CSRF token mismatch")
			c.Abort()
		},
	}))

	r.GET("/protected", func(c *gin.Context) {
		c.String(200, csrf.GetToken(c))
	})

	r.POST("/protected", func(c *gin.Context) {
		c.String(200, "CSRF token is valid")
	})

	r.Run(":8080")
}
```


### IronのCSRF対策

[Iron](https://docs.rs/iron/latest/iron/)はRust言語におけるWEBフレームワークです。

[iron_csrf](https://github.com/heartsucker/iron-csrf)


トークンとクッキーの抽出と検証をbeforeでやり、afterでトークンの再生成とレスポンスの設定をやっています。
beforeは検証なので対象のメソッド(method::Post, method::Put, method::Patch, method::Delete)の時に動作します。これは後述のCSRFConfigで定義されます。

```rust
impl<P: CsrfProtection + 'static, H: Handler> Handler for CsrfHandler<P, H> {
    fn handle(&self, mut request: &mut Request) -> IronResult<Response> {
        // before
        let token_opt = self.extract_csrf_token(&mut request).and_then(|t| {
            self.protect.parse_token(&t).ok()
        });
        let cookie_opt = self.extract_csrf_cookie(&request).and_then(|c| {
            self.protect.parse_cookie(&c).ok()
        });

        if self.config.protected_methods.contains(&request.method) {
            debug!(
                "CSRF elements present. token: {}, cookie: {}",
                token_opt.is_some(),
                cookie_opt.is_some()
            );

            match (token_opt.as_ref(), cookie_opt.as_ref()) {
                (Some(token), Some(cookie)) => {
                    let verified = self.protect.verify_token_pair(&token, &cookie);
                    if !verified {
                        // TODO differentiate between server error and validation error
                        return Ok(Response::with((status::Forbidden, "CSRF Error")));
                    }
                }
                _ => return Ok(Response::with((status::Forbidden, "CSRF Error"))),
            }
        }

        let (token, csrf_cookie) = self.protect
            .generate_token_pair(
                cookie_opt
                    .and_then(|c| {
                        let c = c.value();
                        if c.len() < 64 {
                            None
                        } else {
                            let mut buf = [0; 64];
                            buf.copy_from_slice(&c);
                            Some(buf)
                        }
                    })
                    .as_ref(),
                self.config.ttl_seconds,
            )
            .map_err(iron_error)?;
        let _ = request.extensions.insert::<CsrfToken>(token);

        // main
        let mut response = self.handler.handle(&mut request)?;

        // after
        let cookie = Cookie::build(CSRF_COOKIE_NAME, csrf_cookie.b64_string())
            // TODO config for path
            .path("/")
            .http_only(true)
            .secure(self.config.secure_cookie)
            // TODO config flag for SameSite
            .max_age(Duration::seconds(self.config.ttl_seconds))
            .finish();
        let mut cookies = vec![format!("{}", cookie.encoded())]; // TODO is this formatting dumb?

        {
            if let Some(set_cookie) = response.headers.get::<SetCookie>() {
                cookies.extend(set_cookie.0.clone())
            }
        }
        response.headers.set(SetCookie(cookies));

        Ok(response)
    }
}

```

CSRFConfigのコードは以下です。
```rust
/// The configuation used to initialize `CsrfProtectionMiddleware`.
pub struct CsrfConfig {
    // TODO make this an Option
    ttl_seconds: i64,
    protected_methods: HashSet<method::Method>,
    secure_cookie: bool,
}

impl CsrfConfig {
    /// Create a new builder that is initialized with the default configuration.
    pub fn build() -> CsrfConfigBuilder {
        CsrfConfigBuilder { config: CsrfConfig::default() }
    }
}

impl Default for CsrfConfig {
    fn default() -> Self {
        let protected_methods: HashSet<method::Method> =
            vec![method::Post, method::Put, method::Patch, method::Delete]
                .iter()
                .cloned()
                .collect();
        CsrfConfig {
            ttl_seconds: 3600,
            protected_methods: protected_methods,
            secure_cookie: false,
        }
    }
}
```

アプリケーションとしての利用の仕方は、CsrfProtectionMiddlewareをnewしてmiddlewareとして設定しておきます。

```rust
extern crate csrf;
extern crate iron;
extern crate iron_csrf;

use csrf::{CsrfToken, AesGcmCsrfProtection};
use iron::AroundMiddleware;
use iron::prelude::*;
use iron::status;
use iron_csrf::{CsrfProtectionMiddleware, CsrfConfig};

fn main() {
    // Set up CSRF protection with the default config
    let key = *b"01234567012345670123456701234567";
    let protect = AesGcmCsrfProtection::from_key(key);
    let config = CsrfConfig::default();
    let middleware = CsrfProtectionMiddleware::new(protect, config);

    // Set up routes
    let handler = middleware.around(Box::new(index));

    // Make and start the server
    Iron::new(handler); //.http("localhost:8080").unwrap();
}
```

レスポンスにtokenを返します。
```rust
fn index(request: &mut Request) -> IronResult<Response> {
    let token = request.extensions.get::<CsrfToken>().unwrap();
    let msg = format!("Hello, CSRF Token: {}", token.b64_string());
    Ok(Response::with((status::Ok, msg)))
}

```

### Ruby on RailsのCSRF対策

[Ruby on Rails](https://rubyonrails.org/)はRuby言語におけるWEBフレームワークです。

[request_forgery_protection](https://github.com/rails/rails/blob/main/actionpack/lib/action_controller/metal/request_forgery_protection.rb)

`form_authenticity_token`にてcsrf tokenを生成されます。
```ruby
      # Creates the authenticity token for the current request.
      def form_authenticity_token(form_options: {}) # :doc:
        masked_authenticity_token(form_options: form_options)
      end

      # Creates a masked version of the authenticity token that varies
      # on each request. The masking is used to mitigate SSL attacks
      # like BREACH.
      def masked_authenticity_token(form_options: {})
        action, method = form_options.values_at(:action, :method)

        raw_token = if per_form_csrf_tokens && action && method
          action_path = normalize_action_path(action)
          per_form_csrf_token(nil, action_path, method)
        else
          global_csrf_token
        end

        mask_token(raw_token)
      end
 
```

`protect_from_forgery`メソッドにて`before_action`として`verify_authenticity_token`が定義されます。
ここでtokenの検証がされます。
```ruby

      def protect_from_forgery(options = {})
        options = options.reverse_merge(prepend: false)

        self.forgery_protection_strategy = protection_method_class(options[:with] || :null_session)
        self.request_forgery_protection_token ||= :authenticity_token

        self.csrf_token_storage_strategy = storage_strategy(options[:store] || SessionStore.new)

        before_action :verify_authenticity_token, options
        append_after_action :verify_same_origin_request
      end

      def verify_authenticity_token # :doc:
        mark_for_same_origin_verification!

        if !verified_request?
          logger.warn unverified_request_warning_message if logger && log_warning_on_csrf_failure

          handle_unverified_request
        end
      end
            
      # Returns true or false if a request is verified. Checks:
      #
      # * Is it a GET or HEAD request? GETs should be safe and idempotent
      # * Does the form_authenticity_token match the given token value from the params?
      # * Does the +X-CSRF-Token+ header match the form_authenticity_token?
      def verified_request? # :doc:
        !protect_against_forgery? || request.get? || request.head? ||
          (valid_request_origin? && any_authenticity_token_valid?)
      end
```

アプリケーションとしての利用の仕方は、デフォルトで`config.action_controller.default_protect_from_forgery = true`になっているので最初から動いている。

erb(テンプレートエンジン)で以下を書いておく
```erb
<head>
  <%= csrf_meta_tags %>
</head>
```

そうすると、以下のようにHTMLが生成される。
```erb
<head>
  <meta name="csrf-param" content="authenticity_token" />
  <meta name="csrf-token" content="生成されたトークン" />
</head>
```


## まとめ
同じCSRF対策という課題解決においても色んな手段や機能提供がされていました。
各FWにおける機能の実装を比較すると、プログラミング言語やFWの性質によってアプローチやメソッドの設計などの違いが読み解けて面白かったです。