Spring Bootで検索APIを作るとき、MyBatisの動的SQLを使えば、入力された条件に応じてWHERE句や並び順を切り替えられます。ただし、検索値を安全に渡すことと、取得できる範囲や件数を制限することは別の仕事です。
この記事では、組織内のユーザーを検索するAPIを題材に、入力検証、Serviceでの正規化、Mapper XML、認可条件、異常系テストをつなげて整理します。対象はMapper XMLによる動的SQLです。別ライブラリの「MyBatis Dynamic SQL」は扱いません。
動的SQLのセキュリティで確認する範囲
値のバインド・入力検証・認可条件の役割を分ける
#{}によるバインドは、入力をSQLの命令ではなく値として渡すための仕組みです。一方、文字列の長さやページサイズを制限するのは入力検証、他の組織のデータを取得させないのは認可条件の役割です。
| 対策 | 担当すること | それだけでは防げないこと |
|---|---|---|
| 値のバインド | 検索値をSQL構造から分離する | 権限外の行の取得、過大な取得要求 |
| 入力検証 | 型、長さ、範囲、許可値を確認する | SQL文字列への危険な埋め込み |
| 認可条件 | 検索対象を利用者の権限内に限定する | 高負荷な検索や不適切なログ出力 |
名前から引用符を削除するような処理をSQLインジェクション対策の中心にすると、正当な入力まで変えてしまいます。値はバインドし、業務上の制約は別に検証する構成にします。
検索APIの仕様と実装環境をそろえる
例として、以下の仕様の検索APIを考えます。認証済み利用者が所属組織のユーザー一覧を閲覧できるものとし、組織内でも個別の閲覧制限がある場合は、追加の認可条件が必要です。
| 項目 | サンプルの仕様 |
|---|---|
| エンドポイント | POST /api/users/search。JSONで検索条件を受け取る |
| name | 最大100。前後の空白を除去し、空なら未指定。部分一致検索 |
| ids | 未指定・nullはID条件なし。空配列は該当なし。最大100要素で、各要素は正の整数 |
| sort | nameAsc、idDescのみ。未指定・nullはidDesc |
| size | 1〜100。未指定・nullは20 |
| offset | 0〜10000。未指定・nullは0 |
| 検索条件なし | HTTP 400。名前またはIDリストを要求する |
| 取得範囲 | 認証・認可処理が確定した組織内に限定する |
Java例はrecordを使えるJava 17以降の記法と、jakarta.validationを利用するSpring Boot構成を想定しています。Bean Validationの実装、Spring MVC、Spring Security、MyBatisの連携設定が必要です。
SQL例はLIMIT ... OFFSET ...とLIKE ... ESCAPE '!'を利用できるDB向けの構文例です。採用DB・JDBCドライバでの対応、照合順序、バインド動作を確認してください。依存関係や実行手順は、利用するJava、Spring Boot、MyBatis、Starter、DB、JDBCドライバの種類とバージョンに合わせて整えます。
Spring Bootで検索条件を検証してMapperへ渡す
検索DTOで文字列・IDリスト・ページ指定の制約を表す
検索条件と取得権限を混ぜないため、DTOに組織IDを含めません。次のDTOでは、IDリスト自体の要素数に加え、各要素のnullと数値範囲も検証します。
package com.example.search;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import java.util.List;
public record UserSearchRequest(
@Size(max = 100)
String name,
@Size(max = 100)
List<@NotNull @Positive Long> ids,
@Pattern(regexp = "nameAsc|idDesc")
String sort,
@Min(1) @Max(100)
Integer size,
@Min(0) @Max(10000)
Integer offset
) {}任意項目のnullは受け入れ、既定値はServiceで補います。idsの要素には@NotNullがあるため、[null]は入力エラーです。[]は要素数の制約を満たすので、Serviceで「該当なし」として扱えます。
@Sizeによる文字列の長さは、見た目の文字数と常に一致するわけではありません。また、この例は正規化前の入力に上限を適用するため、前後の空白を含めて100を超える入力も拒否します。
Controllerで検証を実行し不正な入力を応答へ変換する
DTOに注釈を付けるだけで終わらせず、@Validでリクエストの検証を実行します。以下はControllerの抜粋です。serviceはコンストラクタで注入し、SearchPrincipalはアプリケーション側で実装する認証済み情報の型とします。
@PostMapping("/api/users/search")
public List<UserSummary> search(
@Valid @RequestBody UserSearchRequest request,
@AuthenticationPrincipal SearchPrincipal principal) {
if (principal == null) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
}
if (!principal.canReadUsers() || principal.tenantId() <= 0) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN);
}
return service.search(request, principal.tenantId());
}SearchPrincipalの組織IDと閲覧権限は、認証・認可処理が検証した情報である必要があります。リクエスト本文や未検証のヘッダーをこの型に詰めただけでは、信頼できる情報にはなりません。Security設定でも、このエンドポイントに認証を要求します。
入力エラーの応答には、受け取った名前やJSON本文をそのまま含めないようにします。次は例外ハンドラーの抜粋です。InvalidSearchExceptionは検索仕様違反を表す独自の実行時例外とします。
@RestControllerAdvice
public class SearchErrorHandler {
@ExceptionHandler({
MethodArgumentNotValidException.class,
HttpMessageNotReadableException.class,
InvalidSearchException.class
})
public ResponseEntity<Map<String, String>> invalidInput() {
return ResponseEntity.badRequest().body(
Map.of("code", "INVALID_SEARCH_REQUEST")
);
}
}制約違反とJSONの読み取り失敗は、この例ではHTTP 400にまとめます。ただし、数値文字列や小数が整数へ変換されるかはJSONデシリアライザーの設定にも左右されます。厳密なJSON型を要求する場合は型変換の設定をそろえ、HTTP経由で確認してください。
Serviceで未指定・空文字・空リストの扱いを統一する
Serviceでは、まず入力と取得範囲を検証し、その後に正規化します。次の抜粋では、注入したjakarta.validation.Validatorで再検証することで、Controller以外から呼び出した場合にも同じ制約を適用します。
public List<UserSummary> search(
UserSearchRequest request, long authorizedTenantId) {
if (authorizedTenantId <= 0) {
throw new AccessDeniedException("Access denied");
}
if (request == null || !validator.validate(request).isEmpty()) {
throw new InvalidSearchException();
}
String name = request.name() == null ? null : request.name().strip();
if (name != null && name.isEmpty()) {
name = null;
}
List<Long> ids = request.ids() == null
? null : List.copyOf(request.ids());
String sort = request.sort() == null ? "idDesc" : request.sort();
int limit = request.size() == null ? 20 : request.size();
int offset = request.offset() == null ? 0 : request.offset();
if (ids != null && ids.isEmpty()) {
return List.of();
}
if (name == null && ids == null) {
throw new InvalidSearchException();
}
String pattern = name == null ? null : literalContainsPattern(name);
return mapper.search(
authorizedTenantId, pattern, ids, sort, limit, offset);
}
private static String literalContainsPattern(String value) {
String escaped = value.replace("!", "!!")
.replace("%", "!%")
.replace("_", "!_");
return "%" + escaped + "%";
}空リストの早期returnより先に検証するため、ids=[]でも並び順やページサイズが不正ならHTTP 400です。認可も空リスト判定より先に行います。空配列を指定すれば入力検証や認可を回避できる、という分岐にしないためです。
authorizedTenantIdという引数名自体が認可を保証するわけではありません。このServiceを別の処理から使う場合も、呼び出し元で利用者と取得範囲の対応を確認する必要があります。
Mapper XMLで検索値と並び順を安全に扱う
ここからは、正規化済みの値をMapperへ渡します。動的SQLの基本構文を先に確認したい場合は、次の記事でif、choose、foreachの役割を整理できます。

