正規表現によるサービス拒否

利用者が指定する正規表現による資源枯渇を防ぐ

説明

ReDoSは、過剰なバックトラッキングでCPUを占有し、応答の遅延やサービス停止を起こす問題です。利用者の入力をそのまま正規表現として解釈する場合や、入れ子の量指定子など非効率なパターンを使う場合に生じます。攻撃者は(a+)+$や(.*)+のようなパターン、または照合コストの高い長い入力を送り、処理スレッドを占有できます。

想定される影響

  • CPU枯渇によるリクエスト処理の遅延や停止
  • 評価が続くことによるワーカースレッドや接続プールの枯渇
  • 遅延、タイムアウト、スケーリング費用の増加と可用性目標への影響

対処方法

  • 入力を直接正規表現のソースにせず、確認済みの固定パターンから選ぶか、通常のテキストとして扱ってください。
  • メタ文字に意味を持たせる必要がなければ、Pattern.quote(input)やPattern.compile(input, Pattern.LITERAL)を使ってください。
  • 曖昧な入れ子の繰り返しや問題を起こす後方参照など、複雑なバックトラッキングを招く構造を避けてください。用途に合う場合は\d{1,6}のように繰り返し回数を制限します。
  • パターンと対象文字列の長さを制限してください。Javaのスレッド割り込みは終了を保証しないため、強制的な制限が必要なら対応するエンジンや別プロセスを使います。
  • 互換性を確認し、RE2Jなどバックトラッキングを行わないエンジンを検討してください。
  • 確認済みパターンを事前にコンパイルして再利用し、不要な動的コンパイルを避けてください。
  • 単純な文字列検索や置換には、String.indexOfやString.replaceなど正規表現を使わないAPIを選んでください。

例

変更前

java
import org.springframework.web.bind.annotation.*;

@RestController
public class MaskController {
    // 利用者が指定した正規表現はReDoSを起こし得る
    @PostMapping("/mask")
    public String mask(@RequestParam String rx, @RequestBody String text) {
        // 例: rx = "(a+)+$", text = "aaaaaaaaaaaaaaaaaaaa!" で高コストのバックトラッキング
        return text.replaceAll(rx, "***");
    }
}

変更後

java
import org.springframework.web.bind.annotation.*;
import java.util.Set;
import java.util.regex.Pattern;

@RestController
public class SafeMaskController {
    // 1) 確認済みパターンだけを選択
    private static final Set<String> ALLOW_PATTERNS = Set.of("[0-9]{1,6}", "[A-Za-z]+\"?");

    @PostMapping("/mask")
    public String mask(@RequestParam(required = false) String rx,
                       @RequestParam(defaultValue = "false") boolean literal,
                       @RequestBody String text) {
        if (text.length() > 10000 || (rx != null && rx.length() > 128)) {
            throw new IllegalArgumentException("input too long");
        }
        // 用途に応じた二つの方法
        if (literal) {
            // 2) Pattern.quoteで入力を文字列そのものとして扱う
            String keyword = rx == null ? "" : rx;
            if (keyword.isEmpty()) {
                throw new IllegalArgumentException("empty keyword");
            }
            Pattern p = Pattern.compile(Pattern.quote(keyword));
            return p.matcher(text).replaceAll("***");
        } else {
            // 許可済みのパターンだけを使う
            if (rx == null || !ALLOW_PATTERNS.contains(rx)) {
                throw new IllegalArgumentException("invalid pattern");
            }
            // 確認済みのパターンだけをコンパイル
            Pattern p = Pattern.compile(rx);
            return p.matcher(text).replaceAll("***");
        }
    }
}

変更前のreplaceAll()はリクエスト値を正規表現として解釈します。変更後は文字列そのものの検索と確認済みパターンの選択を分け、入力長を制限します。上限は業務要件に合わせて調整してください。Pattern.quote()はパターン構文の解釈を防ぎますが、入力や出力の大きさは制限しません。また、非効率なパターンを事前コンパイルしても安全にはなりません。

参考資料