XPathインジェクション

PythonのXPath・ElementPathインジェクション

説明

XPathインジェクションは、外部から操作できる文字列をデータとして渡さず、パス式の構文に組み込むことで発生します。lxml は完全なXPath 1.0を扱うため、引用符、演算子、条件式を挿入されると、選択するノードが変わったり、本来の条件が無効になったりするおそれがあります。

Python標準ライブラリの xml.etree.ElementTree が扱うのは、完全なXPathではなく限定的なElementPath構文です。使える操作は少なくても、find()、findall()、findtext()、iterfind() の match を攻撃者が操作できれば、意図しないノードを選択される可能性があります。

想定される影響

  • データの露出: 返されるノードの範囲を広げられると、他のユーザーやテナントの情報が漏れるおそれがあります。
  • 認証・認可の回避: 検索結果をログインやアクセス制御に使っている場合、改変された条件でその判断を回避される可能性があります。
  • 意図しない変更: 選択したノードを後から変更・削除するアプリケーションでは、誤った対象に変更が加わるおそれがあります。
  • リソースの枯渇: 大きな文書に対して任意の複雑なXPathを許可すると、評価に多くのCPUを消費する場合があります。動的なElementPathが必ずサービス拒否につながるわけではありません。

対処方法

  • XPathやElementPathの式を定数にし、文字列連結、f-string、%、str.format() で構文を組み立てないでください。
  • lxml では、信頼できない値をXPath変数としてバインドしてください。例: tree.xpath("//user[@id=$uid]", uid=value)。変数で置き換えられるのは値であり、要素名、演算子、式の構造ではありません。
  • ユーザーがフィールド、パス、操作を選ぶ必要がある場合は、入力をサーバーが管理する完全な定数式に対応付けてください。トークンを直接使う場合は、正規化したうえで文字列全体を許可リストで検証してください。
  • ElementTreeにはXPath変数をバインドするAPIがありません。match を定数にしてPythonで値を比較するか、サーバー側の対応表から定数式を選んでください。
  • 数値の位置や値はPythonの組み込み数値型に変換し、業務上の範囲制約も確認してください。
  • 関数名や汎用エスケープだけを安全性の根拠にしないでください。XPath文字列の処理は文脈に依存し、引用符の置換だけでは不十分になりがちです。変数のバインドか定数の対応表を優先してください。
  • ユーザー作成の任意XPathを評価する必要がある場合は、通常の検索から分離し、専用の認可、入力サイズ、処理時間、リソースの制限を設けてください。

例

lxml

変更前

python
from flask import Flask, request
from lxml import etree

app = Flask(__name__)

XML_DATA = b"""
<accounts>
  <account tenant="blue" id="1001"><email>blue@example.com</email></account>
  <account tenant="red" id="2001"><email>red@example.com</email></account>
</accounts>
"""

@app.get("/account")
def account():
    account_id = request.args.get("id", "")
    root = etree.fromstring(XML_DATA)

    # 危険: account_idがXPathの構文に直接組み込まれます。
    expression = f"//account[@tenant='blue' and @id='{account_id}']"
    matches = root.xpath(expression)
    if not matches:
        return ("not found", 404)
    return etree.tostring(matches[0], encoding="unicode")

変更後

python
from flask import Flask, request
from lxml import etree

app = Flask(__name__)

XML_DATA = b"""
<accounts>
  <account tenant="blue" id="1001"><email>blue@example.com</email></account>
  <account tenant="red" id="2001"><email>red@example.com</email></account>
</accounts>
"""

@app.get("/account")
def account():
    account_id = request.args.get("id", "")
    root = etree.fromstring(XML_DATA)

    # 式は定数にし、信頼できない値は変数として渡します。
    expression = "//account[@tenant=$tenant and @id=$account_id]"
    matches = root.xpath(
        expression,
        tenant="blue",
        account_id=account_id,
    )
    if not matches:
        return ("not found", 404)
    return etree.tostring(matches[0], encoding="unicode")

変更前は account_id を引用符の内側に埋め込むため、入力によって条件の構造が変わる可能性があります。変更後は同じ値をXPath変数として渡すので、引用符や演算子が含まれていても式の構文として解釈されません。形式や長さの検証は業務上の制約として追加できますが、変数のバインドを置き換えるものではありません。

ElementTree

ユーザーが検索モードを選択する場合は、式全体を対応付けます。

python
import xml.etree.ElementTree as ET
from flask import request

def accounts_by_view():
    account_paths = {
        "active": ".//account[@active='true']",
        "disabled": ".//account[@active='false']",
    }

    selected_path = account_paths.get(request.args.get("view"))
    if selected_path is None:
        return ("invalid view", 400)

    root = ET.fromstring(XML_DATA)
    return root.findall(selected_path)

適用時の注意点

許可リストと式の対応表はサーバー側で管理してください。XMLパーサーの外部エンティティ設定と入力サイズの制限も、XPath式の保護とは別に確認してください。

参考資料