404ページでCSRFトークンをセットすると稀に環境依存のバグが起こるかもしれない

## ことの発端
あらゆるページにログインフォームがあるサイトの開発環境をWindows上のApacheからWSL2のDockerに移行したところ、なぜかログインができない。。さらにログインできないのはChromeだけでFirefoxやEdgeだと問題がない。。。なぜ？

よくよく調べるとChromeの場合、CSRF対策のために発行していた**トークン文字列がなぜか書き換えられている**ことが判明しました。そこから四苦八苦しながら調査を行い、一応の原因と対処法を突き止めることができたのでここに記録しておきます。

## 問題を再現する最小限の構成 

### ブラウザ
Google Chrome 85

### サーバー環境
* Docker Desktop for Windows
* WSL2 (Ubuntu20.04)
* php:php7.4-apache イメージ

### ソースファイル
```:ディレクトリ構成
ドキュメントルート
    ├─ .htaccess
    ├─ 404.php
    └─ index.php
```

```apache:.htaccess
ErrorDocument 404 /404.php
```

```php:404.php
<?php
session_start();
$_SESSION['token'] = '404 Not Found!';
```

```php:index.php
<?php session_start(); ?>
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Test</title>
</head>
<body>
<?php
    if(isset($_POST['token']) && isset($_SESSION['token'])){
        echo "POST: {$_POST['token']} <br>";
        echo "SESSION: {$_SESSION['token']} <br>";
    }
    $_SESSION['token'] = rtrim(base64_encode(openssl_random_pseudo_bytes(32)),'=');
?>
    <form name="testform" method="post" action="">
        token: <input type="text" name="token" value="<?php echo $_SESSION['token']; ?>"> <br>
        <button type="submit">テスト</button>
    </form>
</body>
</html>
```

`index.php`にアクセスしてテストボタンを押すと、送信されたトークンとセッション変数に格納されたトークンが表示されます。本来ならば一致しているはずのそれぞれの値が、なぜかセッション変数のほうが`404 Not Found!`の文字列になることがあります。

![テスト用コードの実行例](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/617255/5d09dab9-b864-3f74-fbe7-0bd483a339e0.png)


## 発生する問題の特性

### 環境に依存する
* ブラウザ
    * デスクトップのEdge、Firefoxを試しましたが発生しませんでした[^favicon]
* サーバー環境
    * 移行前の開発環境ではChromeでも発生しませんでした
    * 本番環境（非Docker環境）でももちろん発生していません

### 送信間隔が短いと発生しない
`index.php`のテストボタンを連打するような場合（実際のサイトならありえない速度で必要情報を入力して送信するようなケース）では起きないことが多かったです。そこで、下記のコードを利用して送信間隔と問題発生の相関を大まかに調べる実験を行いました。

```php:test.php
<?php
session_start();

$interval = 1200; // ms
$trial = 50; // 試行回数
$remaining = $trial;

if(isset($_POST['remaining'])){
    $remaining = intval($_POST['remaining']) - 1;
}else{
    $_SESSION['wrong_sessions'] = 0;
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
<?php
    if(isset($_POST['token']) && isset($_SESSION['token'])){
        echo "POST: {$_POST['token']} <br>";
        echo "SESSION: {$_SESSION['token']} <br>";
        if($_POST['token'] != $_SESSION['token']){
            $_SESSION['wrong_sessions']++;
        }
    }
    $_SESSION['token'] = rtrim(base64_encode(openssl_random_pseudo_bytes(32)),'=');
?>
    <form name="testform" method="post" action="">
        token: <input type="text" name="token" value="<?php echo $_SESSION['token']; ?>"> <br>
        remaining: <input type="text" name="remaining" value="<?php echo $remaining; ?>"> <br>
        <button type="submit">テスト</button>
    </form>
    <button type="button" onClick="location.href=''">再試行</button>
<?php if($remaining > 0): ?>
    <script>
        setTimeout(function(){
            document.forms.testform.submit();
        }, <?php echo $interval; ?>);
    </script>
<?php else:
    // interval, trial, wrong
    file_put_contents('result.csv', "$interval, $trial, {$_SESSION['wrong_sessions']}\n", FILE_APPEND);
endif; ?>
</body>
</html>
```

