---
title: HttpServletRequest#getRequestURI() をパスの判定に使うと脆弱性を埋め込みやすい
summary: HttpServletRequest#getRequestURI()をパス判定に使うと、URLエンコードの差異でSpring MVCのルーティングとずれ、認可漏れなどの脆弱性につながります。この記事は具体例と、パス変数やリソース属性で判定するなどの対策を解説します。
tags: ["Spring Boot", "Spring MVC", "Java", "Vulnerability"]
categories: ["Programming", "Java", "org", "springframework", "web"]
date: 2026-11-06T08:27:28.239Z
updated: 2026-11-06T08:27:28.239Z
---

コードを自分で書かなくなり、AIに書かせるようになって久しいですが、AIに書かせた、あるコードを[VulnHunter](https://github.com/nealbridges/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を照合する

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

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

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

```java
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` のように、フィルタが照合する部分を通常の文字のエンコーディングで書いた表記である。

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

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

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

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

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

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

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

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

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

判定はbeanに置く。

```java
@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/**")` のようなパターンになっているか
- テストが正規の綴りだけで、エンコーディングやマトリクスパラメータを送っていないか

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