Spring Security 使い方メモ CSRF

  • 13
    いいね
  • 0
    コメント

基礎・仕組み的な話
認証・認可の話
Remember-Me の話
セッション管理の話
レスポンスヘッダーの話
メソッドセキュリティの話
CORS の話
Run-As の話
ACL の話
テストの話
MVC, Boot との連携の話

番外編
Spring Security にできること・できないこと

CSRF とは Cross-site Request Forgery の略。
Forgery は「偽造」という意味らしい。

この攻撃が何なのかとか、対策方法については IPA のサイト とか Wikipedia とかを参照のこと。

Spring Security での CSRF 対策

Spring Security で namespace や Java Configuration を使用した場合は、デフォルトで CSRF 対策が有効になる。

CSRF 対策が有効になった場合、 GET, HEAD, TRACE, OPTIONS 以外 の HTTP メソッド(POST, PUT, DELETE, PATCH など)でリクエストが来た場合にトークンのチェックが行われるようになる。

対象外となっているメソッドは、HTTP の仕様ではサーバーの状態を変更すべきでないということになっている。
なので、ちゃんと HTTP の仕様に沿って Web サービスを作っていれば、 GETHEAD などを CSRF の保護対象にする必要はないことになる(必要があるとすれば、それは Web サービスのほうがなんか変)。

トークンの連携方法

デフォルトだと、トークンは _csrf という名前でリクエストスコープに保存されている。

このトークンは CsrfToken オブジェクトで、次のようなメソッドを持つ。

CsrfToken.java
package org.springframework.security.web.csrf;

import java.io.Serializable;

public interface CsrfToken extends Serializable {
    String getHeaderName();
    String getParameterName();
    String getToken();
}

getParameterName() でリクエストパラメータで渡すときのパラメータ名を取得でき、 getToken() で実際のトークンの値が取得できる。

よって、 EL式などを使えば次のようにしてトークンをパラメータとして埋め込むことができるようになる。

index.jsp
<%@taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<%@page contentType="text/html" pageEncoding="UTF-8"%>
<!doctype html>
<html>
    <head>
        <meta charset="utf-8" />
        <title>Hello Spring Security!!</title>
    </head>
    <body>
        <h1>Hello Spring Security!!</h1>

        <c:url var="logoutUrl" value="/logout" />
        <form action="${logoutUrl}" method="post">
            <input type="submit" value="logout" />
            <input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}" /> ★ここでトークンを埋め込んでいる
        </form>
    </body>
</html>

HTTP ヘッダーでトークンを送信する

Ajax などでボディは JSON にする場合、上記のような方法でトークンを渡すことができない。

その場合は HTTP ヘッダーでトークンを渡す。

ヘッダー名は ${_csrf.headerName} で取得できる。
あとは、ヘッダー名とトークンの値を HTTP 内に埋め込んで置き、 JavaScript で取り出して Ajax リクエストのヘッダーにセットすればいい。

トークンの値

トークンの値は、 UUID.randomUUID() で生成されている。

最初これを見たとき「あれ、 SecureRandom を使わなくてええんか?」と思った。
しかし、 UUID.randomUUID() の Javadoc を見てみると次のように説明されている。

このUUIDは、暗号強度の高い擬似乱数ジェネレータを使って生成されます。

https://docs.oracle.com/javase/jp/8/docs/api/java/util/UUID.html#randomUUID--

実際ソースを見てみると、 SecureRandom を使っている。
なので、別に UUID.randomUUID() でも問題ないっぽい。

トークンが生成されるタイミング

前述の IPA の解説ページ などを見てみると、トークンを生成するタイミングがいくつか紹介されている。

照合情報には、他者が推測困難な値を用いる。そのような値として下記の 3つが挙げられる。
・セッションIDそのもの もしくは セッションIDから導かれるハッシュ値 等
・セッション開始時に一度生成され各ページで使われる、セッションIDとは無関係の値
・ページごとに生成される、毎回異なる値(ページトークン)

自分は最初、トークンは3つ目の「ページごと」に生成するものだと思っていた。
しかし、実際に Spring Security の CSRF 対策を動かしてみると、トークンは一度ログインしたあとはログアウトするまで同じ値が使いまわされていることに気付いた。

つまり、 Spring Security は2つ目の方法を採用していることになる。

調べてみると、ページごとにトークンを生成するのは、使いやすさの面で問題になるらしい。
こちらの stackoverflow によると、ユーザーがブラウザの「戻るボタン」を使ったときを考慮しだすと話が複雑になるらしい。

ブラウザの「戻るボタン」を使って前の画面に戻ると、クライアント側のページに埋め込まれているトークンが古い状態になってしまう。
そのため次にアクションを起こすと、アプリケーションは CSRF 攻撃が行われたと誤認してしまうことになる。

