본문으로 건너뛰기
CodeAuditAgent
전체 글

Node.js, Python, Go에서 SQL 인젝션 막기

pg, Prisma, psycopg, SQLAlchemy, Go의 취약·수정 예제와 안전한 ORDER BY, IN 목록. CWE-89.

· 7분 분량 · Lina Source LLC

SQL 인젝션(CWE-89)은 웹에서 가장 오래된 버그 중 하나이면서 여전히 가장 파괴적인 축에 듭니다. 원인은 한 번도 바뀐 적이 없습니다. 사용자 입력이 쿼리 텍스트에 그대로 붙여 넣어지고, 그래서 데이터베이스가 데이터와 코드를 구분하지 못하는 것입니다. 해결책도 바뀌지 않았습니다. 쿼리와 값을 따로 보내고 드라이버가 바인딩하게 하세요.

바뀐 것은 버그가 사는 위치입니다. 대부분의 팀이 일상적인 쿼리에 ORM을 쓰기 때문에, 인젝션은 이제 탈출구에서 나타납니다. 리포트용으로 작성한 원시 쿼리, 동적 정렬이 있는 검색 엔드포인트, 마이그레이션 스크립트, 관리자 도구 같은 곳입니다. 이 가이드는 세 가지 생태계에서 취약한 버전과 수정된 버전을 살펴본 뒤, 매개변수만으로는 해결되지 않는 두 가지 경우를 다룹니다.

피해가 테이블 하나에 그치는 일은 드뭅니다. 인젝션이 가능한 쿼리 하나는 보통 애플리케이션의 전체 데이터베이스 권한으로 실행되므로, 비밀번호 해시와 API 토큰을 포함한 모든 고객의 데이터를 읽고 행을 수정할 수 있으며, 일부 데이터베이스에서는 파일 시스템이나 다른 서버에까지 도달할 수 있습니다. 블라인드 기법은 쿼리 결과가 사용자에게 전혀 보이지 않더라도 데이터를 한 비트씩 뽑아내므로, 참과 거짓만 반환하는 엔드포인트도 여전히 악용 가능합니다.

매개변수는 되고 이스케이프는 안 되는 이유

매개변수화 쿼리에서는 드라이버가 플레이스홀더가 들어간 SQL 텍스트를 보내고 값은 별도의 데이터로 전달됩니다. 데이터베이스는 값을 보기 전에 구문을 파싱하므로, 입력에 들어 있는 따옴표나 세미콜론은 그저 문자열 안의 한 글자일 뿐입니다. 수동 이스케이프는 값을 SQL 텍스트에 붙여 넣어도 안전하게 만들려는 시도인데, 인코딩과 숫자 컨텍스트, 식별자, 그리고 미처 생각하지 못한 온갖 경계 사례에서 실패합니다. 이스케이프하지 말고 바인딩하세요.

Node.js: pg와 Prisma

node-postgres에서 취약한 패턴은 query에 전달하는 템플릿 리터럴입니다. 해결책은 $1 플레이스홀더와 값 배열입니다. Prisma는 기본적으로 안전하지만 원시 API가 두 개 있고, 그중 보간과 함께 써도 안전한 것은 하나뿐입니다.

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-string과 % 연산자, str.format입니다. psycopg는 %s 플레이스홀더를 쓰는데, 문자열 포매팅처럼 보이지만 그렇지 않습니다. 값은 execute의 두 번째 인자로 전달되며 결코 % 연산자를 거치지 않습니다. SQLAlchemy의 text() 구문은 이름 있는 바인드 매개변수를 쓸 때는 안전하고, 값을 문자열에 포매팅해 넣으면 안전하지 않습니다.

