SummarinのWebサイトは、PythonのFastAPIで動いています。

最近は、企業やサービスからの情報漏えいのニュースが続いています。個人のサイトでも、使っているライブラリに穴があれば同じように狙われます。何か起きてから慌てるのではなく、先に自分で調べて対応しておこうと考え、調査を始めました。

ちょうどそのころ、PHPでも、遠隔からサーバー上で任意のコードを実行されるおそれのある重大な脆弱性(通称「PHP2shell」)が公表され、IPAが注意喚起を出しました。

SummarinはPHPを使っていないので、この脆弱性の影響は受けません。それでも、広く使われている土台に重大な穴が見つかることは実際にある、と改めて感じました。調べておいてよかった、と思ったきっかけです。

この記事では、見つかった問題ごとに「放っておくと何をされうるのか」と「どう直したのか」を書きます。

調べ方

調べたのは、サーバー側のPythonパッケージ、Dockerのベースイメージ、ブラウザで読むJavaScriptの3つです。

  • Pythonパッケージ:実際にインストールされる依存を、まっさらな仮想環境に入れて pip-audit にかけた
  • ベースイメージ:Trivy でスキャンし、重要度が「高(HIGH)」と「致命的(CRITICAL)」のものを数えた
  • CDNから読むJavaScript:HTMLに書いてあるバージョンを洗い出し、既知の脆弱性を調べた

requirements.txt を眺めるだけでなく、仮想環境に実際に入れたのには理由があります。バージョンを書いていない間接的な依存も含めて、本番で本当に入るものを調べたかったからです。

先に全体像

調査の結果と対応をまとめると、次のとおりです。対応は3回に分けて、3日ほどで終えました。

対象想定される攻撃対応
Starlette(サーバー側)細工したリクエストでサーバーを止められる、など6件0.x系の最終版へ → 1.x系へ
Dockerのベースイメージ修正の出ないOSパッケージの脆弱性を突かれるサポート中の Debian 13 の slim へ
jQuery外から来たHTMLを経由してスクリプトを実行される3.4.1 → 3.7.1
CDNのライブラリCDN上のファイルが差し替えられ、訪問者のブラウザで悪意あるコードが動くバージョン固定とSRI
Markdownの表示AIの出力に混ざったHTMLがそのまま実行されるDOMPurifyで無害化
古いスマホ用ページ放置された古いjQueryを足がかりにされるページを削除し、転送だけ残す

FastAPI本体、Uvicorn、Jinja2、Pydanticには、この時点で既知の脆弱性はありませんでした。

順番は「実際に影響のあるものを先に、大きな変更は後に」としました。

1. Starlette:まずは互換性を崩さない範囲で上げる

何が起きうるか

FastAPIの土台であるStarletteは 0.48.0 を使っており、既知の脆弱性が6件ありました。1件ずつ、Summarinへの影響を見積もりました。

内容直る版Summarinへの影響
細工したRangeヘッダーで、CPUを使い切らせるDoS0.49.1あり。静的ファイルの配信が、認証なしで対象になる
フォームの件数・サイズの制限が効かないDoS1.3.1低い。フォームを受け取る画面がない
Hostヘッダーで、リクエストのパスを偽装できる1.0.1低い。URLの値をアクセスの判定に使っていない
パスの細工で、リクエストのホスト名を偽装できる1.3.0低い。同上
想定していないHTTPメソッドを呼べる1.1.0なし。該当する機能を使っていない
Windows上でのSSRF1.1.0なし。Linuxで動かしている

実際に影響があったのは、1つめのRangeヘッダーの問題です。

Rangeヘッダーは、ファイルの一部だけを取り出すための仕組みです。動画の途中から再生したり、ダウンロードを再開したりするときに使われます。このヘッダーの解釈に弱点があり、細工した値を送られると、サーバーが重い処理を抱え込みます。

この種の攻撃はDoS(サービス妨害)と呼ばれます。情報が盗まれるわけではありませんが、少ないリクエストでサイトを応答できない状態にされます。SummarinはCSSや画像などの静的ファイルをStarletteから配信しています。静的ファイルはログインなしで誰でも取得できるので、誰でも攻撃できる状態でした。

