Claude Media
Claude Codeでクエリインジェクションを防ぐ — Elasticsearchのquery_string対策

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. 1

    洗い出す

    query_stringが使われる箇所と、入力の出どころを表にします。

  2. 2

    分類する

    match、simple_query_string、query_stringの残置に振り分けます。

  3. 3

    テストを先に書く

    攻撃的な入力を並べ、期待する構造を固定します。

  4. 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を検索することです。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. elastic.co/guide/en/elasticsearch/reference/current/query-dsl-query-s…
  2. elastic.co/guide/en/elasticsearch/reference/current/query-dsl-simple-…
  3. code.claude.com/docs/en/best-practices
この記事を共有:XはてブLinkedIn