psycopg에서 사람들이 자주 걸려 넘어지는 세부 사항이 하나 있습니다. 플레이스홀더는 컬럼 타입과 무관하게 %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-string을 쓴 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()입니다. 그래도 허용 목록이 더 낫습니다. 인용은 이름을 안전하게 만들 뿐 유효하거나 허용된 이름으로 만들어 주지는 않기 때문입니다. 인용된 식별자를 허용하면 호출자는 비밀번호 해시나 내부 점수처럼 노출할 생각이 없던 컬럼을 포함해 테이블의 어떤 컬럼으로도 정렬하거나 필터링할 수 있습니다. 숨겨진 컬럼으로 정렬하면 결과 순서를 통해 한 번에 한 비교씩 그 값이 새어 나갈 수 있습니다.

문자열 조립 없는 IN 목록

ID 목록으로 필터링하는 것은 문자열 연결을 정당화하는 또 하나의 흔한 핑계입니다. 주요 스택마다 안전한 방법이 있습니다.

  • pg나 pgx를 쓰는 PostgreSQL: 목록 전체를 배열 매개변수 하나로 전달하고 WHERE id = ANY($1)이라고 쓰세요. 드라이버가 Postgres 배열로 보냅니다.
  • Prisma: Prisma.sql`SELECT * FROM users WHERE id IN (${Prisma.join(ids)})`는 요소마다 플레이스홀더 하나로 확장됩니다.
  • SQLAlchemy: text("... WHERE id IN :ids").bindparams(bindparam("ids", expanding=True))를 쓴 뒤 리스트를 전달하세요.
  • MySQL을 쓰는 Go: 목록 길이로 플레이스홀더 문자열을 생성하고(strings.Repeat("?,", n)을 잘라서) 값은 인자로 전달하세요. 생성되는 것은 플레이스홀더뿐이고 값은 결코 아닙니다.
  • 쿼리를 실행하기 전에 빈 목록을 거부하고, 엔드포인트가 거대한 구문을 만드는 데 쓰이지 않도록 목록 크기에 상한을 두세요.

심층 방어

해결책은 매개변수화입니다. 아래 항목들은 쿼리 하나가 빠져나갔을 때 피해 범위를 줄여 줍니다.

  • 앱에 필요한 권한만 가진 데이터베이스 역할로 접속하세요. 웹 앱에 DROP이나 ALTER, 다른 스키마 접근 권한이 필요한 경우는 드뭅니다.
  • 원시 데이터베이스 오류를 클라이언트에 반환하지 마세요. 오류 기반 인젝션은 그것을 보는 데 의존합니다.
  • 경계에서 타입을 검증하세요. 정수나 UUID여야 하는 ID는 데이터 계층에 도달하기 전에 그렇게 파싱되어야 합니다.
  • 구문 타임아웃을 설정하세요. 시간 기반 블라인드 인젝션과 폭주하는 쿼리를 함께 제한해 줍니다.

이미 있는 것을 찾아내기

위에 정리한 탈출구를 먼저 검색하고, 그다음에는 문자열 포매팅 근처의 SQL 키워드를 찾으세요. 템플릿 리터럴이나 f-string, Sprintf, + 연결 안에 있는 SELECT나 WHERE가 대상입니다. 각 결과는 올바르게 바인딩되었거나, 허용 목록으로 만들어졌거나, 버그입니다. 내부용처럼 보이는 코드 경로도 건너뛰지 마세요. CSV 임포터와 cron 작업, 관리자 대시보드는 원래 사용자에게서 온 입력을 한 단계 건너 받는 경우가 많습니다. 여러분의 데이터베이스에서 다시 읽어 온 값도 이전에 안전하게 저장되었다가 나중에 쿼리에 붙여 넣어지는 페이로드를 담고 있을 수 있는데, 이를 2차 인젝션이라고 합니다. CodeAuditAgent는 공개 GitHub 저장소와 붙여넣은 코드에서 이 추적을 파일에 걸쳐 수행하며, CWE-89 발견 사항을 인용한 쿼리와 입력이 도달하는 경로, 수정된 버전과 함께 보고합니다.

기억할 규칙은 짧습니다. 값은 매개변수로 넣고, 식별자는 허용 목록에서 가져오며, 요청에서 온 것은 결코 SQL 텍스트에 붙여 넣지 않습니다.