Spring BootでMyBatis動的SQLを安全に扱うセキュリティ実践

Spring BootでMyBatis動的SQLを安全に扱うセキュリティ実践

Spring Bootで検索APIを作るとき、MyBatisの動的SQLを使えば、入力された条件に応じてWHERE句や並び順を切り替えられます。ただし、検索値を安全に渡すことと、取得できる範囲や件数を制限することは別の仕事です。

この記事では、組織内のユーザーを検索するAPIを題材に、入力検証、Serviceでの正規化、Mapper XML、認可条件、異常系テストをつなげて整理します。対象はMapper XMLによる動的SQLです。別ライブラリの「MyBatis Dynamic SQL」は扱いません。

コードを利用する際は、Javaの記法とSQLの構文が利用環境に合うことを確認してください。必要な連携設定とDBの構文上の前提は、以下で整理します。

目次

動的SQLのセキュリティで確認する範囲

値のバインド・入力検証・認可条件の役割を分ける

#{}によるバインドは、入力をSQLの命令ではなく値として渡すための仕組みです。一方、文字列の長さやページサイズを制限するのは入力検証、他の組織のデータを取得させないのは認可条件の役割です。

対策担当することそれだけでは防げないこと
値のバインド検索値をSQL構造から分離する権限外の行の取得、過大な取得要求
入力検証型、長さ、範囲、許可値を確認するSQL文字列への危険な埋め込み
認可条件検索対象を利用者の権限内に限定する高負荷な検索や不適切なログ出力

名前から引用符を削除するような処理をSQLインジェクション対策の中心にすると、正当な入力まで変えてしまいます。値はバインドし、業務上の制約は別に検証する構成にします。

検索APIの仕様と実装環境をそろえる

例として、以下の仕様の検索APIを考えます。認証済み利用者が所属組織のユーザー一覧を閲覧できるものとし、組織内でも個別の閲覧制限がある場合は、追加の認可条件が必要です。

項目サンプルの仕様
エンドポイントPOST /api/users/search。JSONで検索条件を受け取る
name最大100。前後の空白を除去し、空なら未指定。部分一致検索
ids未指定・nullはID条件なし。空配列は該当なし。最大100要素で、各要素は正の整数
sortnameAsc、idDescのみ。未指定・nullはidDesc
size1〜100。未指定・nullは20
offset0〜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 ?
位置バインド値
17
2%50!%!_off!!%
310
420
520
60

並び順は固定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未指定・null400呼ばない
nameが空文字・空白のみ、ids=null400呼ばない
nameが有効、ids=null200、名前で絞り込む呼ぶ
ids=[]200、空配列呼ばない
ids=[null]、[0]、[-1]400呼ばない
idsが100要素/101要素200/400呼ぶ/呼ばない
sizeが0/1/100/101400/200/200/400妥当な場合だけ呼ぶ
offsetが-1/0/10000/10001400/200/200/400妥当な場合だけ呼ぶ
ids=[]かつsize=101400呼ばない

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での検証結果に基づいて、構文と上限値を調整しましょう。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

コメント

コメントする

CAPTCHA


目次