ページごとにトークンを生成するとなると、トークンの管理が大変になるっぽい。

エラーハンドリング

不正なトークンが渡ってきた場合、デフォルトだとサーバーのエラーページが表示される。

spring-security.jpg

このままだといろいろアレなので、エラーハンドリングを実装する。

エラーページの指定

CSRF トークンのチェックは CsrfFilter によって行われる。
そして、もしトークンに問題がある場合は AccessDeniedHandlerImpl にエラー処理が委譲される。

AccessDeniedHandlerImpl には errorPage というプロパティがあり、エラー時のフォワード先を指定できる。

access-denied.html
<!doctype html>
<html>
    <head>
        <meta charset="utf-8" />
        <title>不正リクエスト</title>
    </head>
    <body>
        <h1>不正なリクエストを検知しました</h1>
    </body>
</html>

namespace

applicationContext.xml
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:sec="http://www.springframework.org/schema/security"
       ...>

    <sec:http>
        ...
        <sec:access-denied-handler error-page="/access-denied.html" />
    </sec:http>

    ...
</beans>
  • <access-denied-handler> タグを追加し、 error-page 属性で指定する。

Java Configuration

MySpringSecurityConfig.java
...

@EnableWebSecurity
public class MySpringSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                ...
                .and()
                .exceptionHandling()
                .accessDeniedPage("/access-denied.html");
    }

    ...
}
  • exceptionHandling().accessDeniedPage(String) で指定する

spring-security.jpg

CSRF トークンを不正な値にしてエラーを発生させると、指定されたページにフォワードされた。

AccessDeniedHandlerImpl通常の権限チェックのエラーハンドリングでも利用されるので、どちらのエラーでも不自然じゃないメッセージにしておかないといけない点に注意

どうしても2つのエラーハンドリングの実装クラスを分けたい場合は、 CsrfFilterExceptionTranslationFilter のそれぞれの AccessDeniedHandler を直接上書きするように BeanPostProcessor を実装するみたいなことをすればいいと思う。

実装を指定する

MyAccessDeniedHandler.java
package sample.spring.security.handler;

import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;

public class MyAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        if (response.isCommitted()) {
            return;
        }

        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        try (PrintWriter writer = response.getWriter()) {
            writer.println("Access Denied!!");
        }
    }
}
  • AccessDeniedHandler を実装したクラスを用意する
  • handle() メソッドの第三引数には発生した例外オブジェクトが渡される
    • CSRF トークンのエラーの場合は MissingCsrfTokenExceptionInvalidCsrfTokenException になる
    • MissingCsrfTokenException はサーバーで保存しているはずのトークンが見つからなかった場合に渡ってくる(主にセッション切れ)
    • InvalidCsrfTokenException はクライアントから来たトークンとサーバーが保存しているトークンに差異があった場合に渡ってくる
    • どちらのクラスも CsrfException を継承している

namespace

applicationContext.xml
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:sec="http://www.springframework.org/schema/security"
       ...>

    <bean id="accessDeniedHandler" class="sample.spring.security.handler.MyAccessDeniedHandler" />

    <sec:http>
        ...
        <sec:access-denied-handler ref="accessDeniedHandler" />
    </sec:http>

    ...
</beans>
  • <access-denied-handler> タグの ref 属性で Bean を指定する

Java Configuration

MySpringSecurityConfig.java
...

import sample.spring.security.handler.MyAccessDeniedHandler;

@EnableWebSecurity
public class MySpringSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                ...
                .and()
                .exceptionHandling()
                .accessDeniedHandler(new MyAccessDeniedHandler());
    }

    ...
}
  • accessDeniedHandler(AccessDeniedHandler) で指定する

spring-security.jpg

MyAccessDeniedHandler によって生成したレスポンスが出力された。

セッションタイムアウト問題

デフォルトだと、 CSRF 対策用のトークンはセッションに保存される。
よって、セッションタイムアウトが発生するとトークンが取得できずに、 CSRF のトークンチェックのエラーが発生する。

spring-security.jpg

この場合は、セッション切れなので単純にログインページに遷移させたい。
しかし、 AccessDeniedHanlderImplerrorPage を指定する方法だとセッション切れと不正トークンを区別できないので、両者を区別してハンドリングしたい場合は必然的に AccessDeniedHandler を自作することになりそう。

MyAccessDeniedHandlerImpl.java
package sample.spring.security.handler;

import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.DefaultRedirectStrategy;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.csrf.MissingCsrfTokenException;

import javax.servlet.RequestDispatcher;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

