HttpServletRequest#getRequestURI() をパスの判定に使うと脆弱性を埋め込みやすい

コードを自分で書かなくなり、AIに書かせるようになって久しいですが、AIに書かせた、あるコードをVulnHunterで脆弱性検査したところ、本当の脆弱性っぽい指摘があったのでメモしておきます。AIにコードを書かせているとこういうコードを書く可能性があるので、人間側も注意しましょう。

ちなみに実際の修正は https://github.com/making/kagami/commit/1b3e4b678a53af7ab0df23c326f7a7af6a155f1b です。

以下はこの問題を汎化させ、DeepSeek-V4.1-Flash-1017に書かせた文章です。


Spring MVC のアプリで、リクエストのURLパスを見て、認可、テナントの分離、公開範囲の制御、処理の分岐などを行うコードがある。サーブレットフィルタ(jakarta.servlet.Filter)はその置き場所の一つで、DispatcherServlet より先にリクエストを受け取り、許可されないリクエストをコントローラに届く前に止める。テナントごとに公開範囲を分ける、管理用のURLだけを保護する、といった粗い単位の判定を、ハンドラから切り離して一箇所に置くために使われる。

こうしたコードで HttpServletRequest#getRequestURI() が返す文字列をそのまま使うと、Spring MVC が実際にルーティングに使うパスとずれる。ずれがあれば、その分だけ判定をすり抜けたリクエストがコントローラに届く。認可に限らず、パスを見て何かを決める処理で同じことが起きる。

以降では、パスからテナントIDを取り出して照合するフィルタと、それに対応する @GetMapping のハンドラを例にする。/tenants/{tenantId}/documents/** を1つのコントローラで受け、alpha テナントだけを許可する最小の構成である。テナントIDは一例であり、パスから取り出す値、前方一致の対象、ハンドラ選択の条件のいずれでも同じずれが生じる。

例:生のURIでテナントIDを照合する

アプリは、次のハンドラとフィルタで構成する。

@GetMapping("/tenants/{tenantId}/documents/**")
Map<String, Object> document(@PathVariable String tenantId) {
    return Map.of("tenantId", tenantId);
}

フィルタは、生のURIからテナントIDを取り出して照合する。

String uri = request.getRequestURI();
if (!uri.startsWith("/tenants/")) {
    return; // skip check
}
String[] segments = uri.split("/");
String tenantId = segments.length > 2 ? segments[2] : "";
if (!ALLOWED_TENANTS.contains(tenantId)) {
    response.sendError(403);
    return;
}

このフィルタに次の2つのリクエストを送る。

GET /tenants/beta/documents/1        -> 403
GET /%74enants/beta/documents/1      -> 200 {"tenantId":"beta"}

%74 は t のパーセントエンコーディングである。2つ目はフィルタから見ると /tenants/ で始まらないので判定を飛ばし、コントローラはデコード後のパスから beta をパス変数として受け取る。同じリソースへのリクエストなのに、判定に使う文字列と、処理対象を決める文字列が食い違っている。

食い違いの出どころ

getRequestURI() はリクエストラインのパスをデコードせずに返す。一方、Spring MVC は @RequestMapping("/tenants/{tenantId}/documents/**") の照合にデコード後のパスを使う。/%74enants/beta/documents/1 はフィルタには %74 のまま届き、ルーティングには /tenants/beta/documents/1 として一致する。ハンドラは tenantId=beta で実行される。フィルタが判定を飛ばしたリクエストを、ルーティングは正規のリクエストとして受理し、ハンドラまで届く。マッピングが生のパスで照合していれば、このリクエストはどのハンドラにも一致しなかった。

どの表記がフィルタまで届くかは、前段の検証にも左右される。Spring Security の StrictHttpFirewall は . や .. のセグメント、URLエンコードされたドット(%2e)、;、// を含むパスをデフォルトで 400 で拒否するため、これらはフィルタの判定まで届かない。判定をすり抜けるのは、%74 のように、フィルタが照合する部分を通常の文字のエンコーディングで書いた表記である。

一般的にはどう書くべきか

食い違いを避けるには、判定に使う値を、パスの文字列ではなく、ルーティングが確定した値か、リソースそのものの属性から取る。方法を、フレームワークへの依存が少ない順に挙げる。

リソースの所有者で判定する

パスはリソースを引くキーとしてだけ使い、認可は読み込んだリソースの属性で行う。

var document = repository.findById(documentId).orElseThrow();
if (!document.tenantId().equals(caller.tenantId())) {
    throw new AccessDeniedException("tenant mismatch");
}

URIの綴りが何であっても、判定に使う値はデータから来るので、生のURIとデコード後のパスの食い違いが入り込まない。呼び出し元が alpha の権限で beta のリソースを要求すれば、URLの書き方に関係なくここで止まる。

ハンドラのパス変数で判定する

リソースを読み込む前にURLの範囲で判定したい場合は、ルーティングが取り出したパス変数で判定する。メソッドセキュリティにパス変数を渡せば、MVCがデコード・照合した後の値が入る。

@GetMapping("/v2/tenants/{tenantId}/documents/**")
@PreAuthorize("@tenantGuard.allows(#tenantId)")
Map<String, Object> document(@PathVariable String tenantId) {
    // ...
}

判定はbeanに置く。

@Component("tenantGuard")
public class TenantGuard {

    public boolean allows(String tenantId) {
        return Set.of("alpha").contains(tenantId);
    }
}

このアプリでは、/v2/tenants/alpha/documents/1 は200、/v2/tenants/beta/documents/1 と /v2/%74enants/beta/documents/1 は403になった。#tenantId はルーティング結果から取るので、URIの綴りに左右されない。

フィルタで自前判定する場合

フィルタ以外に判定を置けない場合は、ルーティングが使うのと同じ手順でパスを得る。UrlPathHelper.defaultInstance.getPathWithinApplication(request) は %74enants を tenants にデコードする。ただしドットセグメントは解決しないので、StrictHttpFirewall は有効のままにしておく。

判定をパス文字列に対して書く場合でも、次の3点は守る。

  • 比較はセグメント単位で行い、前方一致を使わない
  • デコードやセミコロン除去を、ルーティングと同じ手順で行う
  • ドットセグメントや、%2f・%2e のようなエンコーディングを弾く StrictHttpFirewall を無効化しない

見直すときの観点

  • getRequestURI() の文字列で判定していないか
  • パスの切り出しが split("/") や substring になっていないか
  • URLの範囲を指定する箇所が startsWith や contains ではなく、requestMatchers("/admin/**") のようなパターンになっているか
  • テストが正規の綴りだけで、エンコーディングやマトリクスパラメータを送っていないか

これらの見落としは、ルーティングをフレームワークに任せているアプリで起こりやすい。判定の側だけが生の文字列を見ている状態を作らないことである。