10
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

この記事はHajimari Advent Calendar 2023の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はPHPのWEBフレームワークです。
Middlewareで提供されているVerifyCsrfTokenがリクエストの間に入ることで実現します。

VerifyCsrfToken

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


    /**
     * 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を取得して返すメソッド

    /**
     * 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が埋め込まれる。

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


FastAPIのCSRF対策

FastAPIはPythonの軽量WEBフレームワークです。
FastAPIでのcsrf対策はStarlette(Pythonで書かれた軽量なASGIフレームワーク)に実装されている機能を使用して行います。

Starlette CSRF Middleware

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


    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の検証をし、無効であればエラーを返します

    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はGo言語におけるWEBフレームワークです。

gin-csrf

token生成用メソッド

// 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の検証をし、無効であればエラーを返します


// 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"という状態になります。

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はRust言語におけるWEBフレームワークです。

iron_csrf

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

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のコードは以下です。

/// 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として設定しておきます。

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を返します。

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はRuby言語におけるWEBフレームワークです。

request_forgery_protection

form_authenticity_tokenにてcsrf tokenを生成されます。

      # 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の検証がされます。


      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(テンプレートエンジン)で以下を書いておく

<head>
  <%= csrf_meta_tags %>
</head>

そうすると、以下のようにHTMLが生成される。

<head>
  <meta name="csrf-param" content="authenticity_token" />
  <meta name="csrf-token" content="生成されたトークン" />
</head>

まとめ

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

10
7
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
10
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?