検索値とIN句の要素を#{}で渡す
Mapperの引数には名前を付け、XMLと対応させます。戻り値のUserSummaryは、idとnameのプロパティ、引数なしコンストラクタ、getter・setterを持つJavaBeanを別途用意する想定です。
package com.example.search;
import java.util.List;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@Mapper
public interface UserSearchMapper {
List<UserSummary> search(
@Param("tenantId") long tenantId,
@Param("namePattern") String namePattern,
@Param("ids") List<Long> ids,
@Param("sort") String sort,
@Param("limit") int limit,
@Param("offset") int offset
);
}次はMapper XMLの例です。テーブルusersにはid、tenant_id、nameがあり、組織内でIDが一意になる前提です。XMLの配置・読み込み設定はアプリケーション側で行います。
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.search.UserSearchMapper">
<select id="search" resultType="com.example.search.UserSummary">
SELECT u.id, u.name
FROM users u
WHERE u.tenant_id = #{tenantId}
<if test="namePattern != null">
AND u.name LIKE #{namePattern} ESCAPE '!'
</if>
<choose>
<when test="ids != null and ids.size() > 0">
AND u.id IN
<foreach collection="ids" item="id"
open="(" separator="," close=")">
#{id}
</foreach>
</when>
<when test="ids != null">
AND 1 = 0
</when>
</choose>
<if test="namePattern == null and ids == null">
AND 1 = 0
</if>
ORDER BY
<choose>
<when test='sort == "nameAsc"'>
u.name ASC, u.id ASC
</when>
<otherwise>
u.id DESC
</otherwise>
</choose>
LIMIT #{limit} OFFSET #{offset}
</select>
</mapper>名前、組織ID、IN句の各ID、LIMIT、OFFSETをすべて#{}で渡しています。IDをカンマ区切りの文字列に変換してSQLへ埋め込む必要はありません。名前とIDリストの両方があるときは、両条件をANDで適用します。
並び順の許可値をchooseで固定SQLへ対応させる
列名やASC・DESCはSQL構造の一部です。ORDER BY #{sort}としても、入力した文字列を列名として扱う指定にはなりません。この例ではDTOで許可値を制限し、XMLのchooseで固定のSQLへ対応させています。
nameAscはu.name ASC, u.id ASC、idDescはu.id DESCになります。名前が同じ場合にも順序が決まるよう、名前順にはIDを追加しています。ただし、取得の合間にデータが追加・更新されることによるOFFSET方式のずれまでは防げません。
未許可の並び順はAPIとServiceで拒否します。XMLのotherwiseは固定SQLに着地するための分岐であり、入力エラーを受け入れるAPI仕様ではありません。バインドと文字列置換の仕組みは、次の記事で補足しています。

