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 定義することで設定できる。

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

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

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

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

```html: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 メソッドを作る。

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

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

```java
@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 メソッドを作る。

```html:hello.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Hello</title>
</head>
<body>
<h1>ログインできたよ</h1>
</body>
</html>
```

```java
@Controller
public class HelloController {

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

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

```

# 事象確認

アプリケーションを起動してアクセスするとエラーが発生する。
（正確にはエラーが発生してエラー画面へリダイレクトするが、そこでもエラーが発生してリダイレクトがループしている。）
![エラー.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/133607/d4d87ea2-dffb-f04a-17db-967c69df14a1.png)


ログを見ると `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` にはセミコロンを許容するように設定を変更することが可能。

```java
@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` を利用するように設定を変更する。

```java
@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 を施したうえでアクセスすると、ログイン画面が表示できる。
![login.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/133607/cb001b0d-ca71-eebb-814a-afea52079079.png)

ユーザ名:user 、パスワード:user でログインすることができる。
![logindekita.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/133607/cf8e75bd-b798-bf7b-de8f-2a46e1b737ca.png)

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

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