filtersql 1.2.6을 공개했을 때 보안 모델에 자신이 있었지만, 새 예외 사례가 드러났다. 처음에는 몇 분이면 고칠 수 있는 사소한 실수처럼 보였으나, 코드를 살펴보니 특정 문제를 해결하는 것만으로는 부족했다. 보안 취약점의 원인은 로직이 아니라 구조에 있었다.
식별자와 `scope` 충돌
실제 데이터베이스 스키마에는 공백, 악센트, 특수 문자, 유니코드 기호가 들어간 열 이름이 있을 수 있다. 식별자를 복잡하게 처리하지 않고 인용하려고 처음에는 앞뒤 공백을 제거하는 간단한 정규화 규칙을 적용했다. 따라서 뒤에 공백이 붙은 "total " 열 이름은 "total"로 정리됐다. 일부 예외 사례는 잘라내지만 표준적인 경우 대부분은 괜찮을 것으로 봤다.
filtersql의 scope는 tenant_id = 42 같은 서버 측 고정 조건을 담는 맵이다. 이 조건은 select, insert, update, delete 작업에 자동으로 삽입된다. 데이터베이스 수준의 행 단위 보안을 대체하지는 않지만, 멀티테넌트 웹 앱을 위한 강한 안전장치 역할을 한다.
처음에는 클라이언트 페이로드가 scope의 열을 덮어쓰려 해도 막지 않았다. 하지만 읽기와 삭제에서는 scope 조건이 where 절에 and로 추가되므로 select와 delete는 구조상 안전하다. 쓰기 작업은 다르다.
insert에서 중복 열을 지정하면 다음과 같은 쿼리가 만들어질 수 있다.
insert into users (tenant_id, tenant_id, name) values (400, 500, 'Mario');
DBMS에 따라 첫 번째 값을 선택하거나 마지막 값을 선택하거나, 아예 실패할 수 있다.
update에서는 문제가 더 심각하다.
update users set tenant_id = 500 where tenant_id = 400;
잘못됐거나 악의적인 페이로드 하나로 레코드를 한 테넌트에서 다른 테넌트로 조용히 옮길 수 있다. 레코드 손실뿐 아니라, 공격자가 관리자 계정을 자기 테넌트에서 피해자 테넌트로 옮겨 탈취할 수도 있다.
이를 막기 위해 충돌 검사를 추가했지만, 1.2.6에는 그 검사에도 결함이 있었다. scope가 "tenant_id" 열을 보호할 때 클라이언트가 쓰기 페이로드의 키로 뒤에 공백이 붙은 "tenant_id "를 보내면, 검사는 원래 문자열인 "tenant_id"와 "tenant_id "를 비교한다. 두 문자열이 다르다고 판단해 통과시킨 뒤, 처리 과정 후반부에서 인용 함수가 .strip()을 적용해 공백을 제거한다. 그 결과 보호 대상인 tenant_id를 덮어쓰는 쿼리가 컴파일되어 실행된다.
이는 전형적인 정규화(canonicalization) 버그다. 검증 단계와 최종 실행 단계가 같은 데이터의 서로 다른 표현을 보고 있었다. .strip()을 더 앞에서 적용했다면 당장의 버그는 고칠 수 있었지만, 더 큰 구조적 문제가 드러났다.
문자열 비교만으로는 의미상 같은 식별자를 찾을 수 없다
문자열이 같으면 같은 문자열이라는 점은 안심이 된다. 그러나 서로 다른 문자열도 데이터베이스 엔진에서는 같은 의미로 해석될 수 있다.
예를 들어 서버가 scope: {"tenant_id": 400}을 설정하고 클라이언트가 values: {"users.tenant_id": 500}을 보내면, 문자열 기반 충돌 검사는 "tenant_id"와 "users.tenant_id"가 다르다고 판단한다. 라이브러리는 다음 쿼리를 생성한다.
update users set users.tenant_id = 500 where tenant_id = 400;
Postgres와 SQLite는 이 구문을 거부했지만, MySQL은 이를 받아들여 허가되지 않은 테넌트 이동을 실행했다.
테이블 한정자는 문제의 일부에 불과했다. 대소문자 구분 방식, 데이터베이스 별칭, 유니코드 정규화 형식인 NFC와 NFD도 고려해야 한다. 예를 들어 à는 하나의 코드 포인트로 표현할 수도 있고 두 개로 나눠 표현할 수도 있다. 원시 바이트 문자열은 완전히 다르지만 데이터베이스는 두 표현을 같은 열로 처리한다.
공격자나 데이터베이스가 만들어낼 수 있는 모든 의미상의 변형을 추측하는 것은 지속할 수 없는 방식이다. filtersql은 문자열만 생성하며, 의도적으로 실제 데이터베이스 연결과 분리돼 있다.
쓰기는 엄격하게, 읽기는 유연하게
문자열 비교를 더 똑똑하게 만드는 대신 경계에서 계약을 바꿔야 했다. 이에 읽기와 쓰기에 서로 다른 규칙을 적용했다.
- 읽기 작업인
select와where에서는 한정 식별자(users.tenant_id)나 JSONB 경로를 계속 허용한다. 이 값들은 별도로 파싱하고 안전하게 인용한다. - 쓰기 작업인
insert,update,delete에서는values또는id의 모든 키가_validate_bare_identifier검증을 통과해야 한다.
유효한 쓰기 식별자는 순수한 단일 식별자여야 한다. 앞이나 뒤에 공백이 있으면 안 되며, 공백을 잘라내는 처리는 없앴다. 공백이 있으면 InvalidIdentifierError를 반환한다. 점이나 JSONB 연산자도 허용하지 않으며, 유니코드는 반드시 NFC 정규 형식이어야 한다. scope와 쓰기 키를 모두 NFC로 정규화하고 .casefold()를 적용해 충돌 가능한 범위를 좁혔다.
1.3.0에서 허용 범위를 명시하다
돌이켜보면 명시적 허용 목록을 처음부터 설계에 넣었어야 했다. 식별자 검증 규칙과 NFC 정규화 및 .casefold()는 당장의 취약점을 해결했지만, 데이터베이스가 식별자를 해석하는 모든 방식을 예상하려는 접근에는 더 깊은 한계가 있었다.
그래서 filtersql 1.3.0에는 allowed_columns와 allowed_sources가 추가됐다. 애플리케이션은 각 엔드포인트가 노출할 수 있는 열과 데이터 소스를 명시적으로 지정할 수 있고, 그 경계 밖의 항목은 거부된다.
이에 따라 책임도 나뉜다. 애플리케이션은 각 엔드포인트에서 허용할 데이터 범위를 정하고 테넌트 범위 같은 신뢰할 수 있는 서버 측 제약 조건을 제공한다. filtersql은 입력이 그 제약 조건에 맞는지 검증하고 매개변수화된 SQL을 생성하는 결정론적 컴파일러로 동작한다.
목표는 데이터베이스가 식별자를 해석하는 방식을 모두 추측해 안전하게 만드는 것이 아니다. 컴파일러가 표현할 수 있는 범위 자체를 제한하는 것이다.
코드, 문서, 예제는 filtersql.org와 GitHub 저장소에서 확인할 수 있다.
댓글 (0)
로그인하면 이 기사에 내 생각을 남길 수 있어요