本文へスキップ
CodeAuditAgent
すべての記事

Node.js・Python・GoにおけるSQLインジェクションの防止

pg、Prisma、psycopg、SQLAlchemy、database/sqlの脆弱例と修正例。動的なORDER BYやINリストの安全な書き方も。CWE-89。

· 7分で読めます · Lina Source LLC

SQLインジェクション(CWE-89)はWeb上で最も古いバグの1つでありながら、今なお最も被害の大きいものの1つです。原因は昔から変わりません。ユーザー入力がクエリの文字列に貼り付けられるため、データベースがデータとコードを区別できなくなるのです。対策も変わっていません。クエリと値を別々に送り、バインドはドライバーに任せることです。

変わったのは、このバグが潜む場所です。多くのチームは日常的なクエリにORMを使っているため、インジェクションは今や抜け道に現れます。レポート用に書いた生クエリ、動的な並べ替えを持つ検索エンドポイント、マイグレーションスクリプト、管理ツールなどです。本稿では3つのエコシステムで脆弱な版と修正版を見ていき、そのあとパラメータだけでは解決しない2つのケースを扱います。

影響が1つのテーブルにとどまることはまれです。インジェクション可能なクエリは通常アプリケーションのデータベース権限をすべて持って実行されるため、パスワードハッシュやAPIトークンを含む全顧客のデータを読み取ったり、行を書き換えたり、データベースによってはファイルシステムや他のサーバーに到達したりできます。ブラインド手法を使えば、クエリの結果がユーザーに表示されない場合でも1ビットずつデータを抽出できるため、trueかfalseしか返さないエンドポイントでも悪用は可能です。

パラメータが有効でエスケープが無効な理由

パラメータ化クエリでは、ドライバーがプレースホルダー付きのSQL文を送り、値は別のデータとして送られます。データベースは値を見る前に文を解析するため、入力に含まれる引用符やセミコロンは単なる文字列中の文字にすぎません。一方、手動のエスケープは値をSQL文に貼り付けても安全になるようにする試みであり、文字エンコーディング、数値のコンテキスト、識別子、そして思いつかなかったあらゆる境界ケースで破綻します。エスケープではなく、バインドしてください。

Node.js:pgとPrisma

node-postgresでは、テンプレートリテラルをqueryに渡すのが脆弱なパターンです。修正は$1のプレースホルダーとvalues配列です。Prismaは既定で安全ですが、生クエリ用のAPIが2つあり、補間しても安全なのは一方だけです。

import { Pool } from "pg";
import { PrismaClient } from "@prisma/client";

const pool = new Pool();
const prisma = new PrismaClient();

// 脆弱:入力がSQL文の一部になってしまう
await pool.query(`SELECT id, email FROM users WHERE email = '${email}'`);

// 修正:値はパラメータとしてバインドされる
await pool.query("SELECT id, email FROM users WHERE email = $1", [email]);

// Prisma:$queryRawはタグ付きテンプレートで、補間はパラメータになる
await prisma.$queryRaw`SELECT id, email FROM users WHERE email = ${email}`;

// Prisma:文字列連結を渡す$queryRawUnsafeはインジェクション可能
await prisma.$queryRawUnsafe(
  "SELECT id, email FROM users WHERE email = '" + email + "'"
);

// どうしても$queryRawUnsafeを使うなら、値は引数として渡す
await prisma.$queryRawUnsafe(
  "SELECT id, email FROM users WHERE email = $1",
  email
);

Prismaの見落としやすい罠があります。先にクエリ文字列を組み立てて$queryRawに渡すと、保護は失われます。安全性はタグ付きテンプレートの構文から生まれるからです。フラグメントを組み合わせる必要がある場合は、値をパラメータのまま保つPrisma.sqlとPrisma.joinを使ってください。同じルールはpostgres.jsやslonikなど他のタグ付きテンプレート系ライブラリにも当てはまります。補間を安全にしているのはタグであり、あらかじめ組み立てた文字列を普通の値として渡すとそれを迂回してしまいます。

Python:psycopgとSQLAlchemy