public class MyAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        if (response.isCommitted()) {
            return;
        }

        if (accessDeniedException instanceof MissingCsrfTokenException) {
            DefaultRedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
            redirectStrategy.sendRedirect(request, response, "/login");
        } else {
            RequestDispatcher dispatcher = request.getRequestDispatcher("/access-denied.html");
            dispatcher.forward(request, response);
        }
    }
}

例外が MissingCsrfTokenException の場合だけログインページにリダイレクトさせて、それ以外は専用のエラーページに遷移させる。

MyAccessDeniedHandler の設定方法はエラーの説明のところを参照

不正なトークンを受け取った場合

spring-security.jpg

セッション切れの場合

spring-security.jpg

ログインのときもセッションタイムアウトの可能性がある

もしログインのときに CSRF のチェックをしていない場合、知らない間に攻撃者が用意したアカウントでログインさせられるかもしれない。
そして、そうと気付かずログイン後に個人情報に関わる入力をしたとする。
攻撃者は後で同じアカウントでログインし、被害者が知らずに入力した個人情報を履歴の照会機能などを利用して参照できてしまうかもしれない。

といった攻撃があるかもしれないので、 CSRF のチェックはログインのときにも行ったほうが良いとされているらしい。

しかし、前述したとおりデフォルトだと CSRF トークンはセッションに保存される。
そのため、ログイン画面を開いた後にセッションが切れるまで放置してからログインを行うと、 CSRF トークンがセッションから取得できずエラーになってしまう。

この場合、ログイン画面に戻したりエラー画面に遷移させるというのはちょっと使い勝手が悪い。

Spring Security のリファレンスでは、「Submit 前に JavaScript でトークンを取ってくるのが一般的だよ!」みたいなことが書かれている。

A common technique to protect the log in form is by using a JavaScript function to obtain a valid CSRF token before the form submission.
(訳)
ログインフォームを守る一般的なテクニックは、フォームを Submit する前に JavaScript の関数で有効な CSRF トークンを取得するという方法です。

18.5.2 Logging In

しかし、 JavaScript 側の具体的なソースが記載されていない。

仕方ないので想像で書いてみた。