結果は次のグラフに示す通りです。各インターバルそれぞれにつき50回ずつ試行し、セッション変数の書き換えが発生した割合を示しています。1000ms前後で発生割合が著しく変化していることが分かりました。

![送信間隔に対する問題発生率](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/617255/9a044681-ad01-e322-5cf9-f6e1f255f093.png)

### セットされていないセッション変数が新たに追加されることはない
セッション変数の書き換えは行われますが、新規追加は行われません。例えば、`404.php`のセッション変数のインデックスを'token'以外にしたとしても追加はされません。

## 原因と対処法

### 原因

今回の問題は、[PHP、google chromeでだけ不具合（ErrorDocument 404が原因）](http://piyopiyocs.blog115.fc2.com/blog-entry-47.html)と類似していることから、404エラーページ周辺に絞って原因究明を進めました。（実際には`index.php`と`404.php`で同じファイルをインクルードする等していたため、ここにたどり着くまでにかなり難儀しました。）

最終的に、原因は**Chromeが存在しないsw.js[^swjs]を暗黙的に読み込もうとしているから**でした。つまり、問題発生までは

1. `index.php`を読み込む（セッション変数をセット）
2. Chromeがsw.jsを読み込もうとするが存在しない
3. 404エラーが発生し、エラードキュメントの`404.php`が返される（セッション変数が上書きされる）
4. フォームの値を送信すると、セッション変数が`404.php`で書き換えられたもののためトークンが一致しない

というような流れになっていると推測できます。試しに、`404.php`で`$_SESSION['token'] = $_SERVER['REQUEST_URI'];`とすると、セッション変数が`/sw.js`に書き換えられることからsw.jsがリクエストされていることが分かります。

[送信間隔が短いと発生しない](#送信間隔が短いと発生しない)のはsw.jsが非同期的に読み込まれているからでしょうか？詳しくないのであくまで憶測でしかないですが。
[環境に依存する](#環境に依存する)ことと[セットされていないセッション変数が新たに追加されることはない](#セットされていないセッション変数が新たに追加されることはない)の理由は全く分かっていません。サーバーサイドの環境依存性についてはChromeのネットワークログ（chrome://net-export/）を利用して本番と同様の環境でのリクエストを解析しましたが、sw.jsのリクエストはありませんでした。

### 対処法
対処法として

* sw.jsをドキュメントルートに設置する
* `.htaccess`に`ErrorDocument 404...`の記述をしない
* `.htaccess`でsw.jsへのアクセスを拒否する

のいずれかを行うことは効果がありました。`.htaccess`でsw.jsへのアクセスを拒否する場合は次のような記述です。この場合はErrorDocumentの指定はあっても問題ありません。

```apache
<Files ~ "^sw\.js$">
    Require all denied
</Files>
```

## まとめ
* Chromeは環境によってはsw.jsを暗黙的にリクエストすることがある
* 存在しないsw.jsへのリクエストが発生した場合に、404エラードキュメントでセッション変数をセットしていると意図しない上書きが発生する
* sw.jsを存在させるか、アクセスを拒否するなどで対策が可能

今回はChromeがsw.jsを暗黙的に読み込むために問題が発生しましたが、読み込む内部リソースが1つでも404エラーになるようなページではこの問題が起こり得ます。したがって、そもそも404ページで正常系の動作に影響を及ぼすセッション変数をセットすべきではないのかもしれません。

[^swjs]: sw.jsはService Worker関連のJavaScriptファイルです（参考：https://developers.google.com/web/fundamentals/primers/service-workers?hl=ja）
[^favicon]: 「発生しない」とは書きましたが、sw.jsと同様の理由でfavicon.ico（アイコン画像）を探しに行く場合は問題が発生しました。しかし、この場合は一度発生した後はブラウザを再起動しない限り再発しませんでした。