SQLインジェクションの基礎と対策
SQLインジェクション攻撃の仕組みと、プリペアドステートメント・ORM・入力バリデーションによる防御方法を理解する
TL;DR
- SQLインジェクションは、ユーザー入力をSQL文に直接連結することで発生する。攻撃者は任意のSQL文を実行できる
- 対策の第一はプリペアドステートメント(パラメータバインド)。ORM(Prisma・TypeORM)を使えばほぼ自動で守られる
- 生のSQLを書く場面では必ず
?や$1のプレースホルダーを使う。文字列連結でSQL文を組み立てない
SQLインジェクションとは
SQLインジェクション(SQL Injection)は、ユーザーの入力値がSQLクエリの一部としてそのまま解釈されることで、攻撃者が意図しないSQL文を実行できる脆弱性。
データの不正取得・改ざん・削除から、認証バイパス、最悪の場合はOSコマンドの実行まで可能になる。OWASP(Open Web Application Security Project)が毎年公開する「Webアプリケーションの危険な脆弱性Top10」に常に上位にランクインする深刻な脆弱性。
攻撃の仕組み
sequenceDiagram
participant A as 攻撃者
participant W as Webアプリ
participant D as DB
A->>W: username = "' OR '1'='1"
W->>D: SELECT * FROM users WHERE username='' OR '1'='1'
Note over D: 常にtrueになる条件→全件返る
D->>W: 全ユーザーのレコード
W->>A: ログイン成功 or 全データ漏洩
典型的な攻撃例
ログインフォームで username と password をSQLに直接埋め込んでいる例。
// ❌ 脆弱な実装
async function login(username: string, password: string) {
const query = `SELECT * FROM users WHERE username='${username}' AND password='${password}'`;
return db.query(query);
}攻撃者が username に ' OR '1'='1' -- を入力すると、SQLは以下のようになる。
-- 通常のクエリ
SELECT * FROM users WHERE username='alice' AND password='secret'
-- 攻撃者の入力後(username = "' OR '1'='1' --")
SELECT * FROM users WHERE username='' OR '1'='1' --' AND password='...'
-- ^^ ここ以降はコメントになる'1'='1' は常に真であり、-- 以降はコメントとして無視される。その結果 WHERE 条件が常に真になり、パスワードなしでログインできてしまう。
DROP TABLE攻撃(Union / Stacked Queries)
-- username = "'; DROP TABLE users; --"
SELECT * FROM users WHERE username=''; DROP TABLE users; --'
-- usersテーブルが削除される-- UNION を使ったデータ抽出
-- username = "' UNION SELECT id, email, password FROM users --"
SELECT id, name FROM products WHERE id='' UNION SELECT id, email, password FROM users --'
-- usersテーブルの認証情報が products のクエリ結果に混入する対策1: プリペアドステートメント(最重要)
プリペアドステートメント(Prepared Statement)は、SQL文の構造をあらかじめ固定し、値だけをあとから渡す仕組み。値はSQL文の一部としてではなく「データ」として扱われるため、どんな文字が入っても構文として解釈されない。
graph LR
A["SQL構造(固定)<br/>SELECT * FROM users<br/>WHERE username = ?"] --> C["DB実行エンジン"]
B["値(データとして渡す)<br/>'alice'"] --> C
C --> D["安全な実行<br/>構文への干渉不可"]
生のSQL(pg / mysql2)でのプリペアドステートメント
// PostgreSQL (pg ライブラリ)
import { Pool } from 'pg';
const pool = new Pool();
// ❌ 危険: 文字列連結
const result = await pool.query(`SELECT * FROM users WHERE username='${username}'`);
// ✓ 安全: $1 プレースホルダーを使う
const result = await pool.query(
'SELECT * FROM users WHERE username = $1 AND password_hash = $2',
[username, passwordHash]
);// MySQL (mysql2 ライブラリ)
import mysql from 'mysql2/promise';
const conn = await mysql.createConnection({ /* ... */ });
// ✓ 安全: ? プレースホルダーを使う
const [rows] = await conn.execute(
'SELECT * FROM users WHERE username = ? AND password_hash = ?',
[username, passwordHash]
);対策2: ORM を使う(Prisma / TypeORM)
ORM(Object-Relational Mapping)は、オブジェクトとDBのテーブルをマッピングするライブラリ。クエリを直接書く代わりにメソッドチェーンやオブジェクトで操作を記述するため、内部でプリペアドステートメントが使われ、SQLインジェクションを自動的に防ぐ。
Prisma
// ✓ 安全: Prismaのfindが内部でプリペアドステートメントを使う
const user = await prisma.user.findFirst({
where: {
username: username,
passwordHash: passwordHash,
},
});// Prismaで生のSQLを使う場合は $queryRaw テンプレートリテラルを使う
// ✓ 安全: テンプレートリテラル構文がパラメータを自動でバインドする
const users = await prisma.$queryRaw`
SELECT * FROM users WHERE username = ${username}
`;
// ❌ 危険: $queryRawUnsafe は文字列連結と同じリスクがある
const users = await prisma.$queryRawUnsafe(
`SELECT * FROM users WHERE username = '${username}'`
);TypeORM
// ✓ 安全: Repository APIはプリペアドステートメントを使う
const user = await userRepository.findOne({
where: { username, passwordHash },
});// TypeORMで生のSQLを書く場合はパラメータバインドを使う
// ✓ 安全: :username のプレースホルダーを使う
const user = await userRepository
.createQueryBuilder('user')
.where('user.username = :username', { username })
.andWhere('user.password_hash = :passwordHash', { passwordHash })
.getOne();
// ❌ 危険: 文字列テンプレートリテラルで直接埋め込む
const user = await userRepository
.createQueryBuilder('user')
.where(`user.username = '${username}'`) // SQLインジェクションになる
.getOne();対策3: 入力バリデーション
プリペアドステートメントが主要な対策だが、入力値の形式を事前に検証することで攻撃の余地をさらに減らせる。
// NestJS: class-validator を使ったバリデーション
import { IsString, MaxLength, IsEmail } from 'class-validator';
export class LoginDto {
@IsString()
@MaxLength(100)
username: string;
@IsString()
@MaxLength(200)
password: string;
}NestJS + Prisma での実装例
NestJSとPrismaを組み合わせた場合、通常のRepository操作は自動的に安全。生のSQLが必要な場合のみプレースホルダーを意識する。
// users.service.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class UsersService {
constructor(private prisma: PrismaService) {}
// ✓ 安全: Prismaのfindが内部でエスケープを行う
async findByUsername(username: string) {
return this.prisma.user.findFirst({ where: { username } });
}
// ✓ 安全: $queryRaw テンプレートリテラルはパラメータを自動バインド
async searchByKeyword(keyword: string) {
return this.prisma.$queryRaw`
SELECT id, name, email
FROM users
WHERE name ILIKE ${'%' + keyword + '%'}
`;
}
}NestJSのDTO・ValidationPipeによる入力バリデーションの詳細は NestJS DTO と ValidationPipe の基礎 を参照。
よくある落とし穴
ORMを使っていても生SQLが混入する
ORMで書かれたコードベースでも、パフォーマンス最適化やORMで表現しにくいクエリのために生のSQLを書く場面がある。このときにプレースホルダーを忘れると脆弱性になる。
// ❌ 危険: TypeORMの query() メソッドへの直接埋め込み
const result = await dataSource.query(
`SELECT * FROM products WHERE category = '${category}'`
);
// ✓ 安全: 第2引数でパラメータを渡す
const result = await dataSource.query(
'SELECT * FROM products WHERE category = $1',
[category]
);ORDER BY や テーブル名にはプレースホルダーが使えない
プリペアドステートメントは「値」のエスケープに使う。ORDER BY のカラム名やテーブル名は構文の一部であり、プレースホルダーでバインドできない。
// ❌ 危険: カラム名をそのまま埋め込む
const result = await pool.query(
`SELECT * FROM users ORDER BY ${sortColumn} DESC`
);
// ✓ 安全: 許可するカラム名を明示的にホワイトリストで管理する
const ALLOWED_SORT_COLUMNS = ['created_at', 'name', 'email'] as const;
type SortColumn = typeof ALLOWED_SORT_COLUMNS[number];
function isSortColumn(value: string): value is SortColumn {
return (ALLOWED_SORT_COLUMNS as readonly string[]).includes(value);
}
const column = isSortColumn(sortColumn) ? sortColumn : 'created_at';
const result = await pool.query(
`SELECT * FROM users ORDER BY ${column} DESC`
);エラーメッセージがDB情報を露出する
SQLエラーをそのままAPIレスポンスに返すと、テーブル名・カラム名・DB種別などの情報が攻撃者に渡る。
// ❌ 危険: DBのエラーをそのままクライアントに返す
try {
const user = await db.query(...);
} catch (err) {
res.status(500).json({ error: err.message }); // テーブル構造が漏洩する
}
// ✓ 安全: 内部エラーはログに出力し、クライアントには汎用メッセージを返す
try {
const user = await db.query(...);
} catch (err) {
logger.error('DB query failed', err);
throw new InternalServerErrorException('Internal error');
}まとめ
| 対策 | 効果 | 備考 |
|---|---|---|
| プリペアドステートメント | 根本的な対策 | 生SQLを書く場面で必須 |
| ORM(Prisma・TypeORM)の標準API | 自動的に安全 | ORM内部でプリペアドステートメントを使用 |
| 入力バリデーション | 補完的な対策 | 主要対策の代替にはならない |
| エラーメッセージの隠蔽 | 情報漏洩を防ぐ | DB情報をクライアントに返さない |