フォルダ構成
`-src/main/
  |-java/
  |  `-sample/spring/security/servlet/
  |    `-CsrfTokenServlet.java
  |
  `-webapp/
    |-js/
    |  |-jquery.min.js
    |  `-login.js
    |
    |-WEB-INF/
    |  `-applicationContext.xml
    |
    `-login.jsp
applicationContext.xml
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:sec="http://www.springframework.org/schema/security"
       ...>

    ...

    <sec:http>
        <sec:intercept-url pattern="/login.jsp" access="permitAll" />
        <sec:intercept-url pattern="/js/**" access="permitAll" />
        <sec:intercept-url pattern="/api/csrf-token" access="permitAll" />
        <sec:intercept-url pattern="/**" access="isAuthenticated()" />
        <sec:form-login login-page="/login.jsp" />
        ...
    </sec:http>

    ...
</beans>
  • 未ログインの状態でもアクセスできるように、 /js/**/api/csrf-token (CSRF トークンを取得するためのエンドポイント)を permitAll で定義している。
  • また、ログイン画面を自作のもの(login.jsp)に置き換えるので <form-login>login-page 属性で指定している。
login.jsp
<%@taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<%@page contentType="text/html" pageEncoding="UTF-8"%>
<!doctype html>
<html>
    <head>
        <meta charset="utf-8" />
        <title>Login</title>
    </head>
    <body>
        <h1>Login</h1>

        <c:url var="loginUrl" value="/login" />
        <form id="form" action="${loginUrl}" method="post">
            <div>
                Username : <input type="text" name="username" />
            </div>
            <div>
                Password : <input type="password" name="password" />
            </div>

            <input type="button" id="loginButton" value="Login" />

            <input type="hidden" id="csrfToken" name="${_csrf.parameterName}" />
        </form>

        <c:url var="jsDirUrl" value="/js" />
        <script src="${jsDirUrl}/jquery.min.js"></script>
        <script src="${jsDirUrl}/login.js"></script>
    </body>
</html>
  • CSRF トークンをセットする hidden は value を空にしておき、 Submit するときに設定する。
login.js
$(function() {
    $('#loginButton').on('click', function() {
        $.ajax({
            url: 'api/csrf-token',
            type: 'GET'
        })
        .done(function(token) {
            $('#csrfToken').val(token);
            $('#form').submit();
        });
    });
});
  • loginButton がクリックされたら Ajax で CSRF トークンを取得し、 hidden に設定してからフォームを submit している。
CsrfTokenServlet.java
package sample.spring.security.servlet;

import org.springframework.security.web.csrf.CsrfToken;

import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;

@WebServlet("/api/csrf-token")
public class CsrfTokenServlet extends HttpServlet {

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        CsrfToken token = (CsrfToken) req.getAttribute(CsrfToken.class.getName());
        try (PrintWriter writer = resp.getWriter()) {
            writer.print(token.getToken());
        }
    }
}
  • CsrfToken はリクエストスコープに保存されているので、それを取得して getToken() で生のトークン値を取得、レスポンスとして返している
  • なお、リファレンスでは CsrfTokenArgumentResolver を使って Spring MVC で実装する例が紹介されている
    • 普通はこっちの実装になると思う。

これで、ログイン直前でトークンを取得するようになるのでセッションが切れた後でもスムーズにログインできるようになる。
実際に使う場合は実装の細かいところを修正する必要があるだろうが、基本的な流れはこれで合っているのだろうか?

セッションを使わず Cookie を使う

そもそもトークンの情報をセッションに保存しているために、タイムアウトという現象が発生している。

Spring Security には CSRF 対策用トークンを Cookie に保存する方法も用意されている。
なので、こちらを利用すればセッションタイムアウトについて気にする必要はなくなる。

ではなぜデフォルトで Cookie を使っていないのかというと、セッションよりも若干セキュリティのレベルが下がるかららしい。

若干というのは、

  • Flash を使った特殊な攻撃が実行されたときに、 Cookie を使っていると CSRF 対策が有効に働かなくなることがあった
  • 仮にトークンが盗まれた場合でも、セッションを使っていれば最悪セッションを無効にすれば即座にトークンを無効にすることができる

というぐらい。

Flash を使った攻撃方法については こちら に解説が載っている。

結構前(2011年2月)の話だが、 Ruby on Rails とかもこの問題に対して対策を講じたりしていたらしい。

2017年現在どうなっているのか調べようとしたが、調べ方がよくないのか明確な情報は見つけられなかった。
Flash Player のリリースノート とかも見てみたが、 10.2 付近にそれっぽいアップデートは見当たらなかった。。。

ただ、 Spring Security のリファレンスには Cookie を使うことについて次のように説明している。

As previously mentioned, this is not as secure as using a session, but in many cases can be good enough.
(訳)
前述のとおり、これ(Cookie を使うこと)はセッションほど安全ではありません。しかし、多くの場合でそれは十分に良いものです。

https://docs.spring.io/spring-security/site/docs/current/reference/htmlsingle/#csrf-timeouts

さて、これをどう判断すべきか。。。

Cookie を使う場合は、次のように実装する。

namespace

applicationContext.xml
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:sec="http://www.springframework.org/schema/security"
       ...>

    ...

    <bean id="tokenRepository"
          class="org.springframework.security.web.csrf.CookieCsrfTokenRepository" /><sec:http>
        ...
        <sec:csrf token-repository-ref="tokenRepository" /></sec:http>

    ...
</beans>

CookieCsrfTokenRepository を Bean として定義し、 <csrf> タグの token-repository-ref 属性で指定する。

Java Configuration

package sample.spring.security;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;

import java.util.Collections;

@EnableWebSecurity
@ComponentScan
public class MySpringSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/login").permitAll()
                .anyRequest().authenticated()
                .and()
                .formLogin()
                .and()
                .csrf().csrfTokenRepository(new CookieCsrfTokenRepository()); 
    }

    ...
}

.csrf().csrfTokenRepository()CookieCsrfTokenRepository のインスタンスを渡す。

spring-security.jpg

Cookie に XSRF-TOKEN が追加され、そこにトークンの値が保存されている。

ちなみに、デフォルトだと httpOnly が true になるので JavaScript からこの Cookie にアクセスすることはできないようになっている。
JavaScript のフレームワークとかを使っていてどうしても JavaScript から Cookie を触れないと困るような場合は、この設定を
false にしてあげる必要がある(リファレンスでは AngularJS を例に挙げているっぽい)。

namespace

applicationContext.xml
    <bean id="tokenRepository"
          class="org.springframework.security.web.csrf.CookieCsrfTokenRepository">
        <property name="cookieHttpOnly" value="false" /></bean>

CookieCsrfTokenRepositorycookieHttpOnly プロパティを false にする。

Java Configuration

MySpringSecurityConfig.java
    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                ...
                .csrf().csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()); 
    }

CookieCsrfTokenRepository.withHttpOnlyFalse() でインスタンスを生成する。

secure 属性の指定

通信が HTTPS だと勝手に secure 属性が有効になる模様。
CookieCsrfTokenRepository の実装を見ると次のようになっている。

CookieCsrfTokenRepository.java
    @Override
    public void saveToken(CsrfToken token, HttpServletRequest request,
            HttpServletResponse response) {
        ...
        Cookie cookie = new Cookie(this.cookieName, tokenValue);
        cookie.setSecure(request.isSecure()); ここ
        ...

    }

参考