Pythonで危険なのは、SQLに対して使うf文字列、%演算子、str.formatです。psycopgは%sプレースホルダーを使いますが、これは文字列フォーマットのように見えて別物です。値はexecuteの第2引数として渡され、%演算子を通ることは決してありません。SQLAlchemyのtext()構文は、名前付きバインドパラメータを使えば安全で、値を文字列に埋め込むと危険になります。

psycopgで引っかかりやすい点が1つあります。プレースホルダーは列の型に関係なく%s(名前付きなら%(name)s)であり、その周りに引用符を付けてはいけません。'%s'のように引用符を書くと、パラメータが再び文字列リテラルの一部になってしまい、クエリが壊れます。SQLAlchemyの名前付きパラメータも同様で、':status'ではなく:statusと書きます。

from sqlalchemy import text

# psycopg: 脆弱な例
cur.execute(f"SELECT id, email FROM users WHERE email = '{email}'")

# psycopg: 修正後。値は別途渡す
cur.execute("SELECT id, email FROM users WHERE email = %s", (email,))

# SQLAlchemy text(): 脆弱な例
conn.execute(text(f"SELECT id FROM orders WHERE status = '{status}'"))

# SQLAlchemy text(): バインドパラメータで修正
conn.execute(
    text("SELECT id FROM orders WHERE status = :status"),
    {"status": status},
)

Go:database/sql

Goのdatabase/sqlはプレースホルダーを標準でサポートしていますが、その構文はドライバーによって異なります。pgxなどのPostgreSQLドライバーは$1、MySQLとSQLiteは?です。脆弱なパターンは、fmt.Sprintfや文字列連結でWHERE句を組み立てることです。Goは静的型付けなので、int型のパラメータは危険になりえないと考えたくなります。値そのものについてはそのとおりですが、Sprintfでクエリを組み立てる習慣は隣の文字列パラメータにも広がるため、すべてのクエリをプレースホルダーで書いてください。

// 脆弱:文字列連結
q := "SELECT id, email FROM users WHERE email = '" + email + "'"
rows, err := db.QueryContext(ctx, q)

// 修正:プレースホルダーと引数(PostgreSQL構文。MySQLは?を使う)
var u User
err = db.QueryRowContext(ctx,
    "SELECT id, email FROM users WHERE email = $1", email,
).Scan(&u.ID, &u.Email)
if errors.Is(err, sql.ErrNoRows) {
    // 見つからない場合
}

ORMの抜け道

ORMが守ってくれるのはクエリビルダーであって、ORMのすべてのメソッドではありません。どのコードベースでも、まず次の場所を確認してください。

  • Prisma:$queryRawUnsafeと$executeRawUnsafe、そしてタグ付きテンプレートではなく組み立て済みの文字列を受け取る$queryRawの呼び出し。
  • SequelizeとTypeORM:sequelize.query、連結した文字列を渡すクエリビルダーの.where()呼び出し、生のorder句やgroup句。
  • Knex:?によるバインドではなく値を補間したknex.rawとwhereRaw。
  • SQLAlchemy:f文字列を使ったtext()、および入力から受け取ったliteral_column()や列名。
  • Django:.raw()、.extra()、フォーマット済み文字列を渡すcursor.execute。
  • GORM:引数ではなくfmt.Sprintfの結果を渡すWhere、Order、Raw。

動的なORDER BY:パラメータでは解決できない

プレースホルダーがバインドするのは値であって、識別子ではありません。ORDER BY $1 と書いて列名を渡すことはできません。データベースは定数で並べ替えてしまいます。そのため並べ替え可能なテーブルは開発者を文字列組み立てに引き戻しがちで、そこからインジェクションが戻ってきます。同じ制限は、テーブル名、スキーマ名、ASCやDESCといったSQLキーワードにも当てはまります。

安全なパターンは、ユーザー向けの並べ替えキーを既知の列名に対応付ける許可リストです。入力はエントリを選ぶだけで、決してSQLにはなりません。方向についても同様に、固定のASCかDESCに対応付けます。

var sortColumns = map[string]string{
    "created": "created_at",
    "name":    "name",
    "price":   "price_cents",
}

