Spring Security を利用していると JSESSIONID を URL に付与できなかった


JSESSIONID を URL に含めているようなアプリケーションで Spring Security のバージョンアップを行うとエラーが発生するようになった。
その原因について調査したのでまとめる。

え、今時 JSESSIONID を URL に含めることなんかないって?

環境

  • Spring Boot 2.1.6.RELEASE
  • (Spring Security 5.1.5.RELEASE)
  • Thymeleaf 3.0.11.RELEASE

事象を再現させる

事象を再現させるにはいくつか準備が必要。

JSESSIONID を URL で管理する

Cookie が利用可能なブラウザの場合は Cookie を利用して JSESSIONID を管理してしまう。
強制的に URL で管理するようにサーブレットコンテナの設定を変更する。

Spring Boot を用いた場合は以下のように ServletContextInitializer を Bean 定義することで設定できる。

@Bean
public ServletContextInitializer servletContextInitializer() {
    return servletContext -> servletContext.setSessionTrackingModes(EnumSet.of(SessionTrackingMode.URL));
}

URL Rewriting を有効化する

Spring Security では、URL Rewriting を無効化する機能がデフォルトで設定されている。
以下のように、 WebSecurityConfigurerAdapter を継承したクラスで設定を変更する。

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.sessionManagement().enableSessionUrlRewriting(true);
    }
}

ログインページを作る

Spring Security がデフォルトで提供しているログインページでは URL Rewriting が利用できないので、ログインページを作る。

login.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>login</title>
</head>
<body>
<form th:action="@{/login}" method="post">
    <div>
        <label>ユーザー名: <input type="text" name="username"/></label>
    </div>
    <div>
        <label>パスワード: <input type="password" name="password"/></label>
    </div>
    <input type="submit" value="login"/>
</form>
</body>
</html>

さらにこのページを表示するための Controller メソッドを作る。

@Controller
public class HelloController {
    @GetMapping("/login")
    public String login() {
        return "login";
    }
}

最後に設定を追加する。
ついでに、ログインに利用するユーザ名とパスワードを指定している。

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests().antMatchers("/login").permitAll().anyRequest().authenticated()
                .and()
                .formLogin().loginPage("/login")
                .and()
                .sessionManagement().enableSessionUrlRewriting(true);
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication().withUser("user").password("{noop}user").roles("ADMIN");
    }
}

デフォルトで DelegatingPasswordEncoder が利用されるため、パスワードには prefix が必要。今回は平文なので {noop} を付与している。

DelegatingPasswordEncoder について調べたことは以下にまとめている。
https://qiita.com/d-yosh/items/bb52152318391e5e07aa

ログイン後のページを作る

これも html と Controller メソッドを作る。

hello.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Hello</title>
</head>
<body>
<h1>ログインできたよ</h1>
</body>
</html>
@Controller
public class HelloController {

    @GetMapping("/")
    public String index() {
        return "hello";
    }

    @GetMapping("/login")
    public String login() {
        return "login";
    }
}

事象確認

アプリケーションを起動してアクセスするとエラーが発生する。
(正確にはエラーが発生してエラー画面へリダイレクトするが、そこでもエラーが発生してリダイレクトがループしている。)

ログを見ると RequestRefectedException が発生していることが確認できる。

org.springframework.security.web.firewall.RequestRejectedException: The request was rejected because the URL contained a potentially malicious String ";"

原因

Spring Security は HttpFirewall によって、リクエストのチェックを行っており、その実装の 1つである StrictHttpFirewall では、セミコロンを含む URL を拒否するようになっている。
JSESSIONID を URL に含める場合はセミコロンが URL に付与されるため、StrictHttpFirewall によって拒否されて例外が発生している。

Spring Security のバージョンによってデフォルトで利用する HttpFirewall が異なっていて、かつては DefaultHttpFirewall が利用されていた。
このクラスは、URL にセミコロンが含まれていてもリクエストを拒否することはない。
今回はバージョンを挙げた際に、デフォルトの HttpFirewall が変わったことによってエラーが発生するようになってしまった。

対処1

StrictHttpFirewall にはセミコロンを許容するように設定を変更することが可能。

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    // 諸々省略・・・

    @Override
    public void configure(WebSecurity web) throws Exception {
        StrictHttpFirewall firewall = new StrictHttpFirewall();
        firewall.setAllowSemicolon(true);
        web.httpFirewall(firewall);
    }

}

対処2

DefaultHttpFirewall を利用するように設定を変更する。

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    // 諸々省略・・・

    @Override
    public void configure(WebSecurity web) throws Exception {
        DefaultHttpFirewall firewall = new DefaultHttpFirewall();
        web.httpFirewall(firewall);
    }
}

対処確認

対処1 or 2 を施したうえでアクセスすると、ログイン画面が表示できる。

ユーザ名:user 、パスワード:user でログインすることができる。

ちなみにログイン前後で JSESSIONID が変わっているのは、Spring Security のセッション固定化攻撃対策が有効になっているから。
https://docs.spring.io/spring-security/site/docs/5.1.6.RELEASE/reference/htmlsingle/#ns-session-fixation

さいごに

セッションを URL で管理してはいけません。