Server-Sent Events でチャットアプリを作ってみた (Spring Boot 2.x系 x Spring MVC x Akka Actor)
こんにちは、SSTでWeb脆弱性診断用のツール開発をしている坂本です。
先日 “Server-Sent Events” という仕様に触れる機会があり、勉強として簡単なチャットアプリをSpring Boot 2.x系 x Spring MVC x Akka Actorの組み合わせで作ってみましたのでその紹介と、診断作業に影響しそうなポイントなど考察してみます。
Server-Sent Events とは?
Server-Sent Events (以下 “SSE” とも省略) とは、簡単に書くと HTTP/1.x のchunkedレスポンスを使って非同期な通知を行うための、レスポンスのフォーマットおよびブラウザ側のJavaScript API の仕様です。
WebSocketと異なり、通常のHTTP/1.xの枠組みの中で動作します。
また Comet とも異なり、フォーマットやJavaScript APIが仕様化されているため、ブラウザやライブラリなどで利用しやすくなっています。
- 最新仕様: “HTML Standard” -> “9.2 Server-sent events”
- MDN web docsより:
- 対応状況
- https://caniuse.com/#feat=eventsource
- Chrome, Firefox, Safari系は最新版で対応できていますが、IE/Edgeが未対応です。
- 古い仕様
- http://w3c.github.io/eventsource/ (一つ前?ドキュメントは空で、上記最新仕様へのリンクがあるだけ。)
- https://www.w3.org/TR/eventsource/ (二つ前?2015-10-24 に、最新は上記一つ前のURLを参照してね、というノートがある。)
参考になるブログなど先に紹介しておきます。
- Server Sent Events(SSE)の使いどころと使い方 | GREE Engineers’ Blog
- https://labs.gree.jp/blog/2014/08/11070/
- 2014年の記事ですが、初期の状況やComet/WebSocketとの比較など参考になりました。
- Server-Sent Events with Spring
- https://golb.hplar.ch/2017/03/Server-Sent-Events-with-Spring.html
- Spring MVC で SseEmitter を使って SSE を実現する方法が紹介されています。今回はこちらと、次のQiita記事に大変助けられました。
- Spring MVC(+Spring Boot)上での非同期リクエストを理解する -後編(HTTP Streaming)- – Qiita
- https://qiita.com/kazuki43zoo/items/53b79fe91c41cc5c2e59
@Async
アノテーションについてですが、今回試してみたところ同期的に動いてしまうようです。@Async
メソッドの呼び出しが完全に終わってから、その次のコードが順に実行されるようです。1- タイムアウトについて、こちらの記事では
AsyncRequestTimeoutException
が発生してJSONレスポンスが返されています。しかし、今回試した中では同様の状況は再現しませんでした。Spring/Tomcatのバージョンが変わったか、あるいは、スレッド管理がAkka Actorで試した影響と思われます。
サンプルコードについて
先にサンプルコードについて紹介します。以下のURLで、Spring Boot のMavenプロジェクトとして公開しています。ビルドや実行にはJDKが必要なので、別途インストールしてください。
実行方法:
- ビルド済みのjarファイルをダウンロード
java -jar springboot2-async-chat-demo-v201812.27.1.jar
で起動- Chrome または Firefox で http://localhost:18088/ にアクセスしてください。(IE/Edgeは未対応です)
今回サンプルコードを作るにあたり、坂本がJavaに慣れているのと Spring Boot の使用経験があったため、Spring Boot 2.xを使って Spring MVC 上で動かしてみました。
Spring におけるServer-Sent Eventsでは WebFlux を使うほうがモダンなようですが、坂本も WebFlux まではキャッチアップできていないのと、さすがに勉強用サンプルでいきなりWebFluxまで使うのはオーバースペックな気がしたこともあり、Spring MVCの範囲内に留めました。
非同期通知を実現するためのスレッド制御については2018年3月~4月にかけて Akka (Java版API)を勉強したことがありましたので、それを使ってみることにしました。
Spring Boot 2.x 系 x Spring MVC x Akka Actor による単純なサンプルコード
単純なサンプルコードでサーバ側/ブラウザ側の動きを確認した後、実際のソースコードを紹介します。
サーバ側の動作確認と実際のソースコード
サンプルコードでは http://localhost:18088/sse-demo/emit というエンドポイントで SSE を試せます。
これはパラメータで指定した値まで1からカウントアップしていき、カウント状況をリアルタイムでクライアントに通知します。
以下のパラメータで動作をカスタマイズできます。
numOfCount
: カウントアップ最大値intervalSec
: 何秒おきにカウントアップするかのスリープ秒数timeoutSec
: SseEmitter(後述) にわたすサーバ側のタイムアウト秒数。0の場合はコンテナのデフォルト設定が使われる。errval
: カウントがこの値に到達したら div by zero を発生させる。
curlコマンド2で試してみます。1秒おきに10までカウントアップさせてみます。
$ curl -i -s -N "http://localhost:18088/sse-demo/emit?numOfCount=10&intervalSec=1"
HTTP/1.1 200
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: text/event-stream;charset=UTF-8
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Server-Sent Events で定められたContent-Type
Transfer-Encoding: chunked
^^^^^^^^^^^^^^^^^^^^^^^^^^ chunkedレスポンスとなります。
Date: Thu, 27 Dec 2018 08:02:15 GMT
data:count-1
data:count-with-other-field-1
event:count-up
id:6d6d3eda-e40b-4eaa-aa0a-970abe34e4ea
retry:3000
(この間に1秒のスリープ)
data:count-2
data:count-with-other-field-2
event:count-up
id:6cd7823b-3844-4836-b7b7-a7f0dc079609
retry:3000
(この間に1秒のスリープ)
(...)
data:count-10
data:count-with-other-field-10
event:count-up
id:e2e511c7-bf15-4188-84a0-5918a3f97f88
retry:3000
dataフィールド(data:...
) が1秒おきに10回表示され、接続が終了します。
これが chunked レスポンスを使った通知になります。
実際のアプリケーションでは、JSONやテキストで通知内容をdataフィールドに含めることになります。
付随情報としてIDやイベント種別、リトライ間隔なども追加することができます。
サンプルでは単純な通知と同時に、これら付随情報を埋め込んだ通知も送信しています。
eventフィールド(event:...
)がイベント種別、id:...
がID、retry:...
がリトライ間隔になります。
今度は1秒おきに10までカウントアップさせますが、5秒後にサーバ側でタイムアウトさせてみます。
$ curl -i -s -N "http://localhost:18088/sse-demo/emit?numOfCount=10&intervalSec=1&timeoutSec=5"
HTTP/1.1 200
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: text/event-stream;charset=UTF-8
Transfer-Encoding: chunked
Date: Thu, 27 Dec 2018 08:09:24 GMT
data:count-1
(...)
data:count-5
data:count-with-other-field-5
event:count-up
id:d4703381-2c35-4c80-9453-8d3aa7145266
retry:3000
5秒経ち、カウントも5まで進んだところでサーバ側でタイムアウトし、接続が終了しました。
errval
の動作については省略します、読者のお手元で試してみてください。
続いてソースコードとポイントについて紹介します。
- ソースコード : SseDemoController.java
- ポイント1 : SSEを使うにはリクエストマッピングしたハンドラで SseEmitter を生成し、returnします。
以下のソースは /sse-demo/emit
に対応するハンドラの抜粋で、 SseEmitter を生成し、非同期処理を行うAkka Actorに渡しています。
@GetMapping("emit")
public SseEmitter sse(@RequestParam(defaultValue = "1") long numOfCount,
@RequestParam(defaultValue = "0") long intervalSec, @RequestParam(defaultValue = "0") long timeoutSec,
@RequestParam(defaultValue = "999") int errval) throws IOException {
LOG.info("Start get.");
final ActorSystem actorSystem = managedActorSystem.getActorSystem();
// SseEmitterを生成
final SseEmitter emitter = (timeoutSec > 0) ? (new SseEmitter(timeoutSec * 1000)) : (new SseEmitter());
// (...)
// Akka Actor生成時にconstructorで SseEmitterを渡し、actor側で非同期にメッセージを生成できるようにする。
actorSystem.actorOf(Props.create(AsyncCountUpTimerDemoActor.class, numOfCount, intervalSec, emitter, errval));
LOG.info("End get.");
return emitter; // SseEmitterを返す。
}
- ポイント2 : 実際にSSEで通知データをクライアントに送信するには、
SseEmitter.send()
メソッドを使います。
以下のソースは ポイント1 で生成したActorの内部処理の抜粋で、 SseEmitter.send()
メソッドにより現在のカウント値を通知しています。
また、 SseEmitter.event()
からのBuilderを使ってイベント種別(event:...
), ID(id:...
), リトライ間隔(retry:...
)を指定したバージョンも通知しています。
// 単純な文字列の通知(実際はJSON文字列など)
sseEmitter.send("count-" + count);
// 付随情報を追加した通知
sseEmitter.send(
SseEmitter.event()
.data("count-with-other-field-" + count)
.name("count-up")
.id(UUID.randomUUID().toString())
.reconnectTime(3000));
ブラウザ側の動作確認と実際のソースコード
サンプルコードでは http://localhost:18088/sse-demo/ というエンドポイントで SSE のJavaScript APIを試せます。
機能としては、前述の http://localhost:18088/sse-demo/emit についてパラメータの入力フォームを表示し、「接続」ボタンがクリックされたらリクエストを送信し、通知を受信するたびにテキストエリアに追記していきます。
「切断」ボタンがクリックされたらブラウザ側でSSEの接続を終了します。
DevToolsのNetworkタブを見てみると、実際のレスポンスを確認できます。
ソースコードとポイントについて紹介します。
- ソースコード : sse-demo.html
- ポイント1 : SSEの接続を開始するには
EventSource
オブジェクトを生成します。- 以下のソースは
/sse-demo/emit
に渡すパラメータを入力フォームから集め、EventSource
オブジェクト生成時のサーバ側のURLに指定しています。
- 以下のソースは
const numOfCount = document.querySelector('input[name=numOfCount]').value;
const intervalSec = document.querySelector('input[name=intervalSec]').value;
const timeoutSec = document.querySelector('input[name=timeoutSec]').value;
const errval = document.querySelector('input[name=errval]').value;
const eventSource = new EventSource('./emit?numOfCount=' + numOfCount + '&intervalSec=' + intervalSec + '&timeoutSec=' + timeoutSec + '&errval=' + errval);
- ポイント2 : サーバからの通知を受信する処理は
EventSource
オブジェクトのonmessage
イベントハンドラに登録します。- 以下のソースは基本のdataフィールドの通知が来たら、その内容をテキストエリアに追記するハンドラを
onmessage
に追加しています。 - また、eventフィールドが追加された通知については、
addEventListener()
でイベント種別を指定してハンドラを登録しています。
- 以下のソースは基本のdataフィールドの通知が来たら、その内容をテキストエリアに追記するハンドラを
eventSource.onmessage = function(event) {
talogEvent(event);
};
eventSource.addEventListener('count-up', function(event) {
talogEvent(event);
}, false);
チャットアプリの例
リアルタイム通知が活きる例として、簡単なチャットアプリを作ってみました。
ここではサンプルの使い方と機能について紹介するに留めます。実装について興味のある方はソースコードを見てみてください。
使い方:
- jarファイルを起動したら http://localhost:18088/ にアクセスし、チャットアプリのリンクをクリックします。
- ログイン画面が表示されます。デモアプリなので、ユーザ登録不要です。好きなユーザ名で、パスワードは空のままログインできます。
- ログインしたらチャットルーム一覧が表示されます。初期状態は空なので、適当な名前で作成してください。作成するとそのチャットルームに移動します。
- チャットルーム画面に遷移したら自動で「入室」メッセージが通知されます。
- 他のブラウザからもログインしてみて、お互いにメッセージを送信してみます。SSE経由でリアルタイムにメッセージが通知され、画面上に表示されます。
Burp Suite (ローカルHTTPプロキシ)を通すときの注意点
サンプルの http://localhost:18088/sse-demo/ ですが、ブラウザに Burp Suite をプロキシとして設定する場合、注意点があります。
“Project options” タブ -> “HTTP” タブ -> “Streaming Responses” で、Server-Sent Events をレスポンスで扱うURLを追加してください。3
これを忘れると、Server-Sent EventsによるchunkedレスポンスがBurp Suite側でバッファリングされてしまい、ブラウザ側にリアルタイムに通知されません。
(最初はこの設定に気づかず、「curlではリアルタイムに受信できてるのに、なんでブラウザではサーバ側がcloseした後にまとめて一度に受信されてしまうの???」と悩み、30分ほど時間を溶かしました・・・)
なお Fiddler 5.0 をプロキシに設定してみたところ、ブラウザ側でリアルタイムにchunkedレスポンスによる通知を受信することができました。Fiddlerの画面上では全部終わらないとレスポンスとして見れないようです。
また OWASP ZAP 2.7.0 をプロキシに設定してみたところ、Burp Suite のデフォルトと同様、chunkedレスポンスがバッファリングされてしまい、ブラウザ側にリアルタイムに通知されませんでした。軽く調べてみた範囲では、chunkedレスポンスについて特別な設定などは見つけられませんでした。
診断作業への影響
Server-Sent EventsがWebアプリケーション脆弱性診断へ与える影響について考えてみました。
- Server-Sent Eventsを使っているか?使っているなら、どこでどのように使っているのか把握する必要がありそうです。
- Burp Suite を通して診断している場合、前述の通りデフォルトではchunkedレスポンスがバッファリングされてしまうため、Server-Sent Eventsのリアルタイムな動きをブラウザ側で確認できません。
- つまり、一見するとServer-Sent Eventsが動いておらず、使っていること自体に気づけない可能性があります。
- リアルタイム通知の特性を持つレスポンスで
Content-Type: text/event-stream
を見かけたら、Server-Sent Eventsを使っていると見て間違い無さそうです。
- Server-Sent Eventsのレスポンスを返すURLをスキャナーでスキャンする場合、スキャナーが対応しているか確認が必要になりそうです。
- chunkedレスポンスをバッファリングするタイプだと、サーバ側で接続を閉じるまで延々とレスポンスをバッファリングし続けてしまい、スキャンが止まってしまう可能性が考えられます。
- もし対応していなければ、手動検査で頑張るしか無いかもしれません。
- 反射型の脆弱性だけでなく、蓄積型の脆弱性に気をつける必要がありそうです。ブラウザ側ではJavaScript APIで通知を取得し、画面に反映することから、DOM based XSS に注意する必要がありそうです。
- Server-Sent Eventsでは、現在のHTTP接続とは独立したアクション結果をリアルタイムにレスポンスに流す使われ方がメインとなります。そのため、Server-Sent Eventsレスポンス自体に対応するリクエストというよりは、通知のきっかけとなるリクエストのほうが重要と思われます。
- Webサイト全体の構造を理解して、ユーザのどの操作(IN)がServer-Sent Eventsにどう通知されて(OUT => 蓄積型の脆弱性)、ブラウザ側でどう処理されるのか(=> DOM based XSSなど)把握する必要がありそうです。
3行でまとめ:
- 使っていることに気づけるか?
- ツールは対応してるか?
- Webサイトを理解した上で、蓄積型の脆弱性やDOM based XSSに注意。
作ってみた感想と、時間を溶かしたところ(ハマった箇所)
作ってみた感想:
- 簡単に Server-Sent Events を使うことができた。
- WebSocketと異なり、HTTPの中で素直に動かせるのが手軽に感じた。
- プロダクションレベルだと、通信異常が発生したときの切断・再接続についてしっかり調査・対応が必要となりそう。
- 特にサーバ側では、SseEmitterとそれに関連したインスタンスがリークしないよう対策が必要。
時間を溶かしたところ(ハマった箇所):
- Spring Security で、ユーザ名を何を入力しても素通しでログインさせる方法が分からず2時間ほど溶かした。
- ダメ元で
UserDetailsService
設定周りを空にして、Spring Security のドキュメントを何度か読み直して パスワード素通しのAuthenticationManager
実装を追加することでなんとか実現できた。
- ダメ元で
@RequestMapping
のパス指定で2時間ほど溶かした。- class側で
/aaa
に設定し、ハンドラ側で/bbb
に設定したら、ハンドラのURLは最終的に/aaa/bbb
になるだろ・・・と思ったら、/aaa
と/bbb
でそのままに分かれてしまった・・・。 - さらに
/aaa
でマッピングしたclass内で、デフォルトのハンドラを作ろうと/
にマッピングしたら、/aaa/
ではなく文字通り/
にマッピングされてしまった・・・。
- class側で
- indexビューは何もcontrollerが無くても動くっぽい。↑でデフォルトのハンドラでindexビューを返してたら、あれこれ試行錯誤してるうちに、
/
にアクセスしたとき、デフォルトで動く index ビューが動いているのか、間違えて/
にマッピングされたハンドラによるものか分からなくなって混乱した。- 結局デフォルトのハンドラーは引数無しで
@RequestMapping
すればよかった。
- 結局デフォルトのハンドラーは引数無しで
- Spring Security のCSRF Token対策に気づかなくて30分ほど溶かした。
- チャットアプリを作ってる途中、メッセージ送信をJavaScriptのfetch API でPOSTリクエスト呼ぶ実装してて、なぜか403 Forbiddenになり、リクエストマッピングとか散々確認したり試行錯誤してた。
- いろいろググってみたところ、ふと、”Spring Security” “CSRF Token” の文字に気づいて「あーーー!!!!」となり、Spring Securityがformに挿入した “_csrf” を取ってきて手動で送って成功。
- もうちょっとこう、csrf tokenが無いとか間違ってる場合は、それと分かるようなログを出してくれませんかねぇ・・・。
全体的に、サンプルコード作成にかかった時間の 1/3 程度は、特に Spring Security やリクエストマッピングの基本的なところでの「Springでこれどうやればいいの?」や「こう書いたら、なんでこうなるの?」に費やされた形です。巨大フレームワーク使うときの「あるある」ですね。
Spring で SseEmitter を使ってて気になったところで、クライアント側がいきなり切断した場合に、Tomcatコンテナで以下のようなERRORログが出力されました。
2018-12-27 15:16:32.208 ERROR 5792 --- [io-18088-exec-7] o.a.catalina.connector.CoyoteAdapter : Exception while processing an asynchronous request
java.lang.IllegalStateException: Calling [asyncError()] is not valid for a request with Async state [MUST_DISPATCH]
at org.apache.coyote.AsyncStateMachine.asyncError(AsyncStateMachine.java:440) ~[tomcat-embed-core-9.0.13.jar!/:9.0.13]
at org.apache.coyote.AbstractProcessor.action(AbstractProcessor.java:512) [tomcat-embed-core-9.0.13.jar!/:9.0.13]
at org.apache.coyote.Request.action(Request.java:430) ~[tomcat-embed-core-9.0.13.jar!/:9.0.13]
at org.apache.catalina.core.AsyncContextImpl.setErrorState(AsyncContextImpl.java:382) ~[tomcat-embed-core-9.0.13.jar!/:9.0.13]
at org.apache.catalina.connector.CoyoteAdapter.asyncDispatch(CoyoteAdapter.java:239) ~[tomcat-embed-core-9.0.13.jar!/:9.0.13]
at org.apache.coyote.AbstractProcessor.dispatch(AbstractProcessor.java:241) [tomcat-embed-core-9.0.13.jar!/:9.0.13]
at org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:53) [tomcat-embed-core-9.0.13.jar!/:9.0.13]
at org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:791) [tomcat-embed-core-9.0.13.jar!/:9.0.13]
at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1417) [tomcat-embed-core-9.0.13.jar!/:9.0.13]
at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:49) [tomcat-embed-core-9.0.13.jar!/:9.0.13]
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149) [na:1.8.0_192]
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624) [na:1.8.0_192]
at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61) [tomcat-embed-core-9.0.13.jar!/:9.0.13]
at java.lang.Thread.run(Thread.java:748) [na:1.8.0_192]
このissueに行き着くようなのですが、Tomcat側の問題だからでしょうか、まだ綺麗に解決された状況ではないようです。
- Tomcat: java.lang.IllegalStateException: Calling [asyncError()] is not valid for a request with Async state [MUST_DISPATCH] · Issue #15057 · spring-projects/spring-boot
クライアント側が切断した後に SseEmitter.send()
すれば当然 IOException
が発生しますし、 SseEmitter.onError()
に登録したエラーハンドラも実行されます。そのため、ちゃんと作り込めばクライアント側が先に切断したときの対応を実装できます。とはいえ、クライアント側が先に切断する都度、上記のERRORログが出力されるのも困るところかもしれません。本記事執筆時点では、よい対応方法が見つかっていない状況です。
まとめ
- Server-Sent Events について調べてみました。
- Spring Boot 2.x系 x Spring MVC x Akka Actor で Server-Sent Events を使った簡単なアプリを作ってみて、どのように動くのか確認できました。
- 脆弱性診断への影響を考察してみました。
今後 Server-Sent Events を使うアプリケーションが増えるようであれば、診断ツール側での対応を進めようと思います。
また業務用に社内で自作してるWebアプリもありますので、そうしたアプリでリアルタイム通知が必要になった時に活用してみたいと思います。
- 曖昧な書き方になってしまっているのは、1. サンプルコードの関連箇所だけコピペした + 2. もともとAkkaで非同期処理を組み立てる予定だったので、
@Async
について深追いする予定がなかったためで、1-2回「あれ?非同期じゃないかも?」と結果をちら見して疑問に思った程度で、すぐにAkkaでの組み立てに移ってしまったからです。もしかしたらきちんとサンプルコードの全体をコピペすれば動いたかもしれませんし、あるいは自分のコピペミスや理解不足だったかもしれません。↩ -N
:--no-buffer
オプションの短縮系で、レスポンスのバッファリングを無効化します。-s
:--silent
オプションの短縮系で、レスポンス受信の進捗表示を無効化します。ただし curl のバージョンによっては-s
を指定すると-v
によるリクエストヘッダー/レスポンスヘッダーの詳細表示も無効化されてしまう状況が観測されました。そのため、せめてレスポンスヘッダーだけでも表示するため、-i
:--include
オプションの短縮系を追加してレスポンスヘッダーを出力させています。↩- Burp Suite Community Edition v1.7.36 および Burp Suite Professional v1.7.37 にて確認しています。↩