포스트

정렬·필터 파라미터를 화이트리스트로 막기

사용자가 정렬 컬럼·방향을 지정하는 동적 정렬에서 SQL 인젝션이 생기는 이유와, 허용 목록 매핑·거부 기본값으로 막는 설계를 다룬다.

정렬·필터 파라미터를 화이트리스트로 막기

목록 화면에서 사용자가 정렬 컬럼과 방향을 고를 수 있게 만들었다. ?sort=name&dir=desc 같은 흔한 요구다. 그런데 이 작업은 조용히 위험하다. 정렬 컬럼명은 값(value)이 아니라 식별자(identifier)이고, 바인딩 변수로 막을 수 없다.

왜 바인딩으로 못 막나

PreparedStatement의 ? 플레이스홀더는 값 자리에만 들어간다. WHERE id = ?의 ?는 리터럴 값으로 안전하게 바인딩된다. 하지만 ORDER BY ?는 동작하지 않거나, 동작해도 컬럼명이 아닌 문자열 상수로 취급된다. 컬럼명·테이블명·ASC/DESC 같은 SQL 문법 구조는 본질적으로 파라미터화할 수 없다. 그래서 개발자들은 문자열을 그냥 이어 붙인다.

MyBatis로 치면 이 차이가 #{} 와 ${} 다.

  • #{sort} → PreparedStatement 바인딩. 컬럼명 자리엔 못 쓴다.
  • ${sort} → 문자열 그대로 치환. 컬럼명엔 쓸 수 있지만, 사용자 입력을 그대로 넣으면 인젝션이다.

ORDER BY ${sort}에 sort = "name; DROP TABLE users--"가 들어오면 어떻게 될까. 또는 더 흔하게는 (SELECT CASE WHEN ... THEN 1 ELSE (SELECT 1 UNION SELECT 2) END) 같은 블라인드 인젝션으로 데이터를 한 글자씩 추출당한다.

해법은 단 하나, 화이트리스트

사용자 입력을 검증·이스케이프하려는 시도 자체가 틀렸다. 식별자는 검증할 게 아니라 매핑할 대상이다. 허용된 정렬 키의 유한 집합을 서버가 정의하고, 들어온 입력을 그 집합의 키로만 받는다. 매칭되지 않으면 기본값으로 떨어뜨린다.

flowchart TD
  A["요청 sort=name, dir=desc"] --> B{허용 맵에 키 존재?}
  B -- 예 --> C["맵이 반환한 안전한 컬럼식"]
  B -- 아니오 --> D["기본 정렬로 fallback"]
  C --> E["ORDER BY 절 조립"]
  D --> E
1
2
3
4
5
6
7
8
9
10
11
private static final Map<String, String> SORTABLE = Map.of(
    "name",      "u.name",
    "createdAt", "u.created_at",
    "email",     "u.email"
);

public String resolveOrderBy(String sortKey, String dir) {
    String column = SORTABLE.getOrDefault(sortKey, "u.created_at"); // 거부 기본값
    String direction = "asc".equalsIgnoreCase(dir) ? "ASC" : "DESC"; // 이진 선택
    return column + " " + direction;
}

핵심은 사용자 문자열이 SQL에 단 한 글자도 직접 도달하지 않는다는 것이다. 사용자는 맵의 키를 고를 뿐이고, 실제 컬럼식은 서버 코드에 하드코딩된 값이다. dir도 마찬가지로 ASC/DESC 둘 중 하나로만 환원한다.

이렇게 만든 결과를 MyBatis에 넘길 때는 어쩔 수 없이 ${}를 쓰지만, 그 값은 이미 우리가 통제한 상수라 안전하다.

1
2
3
4
5
<select id="findUsers" resultType="User">
  SELECT * FROM users u
  <where> ... </where>
  ORDER BY ${orderBy}   <!-- orderBy는 resolveOrderBy의 산출물 -->
</select>

운영 함정

함정 1 — 다중 정렬과 화이트리스트. sort=name,email처럼 복수 정렬을 허용하면, 콤마로 split한 각 토큰을 개별적으로 맵에 통과시켜야 한다. 전체 문자열을 한 번에 매핑하려 들면 다시 인젝션 표면이 생긴다.

함정 2 — fallback을 예외로 던지지 말 것. 미허용 정렬 키에 400을 던지면, 북마크된 옛 URL이나 클라이언트 버그가 페이지를 통째로 깨뜨린다. 조용히 기본 정렬로 떨어뜨리는 쪽이 견고하다(단, 보안 결정엔 로그를 남긴다).

면접 한 줄 Q&A

Q. ORDER BY는 왜 PreparedStatement 바인딩으로 못 막나? 바인딩 변수는 SQL의 값 자리만 채운다. 컬럼명·정렬방향은 SQL 문법 구조라 파라미터화 대상이 아니다. 그래서 식별자는 검증이 아니라 서버가 정의한 화이트리스트로 매핑해야 한다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.