Prompt
ReDoSはユーザーの入力する検索クエリを正規表現の中に使用する場合にのみ起きる?
それとも、検索対象の文字列がユーザーの入力である場合にも起きる?
結論から言うと、「その両方の組み合わせ」で起きるけれど、特に恐ろしいのは「ユーザーが正規表現自体を自由に操れる場合」 だよ。
情報工学的な視点で整理すると、ReDoS(正規表現のサービス拒否攻撃)の本質は、正規表現エンジンが「マッチしないことを確認するために、天文学的な数の組み合わせを試行してしまう(指数関数的なバックトラッキング)」という現象にあるんだ。
キミが懸念している二つのパターンを解剖してみよう。
1. ユーザーが「正規表現(クエリ)」を入力する場合
これが最も一般的で、かつ「一直線の穴」になりやすいパターンだ。
-
シナリオ: 検索窓にユーザーが
(a+)+$のような、意図的に計算量を爆発させる正規表現を入力し、それをサーバー側でそのままREGEXPなどに渡してしまう。 -
なぜ起きるか: 正規表現エンジンが「この文字列はマッチするかな?」と探す際、ネストされた繰り返し構造があると、マッチしないことが確定するまでに 通りの探索を始めてしまい、CPUを100%占有する。
2. ユーザーが「検索対象(データ)」を入力する場合
こちらは、「サーバー側に、あらかじめ『脆弱な正規表現』が書かれている」 場合に起きる。
-
シナリオ: サーバーに
^([a-zA-Z0-9])+(.com)$というメールアドレスチェック用の正規表現があるとしよう。 -
攻撃手法: 攻撃者は正規表現を入力するのではなく、「非常に長い、かつ最後に一文字だけ違うゴミデータ」 (例:
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!@)を送りつける。 -
なぜ起きるか: 正規表現の書き方が悪いと、エンジンが「最後の
@でマッチしない」と気づくまでに、手前のaの塊の区切り方を全パターン試そうとして爆発する。
どちらがより危険か?
キミが追求する「一意な定義」の観点から見ると、リスクの性質が違うんだ。
| パターン | 脆弱性の場所 | 危険度 | 防御の難易度 |
|---|---|---|---|
| クエリがユーザー入力 | 入力値そのもの | 極高 | 困難(正規表現の安全性を動的に判定するのは難しいため、そもそも自由な正規表現を許可しないのが正解)。 |
| データがユーザー入力 | サーバー側のコード | 中〜高 | 管理可能(静的解析ツールや、より安全な正規表現の書き方で事前に穴を塞げる)。 |
見落とされがちな視点:エンジンによる「救済」
実は、全ての環境で ReDoS が起きるわけではないんだ。
-
指数関数的なエンジン (NFA): JS (V8), Python, PHP, Ruby, Java など。多くの一般的な言語の標準エンジンは、バックトラッキングを行うため ReDoS の影響をモロに受ける。
-
線形時間で動くエンジン (DFA/RE2): Go の標準
regexpや、Google が開発したRE2ライブラリ(Rust のregexクレートもこれに近い思想)は、どんなに複雑な正規表現やデータが来ても計算量が爆発しないように設計されている。
君が Rust を使っているなら、標準の regex クレートを使っている限り、ReDoS という「一直線の穴」は構造的にほぼ起きないようになっているよ。これは Rust の「安全性への執着」の素晴らしい恩恵だね。
解決策:スイスチーズの穴を塞ぐには
-
ユーザーに正規表現を直接入力させない: 生文字列の検索に限定するか、どうしても必要な場合は
RE2などの安全なエンジンを使う。 -
タイムアウトを設定する: 正規表現の実行に「0.1秒以上かかったら強制終了する」という制約を設ける。
-
静的解析: サーバー側の正規表現を
Safe-Regexなどのツールでスキャンし、脆弱なパターン(ネストされた繰り返しなど)を排除しておく。
結論
ReDoS は 「脆弱な正規表現」と「それを追い詰めるデータ」がガッチャンコした瞬間 に起きる。
「ユーザーが何を入力するか」をコントロールするのは難しいけれど、「サーバー側でどのエンジンを使い、どう制約をかけるか」は、君の手で 「一意に」 制御できる部分だ。
次は、「Rust の regex クレートがなぜ ReDoS に強いのか、その内部アルゴリズム(有限オートマトン)の仕組み」 について、もう少し専門的に深掘りしてみる?
Would you like me to code a small benchmark in Node.js (vulnerable) and Rust (safe) to show how they react to a classic ReDoS pattern?
Prompt
const q = /* ユーザーの入力 */ const safe = q.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const regex = new RegExp(safe, "i"); // これセーフ?
結論から言うと、そのコードは 「検索クエリとして使う分には極めてセーフ」 だけど、「それが ReDoS 対策のすべてかと言われると、文脈による」 というのが情報工学的な回答だよ。
キミが書いたコードは、一般に 「Regex Escape(正規表現のエスケープ)」 と呼ばれる処理だね。ユーザーが入力した「単なる文字列」の中に含まれる * や + などの メタ文字 を、ただの記号として扱うように無効化するものだ。
このコードの「安全性」を、キミが懸念している ReDoS の観点から解剖してみよう。
1. なぜ「検索クエリ」としてはセーフなのか?
ReDoS が起きる最大の原因は、正規表現内の 「量化子(+ や *)のネストや重複」 だ。
キミのコードで safe を生成すると、ユーザーが (a+)+ と入力しても、それは \(a\+\)\+ という「ただの文字列」に変換される。
-
結果: 正規表現エンジンは「複雑な分岐」を行わず、単なる文字の羅列として一意にスキャンするだけになる。
-
結論: このエスケープを噛ませている限り、ユーザーが入力したクエリによって ReDoS が引き起こされるリスクは、ほぼゼロ になるよ。
2. 依然として残る「穴」:検索対象側の問題
ただし、スイスチーズモデルの観点から見ると、これだけで「100% 安全な検索機能」とは言えない。
検索対象が巨大な場合
たとえ正規表現自体が単純でも、検索対象の文字列が 数メガバイト〜数十メガバイト と巨大な場合、new RegExp(safe, "i") による検索は依然としてメインスレッドを占有する可能性がある。
- 特に
iフラグ(大文字小文字を区別しない)は、内部的に文字の変換処理が走るため、単純な検索よりも少しだけ負荷が高いんだ。
実行時エラーの可能性
非常に稀だけど、ユーザーがめちゃくちゃに長い文字列を入力して、エスケープ後の文字列が 正規表現の最大長制限 を超えた場合、new RegExp のコンストラクト自体が例外を投げてシステムが止まる可能性がある。
- 実務では、
qの長さ(q.length)を事前にバリデーションしておくのが「一意な正しさ」への道だね。
3. 「もっと一意に正しい」代替案
もしキミが「メタ文字を無視して、単なる部分一致検索がしたい」だけなら、正規表現を使わない という選択肢が最強の防御だよ。
TypeScript
// これが最も安全で、最も速い(生文字列検索)
const isMatch = target.toLowerCase().includes(q.toLowerCase());
-
メリット: 正規表現エンジンを起動すらさせない。ReDoS のリスクを根源から断ち切る。
-
デメリット: 「単語の境界でマッチさせたい」といった、正規表現ならではの高度な制御はできない。
見落とされがちな視点:replace のエスケープ漏れ
キミが書いた正規表現 / [.*+?^${}()|[\]\\] /g は、一般的に推奨されているパターンを網羅しているけれど、実行環境(ブラウザや Node.js のバージョン)によっては、新しいメタ文字(例えば v フラグ関連の記号など)が登場する可能性がある。
「属人性(自作のエスケープ関数)」を排除したいなら、最近のモダンな環境では RegExp.escape() という仕様が提案されているよ(まだ stage 段階だけど、一部のポリフィルなどで利用可能)。
結論
キミのコードは 「ユーザーに正規表現のパワーを与えず、安全に検索させる」 という目的においては、非常に教科書的で正しい実装だ。自信を持っていいよ!
ただ、キミが「極限の安全性」を求めるなら、以下の 2 点を付け加えるとスイスチーズの穴がさらに塞がる。
-
qの文字数制限(例: 100文字以内)を設ける。 -
new RegExpをtry-catchで囲む(万が一のコンパイルエラー対策)。
次は、「このエスケープ処理を Rust 側で実装する場合、regex クレートの regex::escape 関数が内部でどのように効率的に処理しているのか」 を比較してみる?
Would you like me to code a performance comparison between RegExp.test() with an escaped string and String.includes() in Node.js?