FlawOpen

flawopen.com/sql-injection/Javascript

● CWE-89 · 緊急
セキュリティ研究 · FlawOpen

JavaScript の SQL インジェクション(mysql2・pg・Prisma・Knex)

pool.query() の中でテンプレートリテラルや + を使うと、リクエストパラメータが SQL を書き換え、全行が返されます(CWE-89)。mysql2・node-postgres・Prisma・Knex での安全な呼び出し方、${} が Prisma のあるメソッドでは安全なのに隣のメソッドでは危険な理由、orderByRaw を使わない並べ替え方を解説します。[1], [2], [3], [4], [5], [6]

💡 わかりやすい解説 (ELI5)

荷物の仕分けセンターを想像してください。荷物が届く前に配送ルートを印刷して固定します。ラベルの名前は用意された欄に入り、ルートに新しい立ち寄り先を追加できません。SQL パラメーターも同じです。mysql2 execute(sql, [userId]) と pg query(sql, [status]) は値をバインドし、Prisma のタグ付き $queryRaw は各 ${} の位置をバインドします。先にテンプレートリテラルや + で SQL を組み立てたり、$queryRawUnsafe を使ったりすると、利用者の文字列がルート自体に書き込まれます。Knex orderByRaw もソート指定で同じ問題が起きるため、要求キーを固定列に対応づけて orderBy を呼びます。[1], [2], [3], [4], [5], [6]

主要な概念と専門用語

Template literals in query()
${userId} を含むバッククォート文字列は、ドライバーが呼ばれる前に JavaScript が評価します。ドライバーが受け取るのは、値がすでに埋め込まれた完成済みの SQL です。見た目はプレースホルダーですが、何もバインドしません。[7], [1], [6]
mysql2 execute() vs query()
execute(sql, values) はサーバー側のプリペアドステートメントを使います。query(sql, values) はクライアント側で値をエスケープし、? マーカーに差し込みます。値が配列にあればどちらも安全ですが、SQL 文字列を + やテンプレートリテラルで組み立てた場合はどちらも役に立ちません。mysql2 は multipleStatements をデフォルトで無効にしています。[3], [6], [9]
pg query(text, values)
node-postgres は values を text とは別に送るため、$1 を埋めるのは PostgreSQL です。値がない場合 pg は単純クエリプロトコルを使い、これはセミコロン区切りの複数の文も 1 回の呼び出しで受け付けます。[2], [6]
Prisma $queryRaw vs $queryRawUnsafe
$queryRaw はタグ付きテンプレートで、Prisma は各 ${} をバインドパラメータに変えます。$queryRawUnsafe(string) は完成した文字列を受け取るので、通常のテンプレートリテラル内の同じ ${} は SQL に貼り付けられます。名前は一語違うだけで、リスクは正反対です。[4], [6]
Knex whereRaw / orderBy
whereRaw('email = ?', [email]) は値をバインドしますが、補間した文字列を渡す whereRaw や orderByRaw は生の SQL です。orderBy(column) はカラムを識別子としてクォートするので、許可リストの名前だけを渡します。[5], [6]

フレームワーク対応

ステップ・バイ・ステップの攻撃フロー

ステップ 1

リクエストの値がデータ層に届く

Express のルートが req.query.id を読み取り、getUser() に渡します。クエリパラメータは文字列か配列で、数値になることはなく、誰も検証しません。[8], [6]

ステップ 2

テンプレートリテラルが SQL を組み立てる

` ... WHERE id = ${userId} が先に評価されます。無害なテスト値 0 OR 1=1 なら、mysql2 が見る前に文字列は WHERE id = 0 OR 1=1` になっています。[7], [1], [6]

ステップ 3

ドライバーは値を受け取らない

pool.query(sql) の値の配列は空なので、エスケープやバインドの対象がありません。MySQL は OR 1=1 を WHERE 句の一部として解析します。[3], [2], [9], [6]

ステップ 4

ルートがテーブルを返す

条件はすべての行で真になります。1 人のユーザーを返すはずのルートが全員を返し、$queryRawUnsafe やクエリ文字列から渡された orderByRaw でも同じことが起きます。[1], [6]

ソースコード比較:脆弱 vs 堅牢化

脆弱な実装
// users.js: 以下のクエリはどれもリクエストのデータから SQL を組み立てている

// mysql2: ドライバーが呼ばれる前にテンプレートリテラルが評価される
export async function getUser(pool, userId) {
  const [rows] = await pool.query(`SELECT id, email FROM users WHERE id = ${userId}`);
  return rows[0];
}

// pg: 連結しており、バインドする値の配列もない
export async function findOrders(pg, status) {
  const { rows } = await pg.query("SELECT id FROM orders WHERE status = '" + status + "'");
  return rows;
}

// Prisma: Unsafe 版は完成した文字列を受け取る
export function findByEmail(prisma, email) {
  return prisma.$queryRawUnsafe(`SELECT id FROM "User" WHERE email = '${email}'`);
}

// Knex: orderByRaw はリクエストの値を生の SQL として挿入する
export function listUsers(knex, sortColumn) {
  return knex('users').select('id', 'email').orderByRaw(sortColumn);
}
堅牢化されたセキュアパッチ
// users.js: 値は値の配列で渡し、カラムは許可リストから取る
const SORTABLE = new Map([['id', 'id'], ['email', 'email'], ['created', 'created_at']]);

// mysql2: サーバー側プリペアドステートメント。値は ? にバインド
export async function getUser(pool, userId) {
  const [rows] = await pool.execute('SELECT id, email FROM users WHERE id = ?', [userId]);
  return rows[0];
}

// pg: PostgreSQL が値の配列から $1 を埋める
export async function findOrders(pg, status) {
  const { rows } = await pg.query('SELECT id FROM orders WHERE status = $1', [status]);
  return rows;
}

// Prisma: タグ付きテンプレート。各 ${} がバインドパラメータになる
export function findByEmail(prisma, email) {
  return prisma.$queryRaw`SELECT id FROM "User" WHERE email = ${email}`;
}

// Knex: orderBy が許可済みのカラムを識別子としてクォートする
export function listUsers(knex, sortKey) {
  const column = SORTABLE.get(sortKey) ?? 'id';
  return knex('users').select('id', 'email').orderBy(column);
}

エンジニアリング&システム堅牢化チェックリスト

参考資料