この記事は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
がリクエストの間に入ることで実現します。
以下はresponseのcookieにtokenをセットしている所で、newCookie
でXSRF-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フレームワーク)に実装されている機能を使用して行います。
以下は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フレームワークです。
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フレームワークです。
トークンとクッキーの抽出と検証を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フレームワークです。
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の性質によってアプローチやメソッドの設計などの違いが読み解けて面白かったです。