正規表現の落とし穴 7 選:catastrophic backtracking から Unicode の罠まで
正規表現は強力ですが、誤った書き方をするとパフォーマンスが致命的に落ちたり、想定外のマッチを返したりします。本記事では本番環境で実際にトラブルを起こしやすい 7 つのパターンを、ケーススタディと修正例の両方で整理します。
1. Catastrophic backtracking(指数的バックトラック)
最も悪名高い罠。一見シンプルな正規表現が、特定の入力に対して指数的な時間を消費します。
^(a+)+$ 入力 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab" (a が 31 個 + b)に対して、上の正規表現は 2³¹ ≈ 21 億回の試行を行います。マッチ自体は失敗しますが、CPU を 100% 食ったまま数秒〜数分かかる。
原因
(a+)+ のような 重複した量指定子は、a のグループ化の組み合わせがすべて試されます。aaa を (aaa), (aa)(a), (a)(aa), (a)(a)(a) の 4 通りに分けられる、という冗長性が爆発の元。
対策
(a+)+をa+に書き直す(同等で爆発しない)- 一般化すると、重複した量指定子を排除する:
(\w+\s*)+→\w+(\s+\w+)*のように - atomic group
(?>...)や possessive quantifier*+++を使う(対応エンジン限定)
^(?>a+)+$ # atomic group。バックトラックを禁止 2. ^ $ の multiline モード違い
/^foo/ # 単一行モード:文字列の先頭のみマッチ
/^foo/m # 複数行モード:各行の先頭でマッチ ログをパースしていて、/^ERROR/ で「エラー行を全部抽出」したつもりが、m フラグを忘れて最初の行しか取れない事故。逆に、JSON のように改行を含む 1 つの値を扱うときに m を付けて、想定外の位置でマッチが切れる事故もあります。
対策
- 「行ごと」の処理なら明示的に
\nで split してから個別マッチをかける mフラグを使うときは、^$が改行ごとに当たることを意識- 文字列全体の先頭・末尾に確実に当てたいなら
\A\zを使う(PCRE / Ruby など対応エンジン)
3. \b の Unicode 不対応
JavaScript では伝統的に \b(単語境界)は ASCII 文字でのみ動作しました。
'日本語hello'.match(/hello/); // ✓ マッチ
'日本語hello'.match(/日本語/); // ✗ マッチしない(旧挙動) ECMAScript 2018 で u フラグ + v フラグの対応が進みましたが、Unicode property 対応の \b は未だに普通の \b ではない:
// 'v' flag + Unicode property を使う
const re = /(?<=P{L}|^)日本語(?=P{L}|$)/u; 対策
- 日本語を含むテキストで「単語の境界」を扱うなら
\bではなく(?<=\P{L})(?=\P{L})を明示 - そもそも日本語に「単語の境界」は語境的に難しい。形態素解析を使うべきか検討
- Python 3 の
re.UNICODEフラグや、Java の(?U)フラグなど、エンジンごとに名前が違うので確認
4. Lookbehind の対応差
(?<=USDs)d+ # "USD " の後ろの数字 Lookbehind(後読み)はエンジンによって長さ制限があることに注意:
| エンジン | 可変長 lookbehind |
|---|---|
| JavaScript | ✓(ES2018 から) |
Python re | ✗(固定長のみ) |
Python regex | ✓ |
| PCRE | ✗(固定長のみ) |
Go regexp | lookbehind 自体なし |
| Java | 固定長のみ |
「テストでは Python regex モジュールで通ったのに、本番の Python 標準 re で例外」「Node でテストしたのが Go の正規表現エンジンで通らなかった」は典型的な事故。
対策
- 可変長 lookbehind を使う前に、デプロイ環境のエンジンを確認
- どうしても固定長で書けないなら、lookbehind を使わない方向に書き換える(マッチ後に部分文字列を取り出す)
5. 文字クラス内の - と ] の扱い
[a-z-] # a-z と "-"
[a-z] # 'a', '-', 'z'
[a-] # 'a' と '-'(- が末尾なら literal)
[]z] # ✗ 多くのエンジンで構文エラー
[]]z] # ']' と 'z' 文字クラスに - を入れるなら最初か最後に置く。] を入れるならエスケープする。これを誤ると:
- 意図しない範囲展開(
[A-Z\-0-9]のつもりが[A-Z\\-0-9]で-がメタ扱いになる) - 構文エラー
対策
-は[abc-]のように末尾に置く、または\-でエスケープ]は\]でエスケープ- 文字クラスにメタ文字を入れたい場合、文字クラスでは多くのメタ文字が literal になる(
[.+*?]は.+*?の 4 文字に literal マッチ)が、-]\^(先頭の場合)は注意が必要
6. エンジン依存:PCRE / POSIX / RE2 の差
「同じ正規表現がエンジンを変えると動かない」事象。
- PCRE 系(Perl、Python
re、Java、JavaScript の多く):lookbehind、lookahead、後方参照、再帰、キャプチャグループに名前を付ける構文 - POSIX:互換性は高いが拡張機能なし
- RE2(Go の標準、Cloudflare 等):バックトラックを使わないので catastrophic backtracking が起きないが、lookbehind と後方参照に未対応
「ローカルで Python の re.match が通ったから OK」と思って Go のサーバに移植したら通らない、というのが頻発します。
対策
- デプロイ先のエンジンを最初に確認
- バックトラックを使わない RE2 系で動かすことが決まっているなら、最初から RE2 互換の subset で書く
- Cloudflare Workers の WAF ルールなど、特殊なエンジンを使うサービスは仕様を確認
7. ReDoS(Regular Expression Denial of Service)
ユーザー入力をそのまま正規表現で処理する場合、入力を細工することで catastrophic backtracking を意図的に起こせる攻撃。
例:メール検証によく使われる ^([\w\.\-]+)@([\w\-]+)((\.(\w){2,3})+)$ のような pattern に、a@a.aaaaaaaaaaaaaaaaaaaaaaaaaaaaa! のような入力を渡すと、エンジンによっては数秒固まります。
対策
- ユーザー入力を扱う正規表現は、必ずタイムアウトを設定できる環境で動かす(Node.js の
vmモジュール、Python のregexライブラリのtimeoutパラメータなど) - そもそも RE2 系(バックトラックなし)のエンジンを使う
- ReDoS 検出ツール(ReScue、
safe-regex)でパターンを事前検査 - メール検証なら正規表現でなく
parse()系のライブラリ(email-validator など)を使う
チェックリスト
実装時に踏みやすいポイント:
- 量指定子の入れ子(
(a+)+パターン)を避ける - multiline モードの
^$の挙動を確認する \bを非 ASCII テキストで使うときは\P{L}系の代替を検討- lookbehind を使う前にエンジンの可変長対応を確認
- 文字クラスの
-]の位置に注意 - デプロイ先のエンジン(PCRE / RE2 / POSIX)を最初に確認
- ユーザー入力を渡す正規表現はタイムアウト設定 or RE2 系エンジンで実行
正規表現テスターで、書いたパターンの挙動を確認できます。
まとめ
正規表現の落とし穴は「書き手の意図と、エンジンが実行する戦略が一致していない」ことから発生します。catastrophic backtracking は最も極端な例ですが、エンジン差や Unicode 対応の差も同じ系統の問題。書く前にデプロイ先のエンジンと、入力データの言語特性(ASCII なのか Unicode なのか)を確認することで、ほとんどの罠は事前に避けられます。