LIKE検索のワイルドカードを仕様として決める
値をバインドしていても、LIKEに渡した%や_はパターンとして解釈されます。SQLインジェクション対策と、LIKEの検索範囲の制御は分けて考えます。
この記事では、利用者が入力した%と_を文字そのものとして検索します。ServiceのliteralContainsPatternは、エスケープ文字の!を先に二重化し、その後に%と_へ!を付けています。最後に部分一致用の%を両端へ追加します。
たとえば入力50%_off!は、バインド値%50!%!_off!!%に変換されます。XML側のESCAPE '!'と組み合わせて使う設計です。大文字・小文字や全角・半角の一致判定、追加のパターン構文はDBと照合順序にも依存するため、対象DBで確認してください。
生成されるSQLとバインド値の対応を確認する
組織IDが7、名前が50%_off!、IDリストが[10, 20]、並び順がnameAsc、取得件数が20、OFFSETが0なら、SQLは概念的に次の形になります。
SELECT u.id, u.name
FROM users u
WHERE u.tenant_id = ?
AND u.name LIKE ? ESCAPE '!'
AND u.id IN (?, ?)
ORDER BY u.name ASC, u.id ASC
LIMIT ? OFFSET ?| 位置 | バインド値 |
|---|---|
| 1 | 7 |
| 2 | %50!%!_off!!% |
| 3 | 10 |
| 4 | 20 |
| 5 | 20 |
| 6 | 0 |
並び順は固定SQLへ分岐するので、バインド値には現れません。SQLと値を混ぜた文字列だけを見るより、プレースホルダーの位置と値を分けて確認すると、条件の欠落や対応のずれを追いやすくなります。
条件の欠落と過大な取得要求を防ぐ
必須の認可条件を任意の検索条件から独立させる
Mapper XMLのWHERE u.tenant_id = #{tenantId}は、任意条件の外に固定しています。組織IDがnullでなければ追加する、という書き方にすると、値の受け渡しミスが条件の欠落につながります。
取得範囲は認証済み情報から決め、SQLでは必須条件として残します。検索欄が空になっても、IDが指定されても、並び順が変わっても、この条件を外さないことが重要です。
OR検索を追加するときにも注意します。tenant_id = ? AND name LIKE ? OR email = ?では、メール条件だけで他組織の行が一致し得ます。意図が「組織内で名前またはメールが一致」なら、tenant_id = ? AND (name LIKE ? OR email = ?)という括弧が必要です。
条件未指定と空リストで検索範囲を意図せず広げない
このAPIでは、ID条件の未指定と空リストを区別します。ids=nullなら名前で検索でき、ids=[]なら他の条件にかかわらず該当なしです。空リストを単に「IN句を付けない」と解釈すると、取得範囲が広がるためです。
Serviceは空リストならMapperを呼び出さずに返します。XMLにも空リスト用のAND 1 = 0を置き、誤って直接呼ばれた場合にもIN条件が消えて検索範囲が広がらないようにしています。両条件がnullの場合も、XMLでは0件に限定します。
ただし、XMLは入力検証の代わりにはなりません。上限値や未許可の並び順はServiceで検証する契約なので、Mapperを検索APIの別入口として直接公開しないようにします。
ページサイズ・OFFSET・IN句の要素数に上限を設ける
この例ではページサイズ100、OFFSET 10000、IDリスト100要素を上限としています。実際の上限は、データ量、実行計画、応答時間、DBのパラメータ数制約などを確認して決めます。
IDリストの上限は重複を除く前の入力要素数に適用します。また、DTOの検証はJSONを読み取った後に行われるため、大きすぎるリクエストへの対策にはHTTP本文のサイズ制限も必要です。
ページ番号からOFFSETを計算する設計なら、乗算前にlongへ変換し、計算後の上限も確認します。数値をバインドするだけでは、整数のオーバーフローや深いページの負荷は防げません。
LIMITは返す行数の上限であり、DBが調べる行数の上限ではありません。部分一致検索や並び替えには、少ない取得件数でも時間がかかる場合があります。タイムアウトやリクエスト頻度の制御も含めて考え、深いページが必要なら取得方式を見直します。

