Claude Codeでクエリインジェクションを防ぐ — Elasticsearchのquery_string対策
検索ボックスの入力がElasticsearchのquery_stringに渡る箇所をClaude Codeに洗い出させ、simple_query_stringへの置換とエスケープをテストで固定する手順を、公式の構文仕様に沿って示します。
Elasticsearchのquery_stringクエリにユーザーの入力をそのまま渡すと、利用者は検索語ではなく「クエリ構文」を書けてしまいます。status:secretのようなフィールド指定、先頭の*、正規表現の/.*n/が、検索ボックスから通ります。このページでは、Claude Codeに該当箇所を洗い出させ、simple_query_stringへの置換とエスケープをテストで固定する手順を扱います。
query_stringに入力を渡すと何が起きるか
query_stringは、文字列を演算子で分割して解析する厳格な構文のクエリです。ANDやNOT、ワイルドカード、複数フィールド検索といった高機能が使えます。一方で構文が不正だとエラーを返します。このエラーを理由に、リファレンスはquery_stringを検索ボックスに使うことを推奨していません。
つまり問題は2種類あります。ひとつは、不正な構文でクエリが失敗すること。もうひとつは、正しい構文として解釈された入力が、開発者の意図しない検索になることです。後者がインジェクションにあたります。
| 入力の例 | 構文上の意味 | 起きること |
|---|---|---|
status:(active OR pending) | 構文上の意味フィールドを指定したグループ | 起きること検索者が対象フィールドを選べる |
*ing | 構文上の意味先頭のワイルドカード | 起きること全タームの走査が必要になり重い |
/.*n/ | 構文上の意味正規表現 | 起きること全タームの走査を強いる |
price:>100 | 構文上の意味範囲クエリ | 起きること<と>はエスケープ自体できない |
(1+1)=2 | 構文上の意味予約文字を含む | 起きることエスケープしないと構文エラー |
title:(a OR b)^10 | 構文上の意味ブースト指定 | 起きることスコアが検索者の入力で動く |
先頭ワイルドカードはallow_leading_wildcardをfalseにすると止められます。ただしこのパラメータは正規表現を制御しません。/.*n/はこの設定をすり抜け、インデックスの全タームを訪問させます。
フィールドを絞らない場合の挙動にも注意が要ります。simple_query_stringのfieldsを省略すると、既定はindex.query.default_fieldで、その既定値は*です。検索対象になりうる全フィールドが対象になります。内部用のフィールドを持つインデックスでは、見せたくない値に検索がヒットする経路になります。
Claudeに入力経路を洗い出させる
最初の仕事は、検索ボックスの入力がquery_stringに届く経路の特定です。コードの検索はClaude Codeが得意な作業です。ベストプラクティスのページも、Claudeは作業の最初にコードを読み、ファイル配置や慣習を自分で見つけると説明しています。ただし「どの入力がユーザー由来か」は、プロンプトで観点を渡した方が精度が上がります。
Elasticsearchの検索コードを点検して。
query_string / queryStringQuery / URLの q= を使う箇所を全て列挙し、
各箇所について次を表にして。
- 入力の出どころ(ユーザー入力か、サーバー内部の固定値か)
- fields の指定の有無
- エスケープ処理の有無
まだコードは直さないで。「まだ直さない」と書くのは、洗い出しと修正を分けるためです。一覧を人間が見て、本当にユーザー入力が届く箇所だけを修正対象に決めます。内部の管理バッチが固定のクエリ文字列を使っているだけなら、置換の必要はありません。
洗い出しの結果は、次の3つに分類すると判断が速くなります。
- ユーザー入力が届き、構文を使わせる必要がない箇所は
matchクエリに置換する - ユーザー入力が届き、
ANDや-などの簡単な構文は使わせたい箇所はsimple_query_stringに置換する - 内部入力だけで
query_stringの全機能が要る箇所は、そのまま残してエスケープとテストを足す
リファレンスの勧めも同じ順序です。構文が要らないならmatch、構文が要るならsimple_query_string、という選び方です。
simple_query_stringへの置換と演算子の絞り込み
simple_query_stringは、制限された構文を持ちつつ、不正な構文でもエラーを返さず、不正な部分を無視します。検索ボックスに向く性質です。ただし演算子が使えなくなるわけではありません。公式の構文表では、次の演算子が有効です。
| 演算子 | 意味 | 対応するflags |
|---|---|---|
+ | 意味AND | 対応するflagsAND |
| | 意味OR | 対応するflagsOR |
- | 意味単一トークンの否定 | 対応するflagsNOT |
"..." | 意味フレーズ検索 | 対応するflagsPHRASE |
語末の* | 意味前方一致 | 対応するflagsPREFIX |
( ) | 意味優先順位 | 対応するflagsPRECEDENCE |
語の後の~N | 意味あいまい検索 | 対応するflagsFUZZY |
フレーズの後の~N | 意味語順の許容幅 | 対応するflagsSLOP / NEAR |
flagsパラメータで、有効にする演算子を限定できます。OR|AND|PREFIXと指定すると、それ以外の演算子は無効になります。NONEはすべての演算子を無効にします。ここでは前方一致とあいまい検索を外し、重い検索を呼び出せないようにします。
def build_search_query(user_input: str) -> dict:
return {
"simple_query_string": {
"query": user_input,
"fields": ["title^2", "body"],
"default_operator": "and",
"flags": "AND|OR|PHRASE|ESCAPE|WHITESPACE",
}
}fieldsを明示しているのは、先ほどの全フィールド検索を避けるためです。ESCAPEを含めているのは、\で演算子をリテラルとして扱えるようにするためです。WHITESPACEは空白を区切りとして扱うフラグです。
query_stringを残す箇所のエスケープ
内部用途でquery_stringが必要な箇所は、予約文字をエスケープします。公式が挙げる予約文字は+ - = && || > < ! ( ) { } [ ] ^ " ~ * ? : \ /です。バックスラッシュを前置すれば、演算子ではなく文字そのものとして扱われます。例えば(1+1)=2は\(1\+1\)\=2と書きます。
例外が<と>です。この2文字はエスケープできません。範囲クエリを作らせないためには、クエリ文字列から取り除くしかありません。
import re
# query_string の予約文字。< と > はエスケープできないので取り除く
_RESERVED = re.compile(r'([+\-=&|!(){}\[\]^"~*?:\\/])')
def escape_query_string(text: str) -> str:
text = text.replace("<", " ").replace(">", " ")
return _RESERVED.sub(r"\\\1", text)JSONでリクエストボディを手書きする場合は、バックスラッシュを2つ重ねる必要があります。バックスラッシュがJSON文字列の予約文字でもあるためです。Pythonの辞書をクライアントに渡す実装なら、この二重化はJSONシリアライザが担います。
エスケープを入れても、query_stringのリスクが消えるわけではない点には注意してください。fieldsを絞る、allow_leading_wildcardをfalseにするといった周辺の設定は残ります。置換できる箇所は置換するのが先で、エスケープは置換できない箇所の次善策です。
テストで挙動を固定する
置換が済んだら、攻撃的な入力がquery_stringに到達しないことをテストで固定します。ベストプラクティスは、Claudeに合否が返る確認手段を渡すことを勧めています。テストファイルはその典型で、Claudeは変更を加えて実行し、失敗の出力を読んで直し、再実行します。
import pytest
from search_query import build_search_query, escape_query_string
PAYLOADS = [
"status:secret",
"*",
"/.*n/",
"title:(a OR b)^10",
"price:>100",
"app~5",
"(1+1)=2",
]
@pytest.mark.parametrize("payload", PAYLOADS)
def test_user_input_never_reaches_query_string(payload):
query = build_search_query(payload)
assert list(query) == ["simple_query_string"]
flags = query["simple_query_string"]["flags"].split("|")
assert "PREFIX" not in flags and "FUZZY" not in flags
assert "fields" in query["simple_query_string"]
def test_escape_reserved_characters():
assert escape_query_string("(1+1)=2") == r"\(1\+1\)\=2"
assert escape_query_string("a:b") == r"a\:b"
assert escape_query_string("/.*n/") == r"\/.\*n\/"
def test_angle_brackets_are_removed():
assert "<" not in escape_query_string("price:<100")
assert ">" not in escape_query_string("price:>100")このテストはクエリを組み立てる関数の戻り値を検査するだけで、Elasticsearchを起動する必要がありません。CIでも速く回ります。Claudeに渡すプロンプトは次のとおりです。
洗い出した3箇所を simple_query_string に置換して。
tests/test_search_query.py を先に書き、pytest が通るまで修正を続けて。
終わったら実行したコマンドとその出力を見せて。最後の一文は「証拠を求める」使い方です。「テストは通りました」だけの報告より、実行したコマンドと出力を読む方が速く確実です。
置換作業の流れ
- 1
洗い出す
query_stringが使われる箇所と、入力の出どころを表にします。 - 2
分類する
match、simple_query_string、query_stringの残置に振り分けます。 - 3
テストを先に書く
攻撃的な入力を並べ、期待する構造を固定します。
- 4
置換して緑にする
Claudeにテストが通るまで直させ、実行ログを確認します。
規約をCLAUDE.mdに残す
同じ指摘を毎回繰り返さないために、規約をCLAUDE.mdに1行で残します。公式は、CLAUDE.mdにはテストの実行方法のように、Claudeがコードを読んでも分からないことだけを書くよう勧めています。この規約は、読んでも分からないチームの決めごとにあたります。
# Search
- ユーザー入力を query_string に渡さない。simple_query_string か match を使う
- 検索のテストは tests/test_search_query.py。実行: python -m pytest tests/test_search_query.py規約を守らせたい度合いが強い場合は、Stopフックでテストを自動実行する方法もあります。StopフックはClaudeがターンを終えようとするたびにスクリプトを走らせ、終了コード2で作業に戻します。テストが通るまでターンが終わらない構成になります。
見落としやすい落とし穴
落とし穴
allow_leading_wildcardを切っても正規表現が残る
allow_leading_wildcardは*と?が語頭に来ることを止めますが、/.*n/のような正規表現は制御しません。query_stringを残す箇所では、入力に/が含まれる場合の扱いをテストに含めてください。正規表現の変換量はmax_determinized_states(既定10000)で制限できますが、これは対症療法です。
落とし穴
lenientを真にして構文エラーを隠さない
lenientは、数値フィールドにテキストを入れた場合のような形式上のエラーを無視するパラメータです。既定はfalseです。エラーを黙らせるために真にすると、本来検出すべき不正入力の兆候が見えなくなります。
落とし穴
fieldsのワイルドカードと句数の上限
fieldsに*を使う、あるいは省略すると、マッピングのフィールド数が多いインデックスでは検索が高コストになります。同時に検索できるフィールド数にはindices.query.bool.max_clause_countの上限があり、simple_query_stringの公式ページでは既定値が1024と書かれています。検索対象のフィールドは、一覧で明示するのが安全です。
落とし穴
高コストなクエリを禁止した環境での例外
query_stringは内部で前方一致クエリに変換されることがあります。クラスターで前方一致クエリを無効にしている場合、そのquery_stringは実行されず例外になります。置換前後で挙動を比べるときは、この設定の有無も確認項目に加えてください。
simple_query_stringへの置換で挙動が変わる点も、あらかじめ把握しておく価値があります。構文エラーが出なくなる代わりに、不正な部分は無視されます。利用者が入力した演算子の一部が検索に効かなくなっても、画面にエラーは出ません。UI側の検索ヘルプに、使える演算子を書いておくと問い合わせが減ります。
Elasticsearchを自然言語で操作する経路が別にある場合は、Elasticsearch MCPサーバーの使い方で、searchツールがQuery DSLをそのまま実行する仕様を確認できます。MCP経由の検索は、アプリのコードとは別の入口です。点検の対象に加えてください。
コード全体の脆弱性を差分から探すなら、security-reviewコマンドの使い方が併用できます。入力の扱いの観点をスキルとして渡す書き方は、WordPressのnonceと権限の点検に実例があります。遅いクエリが出たときの調べ方は、EXPLAIN ANALYZEの読み方にあります。
まとめ
検索ボックスの入力は、構文を使わせないのが原則です。構文が要らなければmatch、簡単な演算子だけならflagsで絞ったsimple_query_string、内部用途のquery_stringだけにエスケープを足します。作業の順序は、洗い出し、分類、テスト、置換の4段です。テストは関数の戻り値を見るだけで済むため、Elasticsearchなしで回せます。最初の一歩は、リポジトリでquery_stringを検索することです。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。