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는 각 ${} 자리를 바인딩합니다. template literal이나 +로 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)는 서버 측 prepared statement를 씁니다.query(sql, values)는 클라이언트에서 값을 이스케이프해?마커에 넣습니다. 값이 배열에 있으면 둘 다 안전하지만, SQL 문자열을+나 템플릿 리터럴로 만들었다면 어느 쪽도 도움이 되지 않습니다. mysql2는 기본적으로multipleStatements를 끕니다.[3], [6], [9]pg query(text, values)- node-postgres는
values를text와 따로 보내므로$1은 PostgreSQL이 채웁니다. 값이 없으면 pg는 단순 쿼리 프로토콜을 쓰며, 이 프로토콜은 세미콜론으로 구분한 여러 문장도 한 번의 호출로 받아들입니다.[2], [6]
프레임워크 매핑
- 이 가이드에서 설명하는 약점의 CWE 참조: CWE-89
- OWASP Top 10:2025 — A05:2025 Injection
단계별 공격 실행 흐름
단계 1
단계 2
단계 3
소스 코드 비교: 취약한 구현 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: 서버 측 prepared statement, 값은 ?에 바인딩
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);
}
엔지니어링 및 시스템 보안 강화 체크리스트
- ✓CI에서 ESLint
no-restricted-syntax규칙이나 Semgrep으로$queryRawUnsafe,$executeRawUnsafe, 보간을 쓰는whereRaw/orderByRaw, 그리고query()/execute()첫 번째 인자의 템플릿 리터럴이나+를 금지합니다.[1], [3], [2], [4], [5], [6] - ✓모든 값은 드라이버의 값 배열(mysql2는
?, pg는$1)이나 Prisma 태그드$queryRaw의${}로 넘깁니다.[3], [2], [4], [6] - ✓라우트에서 스키마(zod, Joi)로 입력을 검증합니다. ID는 숫자로 바꾸고, 알 수 없는 키는 거부하며, 문자열이 필요한 곳에서 배열을 받지 않습니다.[8], [6]
- ✓정렬 키는
Map허용 목록으로 매핑하고 그 결과를 KnexorderBy()나 PrismaorderBy에 넘깁니다.req.query를 원시 ORDER BY에 넘기지 않습니다.[5], [4], [6] - ✓mysql2의
multipleStatements는 끈 상태로 두고 DDL 권한이 없는 데이터베이스 사용자로 접속합니다.[3], [9], [6]
출처
- CWE-89: Improper Neutralization of Special Elements used in an SQL Command
- node-postgres — Parameterized query
- mysql2 — Using prepared statements
- Prisma — Raw queries and SQL injection
- Knex.js — Raw parameter binding
- OWASP SQL Injection Prevention Cheat Sheet
- MDN — Template literals (JavaScript)
- Express 4 API — req.query
- MySQL C API — Multiple statement execution support