col, ok := sortColumns[r.URL.Query().Get("sort")]
if !ok {
    col = "created_at"
}
dir := "ASC"
if r.URL.Query().Get("dir") == "desc" {
    dir = "DESC"
}

// 安全:colとdirは上のコードにある値しか取りえない
query := fmt.Sprintf(
    "SELECT id, name, price_cents FROM products ORDER BY %s %s LIMIT $1",
    col, dir,
)
rows, err := db.QueryContext(ctx, query, limit)

どうしても許可リスト化できない動的な識別子が必要な場合は、自作ではなくドライバーの識別子クォート機能を使ってください。psycopgならsql.SQL(...).format(sql.Identifier(name))、pgxならpgx.Identifier{name}.Sanitize()です。それでも許可リストのほうが優れています。クォートは名前を安全にはしますが、有効である保証も許可されている保証もないからです。クォートされた識別子でも、呼び出し元はテーブル内の任意の列、たとえばパスワードハッシュや内部スコアなど、公開するつもりのなかった列で並べ替えたり絞り込んだりできてしまいます。隠し列で並べ替えると、結果の順序を通じて1回の比較ずつその値が漏れる可能性があります。

文字列組み立てを使わないINリスト

IDのリストによる絞り込みは、連結を正当化するもう1つのよくある口実です。主要なスタックにはいずれも安全な方法があります。

  • PostgreSQLでpgやpgxを使う場合:リスト全体を1つの配列パラメータとして渡し、WHERE id = ANY($1) と書きます。ドライバーがPostgresの配列として送信します。
  • Prisma:Prisma.sql`SELECT * FROM users WHERE id IN (${Prisma.join(ids)})` は要素ごとに1つのプレースホルダーへ展開されます。
  • SQLAlchemy:text("... WHERE id IN :ids").bindparams(bindparam("ids", expanding=True)) と書き、リストを渡します。
  • GoでMySQLを使う場合:リストの長さからプレースホルダー文字列を生成し(strings.Repeat("?,", n) の末尾を削る)、値は引数として渡します。生成するのはプレースホルダーだけで、値は決して生成しません。
  • クエリを実行する前に空のリストを拒否し、巨大な文を組み立てるために悪用されないようリストの長さに上限を設けます。

多層防御

修正の本体はパラメータ化です。次の対策は、1つのクエリがすり抜けたときの被害範囲を狭めます。

  • アプリケーションが必要とする権限だけを持つデータベースロールで接続します。Webアプリケーションに、DROPやALTER、他スキーマへのアクセスが必要なことはめったにありません。
  • データベースの生のエラーをクライアントに返さないようにします。エラーベースのインジェクションは、それが見えることを前提にしています。
  • 境界で型を検証します。整数やUUIDであるべきIDは、データ層に届く前にその型としてパースすべきです。
  • ステートメントタイムアウトを設定します。時間ベースのブラインドインジェクションも、暴走したクエリも同時に抑えられます。

すでにある脆弱箇所を見つける

まずは上に挙げた抜け道を検索し、次に文字列フォーマットの近くにあるSQLキーワードを探します。テンプレートリテラル、f文字列、Sprintf、+による連結の中のSELECTやWHEREです。ヒットした箇所はそれぞれ、正しくバインドされているか、許可リストから組み立てられているか、あるいはバグかのいずれかです。内部的に見えるコード経路も飛ばさないでください。CSVインポーター、cronジョブ、管理ダッシュボードは、元をたどればユーザー由来の入力を一段階挟んで受け取っていることがよくあります。自分のデータベースから読み戻した値が、以前は安全に保存されたペイロードを含んでいて、あとからクエリに貼り付けられることもあり、これは二次インジェクションとして知られています。CodeAuditAgentは公開GitHubリポジトリと貼り付けコードに対してこのファイル横断の追跡を行い、CWE-89の指摘事項を、該当クエリの引用、入力がそこへ届く経路、修正後のコードとともに報告します。

持ち帰るべきルールは短いものです。値はパラメータで渡し、識別子は許可リストから取り、リクエスト由来のものは決してSQL文に貼り付けない。