1. 導入
Network Checkは、ドメインやIPアドレスに対する公開DNS情報やTLS設定などを外部から読み取って表示する、read-onlyの確認サービスです。侵入試験や脆弱性診断を行うものではありません。今回のSecurity Hardening(セキュリティ上の弱点をレビューで洗い出し、対策を積み重ねていく作業)では、Network Check自身のWeb入力境界と、公開レスポンスに含まれるエラー情報の扱いを中心に確認しました。Network Check全体を包括的に安全化する作業ではありません。
この作業は、何かが破られたことへの対応ではなく、既存の防御が実際にどこまで機能しているかを、コードとテストだけでなく実際に動いているアプリケーションのふるまいまで確認しようとした、読み取り専用のセキュリティレビューから始まりました。
その過程で、最初は妥当に見えた設計判断が、実際には想定通りに機能していなかったことが一つ見つかりました。本稿はその経緯の記録です。
2. 最初に確認した既存の防御
今回のレビューでは、XSS、SQL Injection、Command Injection、そしてエラー情報の漏えいについても確認しており、確認した範囲では既存の防御構造が機能していることを確認できました。
テンプレートエンジンのJinja2はデフォルトでHTMLエスケープを行っており、|safeのような明示的なエスケープ解除は使われていません。JavaScript側もDOMへの書き込みにtextContentを使っており、innerHTMLのような形でユーザー入力をそのまま挿入する箇所はありませんでした。SQLを発行する部分は一貫してプレースホルダによるパラメータバインディングを使っており、文字列連結でSQL文を組み立てている箇所はありません。外部コマンドを呼び出すHTTP/2チェックも、シェル経由ではなく引数リストで直接プロセスを起動しており、ドメイン名は事前に正規表現で検証されてから渡されます。
これらの箇所に対しては、シェルメタ文字やSQLメタ文字などを含む境界値・回帰入力を用いた回帰テストを追加しました。これは第三者システムへの攻撃を試みるものではなく、既存の防御が壊れていないことを今後のコード変更に対しても継続的に確認できるようにする、防御確認用の安全なテストです。エラーメッセージについても、内部の例外詳細やファイルパスをそのまま公開レスポンスに含めていた箇所が複数あり、これらは汎用的なメッセージに置き換えました。これらは今回の作業の主役ではなく、次に述べるinput sizeの制限をめぐる経緯こそが、この記事で伝えたいことの中心です。
3. 当初の実装案:Content-Lengthへの依存
resource exhaustionを抑えるためにrequest body sizeへ上限を設ける、という一般的な設計方針に沿って、今回のHardening作業では、HTTPリクエストのContent-Lengthヘッダを読み取り、それが一定のバイト数(8 KiB)を超えていれば413(リクエストbodyが大きすぎることを示すHTTP status)を返す案から着手しました。これはNetwork Checkに元々あった実装ではなく、今回のHardening作業の中で最初に採用した案です。Content-Lengthはクライアントがリクエストボディの正確なバイト数を事前に申告するヘッダで、これを見れば本文を全部受信する前に大きさを判断できます。一見すると、効率的で合理的な設計に見えました。
4. 境界の穴:自己申告ヘッダと文字数上限の不整合
しかしレビューの過程で、この設計には見落としがあることが分かりました。Content-Lengthはあくまでクライアントが自己申告する値です。HTTPの仕様上、クライアントはこのヘッダを送らずにリクエストを送ることもできますし、Transfer-Encoding: chunkedという、ボディを分割して逐次送信する方式を使うこともできます。chunked方式では、送信開始時点でボディ全体の長さが確定していないため、Content-Lengthは使われません。
今回使っていたStarletteのバージョン帯のソースを確認した範囲では、リクエストボディを読み出すRequest.body()やフォームパーサ自体に、ボディサイズを制限する組み込みの仕組みはありませんでした。今回のapplication layerで実装したbody-size制限としては、Content-Lengthがない、またはchunked転送を使うrequestに対して意図した制限として機能しなかった、ということです。「ヘッダを確認した」ことと、「実際に受信するボディのサイズを制限した」ことは、同じではありませんでした。
同じタイミングで、もう一つの不整合にも気づきました。ドメイン名やURLの入力には文字数ベースの上限(URLは2048文字)を設けていましたが、body-sizeの上限はバイト数ベース(8 KiB)でした。文字数制限とバイト数制限を別々に決めると、パーセントエンコーディングを経た実際のバイト数で食い違うことがあります。単純に計算すると、2048文字のURLフィールドだけでも、エンコード後には24,576バイトに達しうる計算になり、8 KiBという上限を上回ってしまいます。
5. 最初の修正でも413にはならなかった
Content-Length依存を外すために、まずbody読み取り時に独自の例外を送出し、それを413へ変換する方式を試しました。ところが、実際にテストを実行してみると、想定していた413ではなく、汎用的な400(Bad Request)が返ってくることが分かりました。
原因を追ったところ、今回使用していたFastAPI 0.115.6の処理経路では、リクエストボディの読み取り処理の中で発生した例外をルーティング処理が捕捉し、汎用的な400へ変換していたことが実測で確認できました。アプリケーションのコードの中では413を返すつもりで書いていても、実際にリクエストがルーティングへ渡る過程で、その意図が上書きされてしまっていたということです。
これは、ソースコード上の「意図」と、実際にフレームワークを通過したときの「挙動」が、必ずしも一致するとは限らないことを示す具体例でした。この挙動は今回のアプリケーションの実装経路で観測したものであり、他バージョンへの一般化は確認していません(詳細は末尾「この事例が示すこと、示さないこと」を参照)。
6. 境界をASGI層へ移す
このアプリケーションでは、HTTPリクエストはASGI(PythonのWebアプリケーションとサーバーの間の標準的なインターフェース)を通ってStarlette/FastAPIへ渡り、その先でNetwork Checkの処理へ到達します。図にすると、おおよそ次のような位置関係になります。
client request
→ ASGI server / interface
→ Starlette / FastAPI (routing, exception handling)
→ Network Check application
先ほどの400への変換は、この経路のうちStarlette/FastAPIの段階で起きていました。そこで設計をやり直し、Content-Lengthヘッダの値に依存するのではなく、一番外側のASGI interfaceのreceive()という、実際にリクエストボディのバイト列を受け取る段階で、受信したバイト数そのものを積算するミドルウェアを新たに実装しました。
このBodySizeLimitMiddlewareは、リクエストがStarlette/FastAPIのルーティングやアプリケーションコードに到達するよりも前の段階で動作します。受信したバイト数が上限を超えた時点で、ルーティングを経由せずに直接413レスポンスを送信して処理を打ち切ります。上限内であれば、受信したデータをいったんバッファに保持し、そのまま下流のアプリケーションへ「再生」して渡します。この設計であれば、Content-Lengthの有無やchunked転送かどうかに関わらず実際に受信したバイト数だけを基準に判断できますし、先ほどの「ルーティングが例外を捕まえてしまう」という問題も、そもそもルーティングに到達する前に応答を返すことで回避できます。
あわせて、それまで別々に存在していた複数のボディサイズ関連の定数を、REQUEST_BODY_MAX_BYTES = 32768(32 KiB)という一つの正本定数に統合しました。この値は、URLの文字数上限である2048文字が最悪の場合にパーセントエンコーディングで膨張した際の値(24,576バイト)に余裕を持たせて設定したものです。この32 KiBという値は、Network Checkの今回の入力上限に合わせて設定したものであり、一般的な推奨値ではありません。今回確認した入力設計上の不整合は、この統合によって解消しました。
7. テストが通った後に何をしたか
ここまでの変更を反映して、ソースコードに対する回帰テストを実行し、最終的に73件すべてが成功しました(73/73 PASS)。しかしそこで作業を止めず、実際にNetwork Checkが動作している検証用サーバーへこの変更を反映し、稼働中のアプリケーションに対して直接リクエストを送って挙動を確認しました。
具体的には、32,768バイトを超えるPOSTリクエストを送って実際に413が返ることを確認し、253文字を超えるdomainフィールドを送って422(入力値が妥当でないことを示すステータスコード)が返ることを確認し、/usage-metricsというエンドポイントのレスポンスに、内部のファイルパスやデータベースのパス、例外の詳細などが含まれていないことを確認しました。
なぜソースコードのテストが全て通っているのに、あらためて実際のサーバーで確認する必要があったのか。それは、ここまで述べてきたとおり、Content-Lengthだけに頼った設計や、フレームワーク内部の例外処理との衝突が、いずれもソースコードを読んだだけでは気づきにくく、実際にリクエストを送って初めて挙動として現れたものだったからです。このruntime確認によって「安全性が確認できた」ということではなく、今回対象とした境界について、実装意図とruntime挙動が一致することを確認した、というのがここでの実際の到達点です。ソースコード上のテストが通っていることと、実際に動いているアプリケーションでその境界が機能していることは、今回の経験では別々に確認する価値のある対象でした。
その後、2026年9月14日に同じSecurity HardeningをJupiter上の2つのNetwork Check runtimeへ反映した際にも、32,768バイトを超える通常POSTとchunked送信が413、254文字のdomain入力が422となり、公開レスポンスへのerror/path leakageがないことを再確認しました。これは今回対象とした境界についての後日再検証であり、アプリケーション全体の安全性を示すものではありません。
8. 今ならどうするか
ここまでの経緯を現在の視点から補足すると、今回の実装が使っていたFastAPIのバージョンは0.115.6で、これが依存するStarletteのバージョンは0.40.0以上0.42.0未満という範囲でした(正確に解決されたStarletteのバージョンそのものは、ロックファイルが存在しないため特定できていません)。
その後、Starletteの本家プロジェクトを確認したところ、RequestBodyLimitMiddlewareおよびmax_body_sizeという、公式にリクエストボディのサイズを制限する仕組みを追加するPull Request(#3431)が実際にマージされ、Starlette 1.6.0という正式リリースに最初に含まれていたことを、Starlette公式GitHubのrelease・tag・commit履歴から確認しました。このリリースは、今回使っていた依存バージョンの範囲(0.40.0以上0.42.0未満)には含まれていません。今回の依存バージョン帯では、その後Starlette本体に追加された公式機能を利用することはできませんでした。そのためNetwork Checkではapplication側で独自の境界を実装しました。
現在Starletteを使って新しくアプリケーションを実装するのであれば、公式に提供されているRequestBodyLimitMiddlewareのような仕組みを検討する余地があります。今回の記録は、あくまで実装した当時のバージョン境界における出来事であり、当時と現在を混同しないよう時期を区切って書いています(Generalization Boundaryの詳細は末尾「この事例が示すこと、示さないこと」に一本化しています)。
9. この事例が示すこと、示さないこと
ここまでの経緯は、Gitの記録やテスト結果、稼働中サーバーへのリクエストという形で確認できる、今回のNetwork Checkという一つの実装における事実です。
同じ種類の不整合が他のFastAPI・Starletteのバージョンや他のASGIアプリケーションで一般的に起きるのかどうかは確認しておらず、この記事はそれを一般的な法則として提示するものではありません。「Starletteにはボディサイズを制限する機能が存在しない」という主張でも、「FastAPIでは独自ミドルウェアの実装が必須である」という主張でもありません。また、今回の作業によってNetwork Checkが「完全に安全になった」ということも意味しません。Network Check自身のSECURITY_POLICY.mdにも明記されている通り、これは脆弱性診断サービスではなく、今回確認したのは私たちが認識できた特定の境界についての挙動だけです。
10. AI-assisted developmentとHuman Gate
この作業はAI-assisted developmentとして進めましたが、この記事はそれを宣伝するためのものではありません。今回意味があったと感じているのは、「AIを使ったから安全になった」ということではなく、提案、実装、そして稼働中サーバーでの検証までの一連の作業を、一つの連続した判断としてまとめて進めるのではなく、それぞれの区切りで人間が個別に承認する形を取ったことでした。
例えば、最初の実装が413ではなく400を返してしまうという問題については、コミット前のレビュー段階で見つかり、そこで設計を修正する判断を挟むことができました。AI支援の開発であっても、提案・実装・確認をひとつの同じ判断として扱わず、境界ごとに人間が立ち止まって承認したことが、今回の発見と修正につながった一因だったと考えています。
11. おわりに
同じような入力サイズの境界を持つ実装を見直す際の、一つの具体例として参考になれば幸いです。