関連データの一括取得でも取得範囲を維持する
検索結果に関連データを付ける場合は、認可済みの親一覧からIDを集めます。クライアントが送ったIDリストを、そのまま関連データ取得用のMapperへ流さないようにします。
関連データのSQLにも組織条件を残します。子テーブルに組織IDがない場合は、組織をまたいで親を一意に特定できるキーで親テーブルへJOINし、親の組織IDで絞り込みます。親IDが組織内でしか一意でない場合は、子テーブルにも組織IDを保持し、親IDと組織IDの両方でJOINする必要があります。
親一覧が空なら子取得を省略し、親の件数だけでなく子の取得量にも注意します。親が20件でも、子が大量に存在すれば応答は大きくなります。JOINと一括取得の組み立て方は、次の記事で補足しています。

検索APIとMapperの異常系をテストする
検索APIの仕様を確認するためのテスト観点を、HTTP境界のテスト、Serviceのテスト、対象DBを使うMapper結合テストに分けて整理します。Mapperをモックにしたテストだけでは、SQLの構文やLIKEの一致判定は確認できません。
特殊文字を含む検索値と未許可の並び順を確認する
名前には引用符、%、_、!、日本語を含む値を用意します。引用符を含む名前が通常のデータとして検索できること、ワイルドカード文字が仕様どおり文字として扱われることを確認します。
たとえば' OR 1=1 --という検索値でもSQL構造が変わらず、他の行や他組織の行まで返らないことを確認します。単に例外が出ないだけでなく、返却されるIDの集合を検証することが大切です。
並び順はnameAscとidDescが受理され、name DESC、NAMEASC、空文字がHTTP 400になることを確認します。不正な入力でMapperが呼ばれないことも検証対象です。
null・空リスト・上限超過で期待する応答と実行有無を確認する
次の期待値は、認証と閲覧権限が有効で、表にない項目も妥当な場合のものです。「Mapperなし」は検索Mapperが呼ばれない意味であり、認証処理などを含むシステム全体のDBアクセスが0回という意味ではありません。
| 入力 | 期待する応答 | 検索Mapper |
|---|---|---|
| name未指定、ids未指定・null | 400 | 呼ばない |
| nameが空文字・空白のみ、ids=null | 400 | 呼ばない |
| nameが有効、ids=null | 200、名前で絞り込む | 呼ぶ |
| ids=[] | 200、空配列 | 呼ばない |
| ids=[null]、[0]、[-1] | 400 | 呼ばない |
| idsが100要素/101要素 | 200/400 | 呼ぶ/呼ばない |
| sizeが0/1/100/101 | 400/200/200/400 | 妥当な場合だけ呼ぶ |
| offsetが-1/0/10000/10001 | 400/200/200/400 | 妥当な場合だけ呼ぶ |
| ids=[]かつsize=101 | 400 | 呼ばない |
Serviceの単体テストでは、空リスト時の戻り値と呼び出しの有無を合わせて確認できます。次はJUnitとMockitoを使う場合のテストメソッド例です。serviceには実際のValidatorとモックのMapperを注入する前提です。
@Test
void emptyIdsReturnsEmptyWithoutCallingMapper() {
var request = new UserSearchRequest(
null, List.of(), "idDesc", 20, 0);
var result = service.search(request, 7L);
assertTrue(result.isEmpty());
verifyNoInteractions(mapper);
}
@Test
void invalidSizeIsRejectedEvenWhenIdsAreEmpty() {
var request = new UserSearchRequest(
null, List.of(), "idDesc", 101, 0);
assertThrows(InvalidSearchException.class,
() -> service.search(request, 7L));
verifyNoInteractions(mapper);
}HTTPテストでは、不正なJSON、数値範囲外、本文なし、本文がnullの場合も追加します。未認証と閲覧権限なしのケースは、Security設定を含めて401・403への変換と検索Mapperの非呼び出しを確認してください。
検索条件の組み合わせと権限外データの非取得を確認する
Mapper結合テストでは、組織Aと組織Bに同じ名前のユーザーを用意します。組織Aとして検索し、名前だけ、IDだけ、名前とIDの両方、各並び順のすべてで組織Bのデータが返らないことを確認します。
名前とIDを同時指定するテストでは、名前だけに一致する行、IDだけに一致する行、両方に一致する行を分けて用意します。両方に一致する行だけを期待値にすれば、AND条件の片方が抜けた不具合を検出できます。
自組織と他組織のIDを混ぜた場合も、他組織の行は返さない契約です。権限外のIDと存在しないIDを、応答で区別する必要があるかも検討してください。組織IDを本文へ追加しても取得範囲が変更されないこと、Mapperを直接呼んだ空リストのテストで0件になることも確認します。
検証用データでSQLを確認しログへの機密情報出力を避ける
SQL確認には検証用データを使います。確認するのは、組織条件が残っていること、任意条件とIN句が期待どおりに追加されること、並び順が固定候補になること、プレースホルダーと値が対応していることです。
SQLのバインド値には、名前や識別子などの個人情報が含まれ得ます。本番でSQL・パラメータ・リクエスト本文を無条件に出力しないようにし、認証情報やトークンもログへ含めないでください。
通常の運用監視では、リクエストID、処理時間、返却件数、エラーコードなど、必要な情報を選びます。詳細なSQL出力は環境と期間を限定し、ログの閲覧権限と保存期間も決めます。
検証時は、利用バージョン、DB設定、検証データ、期待値と結果をそろえて記録します。
まとめ
Spring BootとMyBatisの動的SQLを安全に扱うには、入力受付からMapperまで、それぞれの層が担当する制約をそろえる必要があります。検索値は#{}で渡し、並び順は許可値から固定SQLへ対応させます。
さらに、null・空文字・空リストの意味を決め、認証済み情報から確定した取得範囲を必須条件として残します。ページサイズ、OFFSET、IN句の要素数には上限を設け、エラー時や空リスト時に検索Mapperを呼ばないことまで確認します。
テストではHTTP応答だけでなく、実際に返る行、権限外データの非取得、SQLとバインド値の対応を検証してください。対象DBでの検証結果に基づいて、構文と上限値を調整しましょう。


コメント