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는 각 ${} 자리를 바인딩합니다. 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]
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

라우트가 테이블을 반환

조건이 모든 행에서 참이 됩니다. 사용자 한 명을 반환해야 할 라우트가 전원을 반환하며, $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: 서버 측 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);
}

엔지니어링 및 시스템 보안 강화 체크리스트

출처