お疲れ様です!IT業界で働くアライグマです!
個人開発している開発関連ニュースのキュレーションサービス「DevPick」で、記事カードのサムネイル画像をリンク先から取ってくる処理を見直しました。DevPickは、ITmedia AI+・ITmedia NEWS・ASCII.jp・INTERNET Watchといった開発関連のニュースサイトをAIが横断してスコアリングし、その人の興味に合ったものだけを届けるWebアプリです。
サムネイルはOGPという仕組みから取っています。ページのHTMLの中に「このページを紹介するときはこの画像を使ってください」という情報を埋めておく決まりごとで、SNSにURLを貼ったときに画像付きのカードが出るのは、これを読んでいるからです。DevPickも同じことをしていて、記事のリンク先を読みに行き、そこに書かれた画像のURLを保存しています。
素朴な実装のまま動かしていたのですが、あらためて見直したときに、性質の違う2つの問題が同時に見つかりました。今回はその2つと、それぞれをどう直したかを書きます。
記事サムネイルは「リンク先を1回GETするだけ」だった
やっていたことは単純です。ニュースサイトが配信しているRSS/Atom(新着記事の一覧を機械が読める形で公開しているもの)を取り込み、そこに書かれているリンクをそのまま読みに行って、返ってきたHTMLからog:imageを拾うだけの処理でした。取れたら保存し、取れなければサムネイルなしで表示する。それ以上のことは何もしていません。
この作りには、自分の中で暗黙の前提がありました。「フィードに載っているリンクは記事ページのURLである」という前提です。実際、日々流れてくるURLはどれも普通の記事ページなので、運用していて違和感を持つ機会がありませんでした。動いているものは疑いにくい、というのはこういうことなのだと思います。
問題は、この処理がサーバー側から外部へ能動的にリクエストを出す処理だという点を、自分がまったく意識していなかったことでした。画面に画像を出すための地味な機能、くらいの認識で置いていたわけです。
IT女子 アラ美そのURLを決めているのは自分ではない
見直していて引っかかったのが、そのURLの値を最終的に決めているのは誰かという点でした。フィードのlinkに何を書くかを決めているのはフィードの提供元です。さらにHacker Newsのような第三者投稿型のソースであれば、実質的には投稿した人が決めています。どちらにしても、自分ではありません。
つまり、提供元が乗っ取られたり、誰でも投稿できるフィードを経由したりすれば、こちらのサーバーに好きな宛先へリクエストを出させられることになります。ここが怖いところで、狙われるのは外部のサイトではなく内側です。
サーバーから見た「内側」には、外のインターネットからは触れないものが並んでいます。自分自身を指す127.0.0.1、社内やクラウド内部だけで使われるプライベートアドレス、そしてクラウドのメタデータエンドポイント(169.254.169.254)です。最後のものは特に厄介で、サーバー自身が自分の設定情報や一時的な認証情報を取りに行くための特別なアドレスになっています。外部から直接は絶対に届かない場所ですが、サーバー自身に「ここを読んできて」と言わせられるなら届いてしまうわけです。
この「サーバーを踏み台にして、本来は外から触れない場所へリクエストさせる」攻撃をSSRF(Server Side Request Forgery)と呼びます。しかも今回の場合、取ってきた中身が画面に表示されるわけではないので、攻撃側からは応答が見えません。応答が見えない形のものはblind SSRFと呼ばれますが、見えないだけで、内部に届いていること自体は変わりません。
自分としては「記事ページを取りに行くコード」を書いたつもりでいたのに、実際には「外から与えられたアドレスへ接続するコード」を書いていた、というのがこのときの一番の反省点でした。以前個人開発で「他人のプロフィールが丸見え」事故を起こした話を書きましたが、あのときも「入力を誰が握っているか」の見落としが根っこにありました。同じ種類の穴を、形を変えてもう一度作っていたわけです。