どう直したか

最初に、0.x系の最終版(0.48.0 → 0.52.1)まで上げました。この版でRangeヘッダーの問題は直っています。

すぐに1.xへ上げなかったのは、1.0でテンプレートまわりの書き方が変わっており、コードの書き換えが必要だったからです。0.x系の中であれば、コードはそのままで済みます。影響のある穴を確実に塞ぎ、大きな変更は後に回す、という判断です。

手元で再現テストもしました。0.48.0 では、細工したRangeヘッダーを1回送るだけで、応答に数秒かかっていました。0.52.1 では、同じリクエストがすぐに400(不正なリクエスト)で拒否されました。デプロイ後に本番でも同じ結果になることを確かめています。

2. ベースイメージ:サポートの終わったOSをやめる

何が起きうるか

使っていたのは python:3.11.5-bullseye、つまり Debian 11 をもとにしたイメージでした。Debian 11 はすでにサポートが終わっていました。

サポートが終わったOSには、新しい脆弱性が見つかっても修正が出ません。コンテナの中のOpenSSLなどに穴が見つかっても、そのまま残り続けます。アプリのコードがどれだけ新しくても、土台のOSが古ければ、そこから攻撃される余地が残ります。

Python本体も 3.11.5 のままでした。3.11系の最新は 3.11.17 で、その間に入った修正を取り込めていませんでした。

どう直したか

Debian 13 をもとにした slim のイメージに変えました。

# 旧
FROM python:3.11.5-bullseye
# 新
FROM python:3.11-slim-trixie

Trivyでのスキャン結果は次のとおりです。

変更前変更後
もとにしたOSDebian 11(サポート終了)Debian 13
Python3.11.53.11.17
重要度「高」以上の脆弱性3,150件44件
うち「致命的」254件0件

残った44件は、すべてDebian側でまだ修正版が出ていないものです。修正が出れば、イメージを作り直すだけで取り込めます。

件数の大半は、アプリが使っていないOSのパッケージでした。slimのイメージは入っているパッケージが少ないので、それだけで大きく減ります。使わないものを入れないこと自体が、攻撃される場所を減らす対策になる、ということが数字ではっきり見えました。

あわせて、Pythonのパッチ版(3.11.5 の「.5」)を固定するのもやめました。3.11 と書いておけば、イメージを作り直すたびに3.11系の最新の修正が入ります。今回のように、古い版のまま気づかず使い続けることを防ぐためです。ビルド時に使う setuptools も、古い版に脆弱性があったので最新にしています。

3. Starlette 1.x へ移る

何が起きうるか

1の表のうち、残りの5件は1.x系でしか直りません。影響は低い、またはないと見積もっていました。

それでも上げることにしたのは、「影響が低い」という判断が、今のコードの使い方を前提にしているからです。将来フォームを受け取る画面を作ったり、URLの値で判定する処理を書いたりすれば、その前提は崩れます。古い版に留まる理由がなければ、上げておくほうが安心です。

どう直したか

Starlette を 1.7.0 へ、あわせて FastAPI を 0.156.8、Uvicorn を 0.54.0 へ上げました。

Starlette 1.0 では、テンプレートを返す関数の古い書き方が削除されています。古い書き方のまま上げると、すべてのHTMLページがエラーになります。

# 旧
templates.TemplateResponse("About.html", {"request": request})
# 新
templates.TemplateResponse(request, "About.html", context)

書き換えは20か所ありました。どの箇所も、引数の先頭に request を足すだけです。手順の1で先に穴を塞いでいたので、この作業は急がずに進められました。

確認には、変更前と変更後のコンテナを並べて起動し、同じリクエストを送って応答を比べる方法をとりました。全HTMLページ、404、リダイレクト、静的ファイル、圧縮、APIのエラー応答など、37種類のリクエストで比べています。