ホスト名を見るだけでは足りなかった4点
では接続する前に何を見ればよいのか、というのが次の話になります。最初は「URLに書かれたアドレスが内部っぽくなければ通す」くらいで足りると思っていたのですが、調べていくとそれでは全然足りませんでした。結果として、接続前のチェックを専用の部品に切り出し、次の4点を順に確認する形にしています。
1つ目はURLの見た目の部分です。まずhttpとhttps以外は受け付けません。またhttps://user:pass@example.com のようにホスト名の前にユーザー名とパスワードを書ける記法があるのですが、これは人間が見たときの「どのサイトに繋がるのか」を誤認させやすいので拒否しています。ポート番号も、80と443という通常の値と一致する場合だけ許可しました。ここを自由にすると、応答が返ってくるまでの時間差だけで「内部のどの番号が開いているか」を外から推測できてしまうからです。
2つ目が名前解決の結果を全部見ることです。URLに書かれているのはたいてい example.com のようなホスト名なので、実際に接続する直前にIPアドレスへ変換します。これが名前解決(DNS)で、AレコードがIPv4、AAAAレコードがIPv6の変換結果にあたります。
ここで大事なのは、返ってくるアドレスは1つとは限らないという点でした。私は最初「返ってきた中からグローバルなものを1つ選んで繋げばいい」と考えていたのですが、これは破れます。攻撃側がグローバルなアドレスと内部アドレスを混ぜて返せば、こちらは「グローバルなものがあった」と判断して通してしまうからです。そのため返ってきた全部がグローバルであることを条件にしました。判定にはPythonのis_globalを使い、真のものだけを許す方式にしています。「これは危ないから弾く」と列挙する方式だと、新しい特殊用途のアドレス範囲が増えたときに素通りしてしまうためです。
3つ目は確認したIPアドレスへ接続を固定することです。ここまでで安全なアドレスだと確認できたのに、いざ接続するときにまたホスト名から引き直すと、確認した瞬間と接続する瞬間で答えが入れ替わる隙が残ります。1回目は正常なアドレスを返し、2回目は内部アドレスを返す、という切り替えが成立してしまうわけです。これはDNS rebindingと呼ばれる古典的な手口です。
そこで、確認済みのIPアドレスを直接指定して接続し、本来のホスト名はHostヘッダとsni_hostnameという指定で相手に伝えるようにしました。こうすると、接続先は自分が確認したアドレスで固定されたまま、HTTPSの証明書チェックは本来のホスト名に対して行われます。
4つ目はIPv6の中に隠れたIPv4です。IPv6のアドレスの中には、IPv4のアドレスを内側に含む書き方がいくつかあります。::ffff:127.0.0.1 のような形が代表例で、見た目はIPv6ですが実際に繋がる先は自分自身です。困ったことに、外側のIPv6アドレスとしてis_globalを確認しても、内側までは見てくれません。そのため、この手の埋め込みがある場合は内側のIPv4を取り出してから判定するようにしました。あわせて、一斉配信用のmulticastアドレスはis_globalが真になってしまうため、別途弾いています。
最後に、名前解決そのものに失敗した場合は接続しないことにしました。判断がつかないときに通してしまう作りだと、そこが抜け道になるからです。またリダイレクト(別URLへの転送)は、転送のたびに同じ検証をやり直さないと意味がないので、この処理では追いかけないと決めて明示してあります。



「取れなかった」と「そもそも無い」を区別していなかった
もう1つの問題は、セキュリティとはまったく関係のないところにありました。サムネイルを取りに行くのは記事を保存する最初の1回きりで、そのとき相手のサーバーが重くて応答が返ってこなかったり、一時的にエラーを返してきたりした記事は、その後ずっと画像なしのまま残っていたのです。DevPickは記事を90日で消しているので、たまたま1回失敗しただけの記事が3か月近く無画像で居座ることになります。
なぜ気づきにくかったかというと、データベースの中では「画像のURLが入っていない」という同じ状態にしか見えないからです。そのページにもともと画像が設定されていない記事と、取りに行ったけどたまたま失敗した記事が、まったく同じ顔で並んでいました。この2つを区別する情報を持っていない以上、「失敗したものだけ後で拾い直す」ということもできません。
そこで、まず失敗を見分けられる情報を持たせることにしました。何回試したか、最後に試したのはいつか、そして「ページは読めたけれど画像の指定が無かった」ことを表す印の3つです。最後の印が付いた記事は、何度取りに行っても結果が変わらないので、そこから先はもう対象にしません。フィードのページ送りを作り直した話のときにも感じたことですが、目の前の症状を消すより先に、区別できていない状態を区別できるようにするほうが結局は近道でした。



再取得の上限を先に決めてから作った
失敗した記事を後から取り直す定期処理を足すことになるのですが、実装より先に決めたのが相手のサイトへどれだけリクエストを出すかの上限でした。こちらの都合で他所のサーバーへのアクセスを増やす話なので、ここを曖昧にしたまま動かしたくなかったからです。
決めたのは4つです。1回の実行で扱うのは50件まで。1つの記事について試すのは3回まで。同じ記事を再び試すのは前回から6時間空けてから。対象にするのは取り込みから1時間〜7日の記事だけ。1時間未満を外したのは、通常の取り込み処理とかち合う可能性があるためで、7日を超えたものを外したのは、そもそもフィードの上のほうに出てこないので取り直す意味が薄いためです。
地味に効いたのが、定期処理を動かす間隔と、再挑戦するまでの待ち時間をわざとずらしたことでした。定期処理は3時間ごとに動かし、再挑戦の待ち時間は6時間にしています。ここを両方6時間で揃えると、うまくいきません。6時間ごとに動く処理が「前回から6時間経ったもの」を探すと、ほんのわずかに足りない記事が毎回すり抜けてしまい、実際には12時間おきにしか再挑戦されなくなるからです。数字としては揃えたくなる組み合わせなので、これは覚えておきたいと思いました。
そして最後に、ここまでの2つの話がつながります。SSRFのチェックで拒否したURLは、何度取り直しても結果が変わらないという点で、「もともと画像が設定されていない記事」とまったく同じ性質を持っています。そのため拒否したURLにも専用の印を付けて、再挑戦の対象から永久に外しました。別々の問題として直した2つが、最終的に「無駄に取りに行かない」という同じ仕組みに合流した形です。



まとめ
今回わかったのは、サーバーが外部のURLを読みに行く処理を書くときは、そのURLを最終的に誰が決めているのかを先に確認しておくべきだった、ということでした。自分が書いたコードでも、実際に接続する先は他人が決めていた。この一点を見落としていたせいで、サムネイルを取ってくるだけの地味な機能が、内部への入口になっていたわけです。
もう一方の「取れなかった」と「そもそも無い」を区別していなかった話も、根っこは似ていると感じています。どちらも結果だけを見て、その結果がどこから来たのかを持っていなかったという点で共通しているからです。今の自分としては、外部から来る値を扱う処理では、値そのものより先に出どころを見る癖をつけたいと思っています。