結果、ステータスコードと本文はすべて一致しました。違いは、すべての応答に Vary: Origin というヘッダーが付くようになったことだけです。これはStarlette 1.xでCORSの処理が変わったためで、CDNなどのキャッシュがアクセス元ごとに正しく分かれる、好ましい方向の変更でした。

移行で困ったこと

移行そのもので困ったことは、ほとんどありませんでした。

大きかったのは、調査から移行までをClaude Codeと一緒に進めたことです。Pythonのパッケージ、ベースイメージ、CDNのライブラリと、幅広い範囲を一度に調べられました。

Starletteについても、「0.x系の最終版に上げる案」と「1.x系に上げる案」の2つを候補に挙げてくれました。それぞれの案で、どの脆弱性が直るのか、コードのどこを直す必要があるのかまで調べてくれたので、それを見て順番を決められました。

脆弱性の1件ずつについて、自分のコードのどこが関係するのかを手作業で確かめていたら、影響範囲の確認だけでものすごく大変だったと思います。候補となる移行先とその影響が先にそろっていたので、判断と確認に集中でき、非常にスムーズに進められました。

4. CDNから読むJavaScriptを固定する

ここからはブラウザ側です。サーバーをいくら固めても、ブラウザで動くJavaScriptに問題があれば、訪問者が被害を受けます。

何が起きうるか(1):古いjQuery

19のページで jQuery 3.4.1 を読んでおり、既知の脆弱性がありました(CVE-2020-11022、CVE-2020-11023)。外から来たHTMLをjQueryでページに差し込むと、無害化したつもりのHTMLからでもスクリプトが動いてしまう場合がある、というものです。いわゆるXSS(クロスサイトスクリプティング)につながります。

XSSでは、攻撃者の書いたスクリプトが、訪問者のブラウザでSummarinのページとして動きます。表示を書き換えたり、別のサイトへ誘導したりできてしまいます。

何が起きうるか(2):バージョンを指定しない読み込み

marked、Chart.js、vis-network の3つは、バージョンを指定せずに読み込んでいました。次のように、URLにバージョンが入っていない書き方です。

<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>

この書き方だと、CDNは「その時点の最新版」を返します。新しい版が出た時点で、知らないうちに中身が変わります。

困るのは、仕様変更でページが壊れることだけではありません。ライブラリの公開者のアカウントが乗っ取られ、悪意あるコードを含む版が公開される事件は、実際に何度も起きています。バージョンを指定していないと、その版がそのまま訪問者のブラウザに届きます。

どう直したか

ライブラリ変更前変更後
jQuery3.4.13.7.1
markedバージョン指定なし15.0.12
Chart.jsバージョン指定なし4.6.1
vis-networkバージョン指定なし10.1.2
  • jQueryは、3.x系の最終版の 3.7.1 にした。4.x系はAPIの変更が大きいので見送った
  • marked、Chart.js、vis-network は、その時点で配られていた版に固定した。たとえばmarkedは、バージョン指定なしのURLが実際に返していたのが 15.0.12 だったので、同じ版にした。中身は同じなので、挙動は変わらない
  • 固定した版に既知の脆弱性がないことを、脆弱性データベースのOSVで確かめた
  • すべての読み込みにSRI(ファイルのハッシュ値)を付けた

変更後の書き方は次のとおりです。URLにバージョンを入れ、integrity にハッシュ値を書きます。

<script src="https://cdn.jsdelivr.net/npm/marked@15.0.12/marked.min.js"
    integrity="sha384-..." crossorigin="anonymous"></script>

SRIを付けると、ブラウザは読み込んだファイルのハッシュ値を計算し、integrity に書いた値と比べます。一致しなければ実行しません。CDN上のファイルが書き換えられても、訪問者のブラウザでは動かない、ということです。

バージョンの固定とSRIはセットで考える必要があります。バージョンを指定していないと、新しい版が出るたびにハッシュ値が変わり、ページが動かなくなるからです。

5. Markdownの出力を無害化する

何が起きうるか

月次・日次のニュース分析の画面では、AIが書いたMarkdownを、ブラウザでmarkedを使ってHTMLに変換して表示しています。

markedは、Markdownの中に書かれたHTMLをそのまま通します。もしAIの出力に、スクリプトを動かすようなタグが混ざっていれば、それがページの一部として実行されます。

「AIがそんなものを書くのか」と思うかもしれません。ただ、AIが読んでいるのは外部のニュース記事です。記事の本文に細工した文章を仕込み、AIに特定の文字列を出力させる手口(間接的なプロンプトインジェクション)は、十分に考えられます。AIの出力は、外から来た入力と同じように扱うべきだと判断しました。

どう直したか

markedの出力をDOMPurifyに通してから、ページに差し込むようにしました。

// 旧
$report.html(marked.parse(markdownText));
// 新:危険なタグや属性を取り除いてから差し込む
$report.html(DOMPurify.sanitize(marked.parse(markdownText)));

DOMPurifyは、スクリプトやイベント属性など、実行につながる部分だけを取り除きます。DOMPurify自体も、バージョンを固定してSRIを付けています。

効果は2つの方向から確かめました。

  • 普段の表示が変わらないこと。 本番と同じデータで表示し、変更前と変更後で描画されたHTMLが完全に一致した。今のデータには、取り除かれる要素はなかった
  • 危険なものが消えること。 スクリプトを動かすタグや、スクリプトを実行するリンクをわざと混ぜたデータで表示した。危険な部分はすべて取り除かれ、スクリプトは実行されなかった。見出しや太字、普通のリンクはそのまま残った

6. 使っていない古いページを消す

何が起きうるか

古いスマホ用のページが、jQuery 2.2.4 を読んだまま残っていました。2.x系はすでにサポートが終わっており、4で挙げたもの以外にも既知の脆弱性を抱えた版です。

このページは、今のアプリからは使っていませんでした。検索エンジンにも載らないようにしていました。ただ、検索に載らないことと、アクセスできないことは別です。URLを知っていれば誰でも開けますし、古いページは見直しの対象から漏れがちです。

どう直したか

古いページは削除し、そのURLには新しいページへの恒久的な転送(301)を設定しました。

完全に消さなかったのは、古い版のアプリがこのURLを開くからです。転送にしておけば、古いアプリの利用者は新しいページを見られ、古いjQueryを読むページはどこにも残りません。

本番に出すときに気をつけたこと

3回の更新は、どれも次の流れで本番に出しました。

  1. 手元のコンテナで動作と応答を確かめる。ブラウザ側はPlaywrightで主要なページを開き、JavaScriptやSRIのエラーが出ないこと、見た目が変わらないことを確かめた
  2. 手元で試せない部分(データベースや外部サービスとのやり取り)は、デプロイ後に確かめる項目として先に書き出しておく
  3. デプロイしたら項目を順に確かめる。問題があれば、Cloud Runの直前の版にすぐ戻す

戻し方を先に決めておくと、ライブラリを上げることへの心理的なハードルがかなり下がります。

やってみて分かったこと

  • 件数の大半は、使っていないものから来ていた。 ベースイメージのOSパッケージも、古いスマホ用ページも、アプリの本体ではありませんでした
  • 件数より、自分のサイトへの影響で順番を決める。 Starletteの6件のうち、実際に影響があったのは1件でした。そこから先に塞ぎ、残りは落ち着いて対応できました
  • ブラウザ側も依存ライブラリ。 requirements.txt でバージョンを固定していても、HTMLの <script> は見落としていました
  • AIの出力は信用しない入力として扱う。 AIが外部の文章を読んでいる以上、その出力も外から来たものと同じです

まとめ

今回の対応で、「動いているから触らない」ものほど古いまま残っていることがよく分かりました。サポートの終わったOS、バージョンを指定していないCDNの読み込み、使っていないページ。どれも、ふだんの開発では目に入らない場所です。

個人サイトでも、狙われるときは大きなサービスと同じ手口で狙われます。ニュースになってから慌てるのではなく、定期的に自分で調べて、影響のあるものから順に塞いでいく。今回の流れを、これからも続けていきたいと思います。