겉보기엔 완벽한 동등 비교 함수가 Next.js 서버 함수의 비동기 변환 때문에 항상 true를 반환해 보안을 무력화한 사건과, 그로부터 얻은 교훈.
뒤로 코드베이스에 ‘마법’이 너무 많아 보안 장치가 무너진다면, 뭔가 크게 잘못됐다는 신호다.
2025년 10월 23일 목요일
981단어 - 읽는 데 5분
...로딩 중
...로딩 중
개요
우리는 모두 좋은, 올바른 소프트웨어를 쓰려고 애쓴다. 그런데 “완벽해” 보이는 함수, 즉 단 한 줄의 동등 비교가 거대한 보안 구멍을 만들어 버리면 어떻게 될까? 이번 글에서는 우리 Next.js 애플리케이션에서 발견했던 아주 고약한 버그 이야기를 공유하려 한다. 원래는 true 또는 false를 반환해야 할 함수가 항상 true를 반환하고 있었다.
현대 프레임워크의 ‘마법’이 때로는 얼마나 놀라운 문제를 야기할 수 있는지에 대한 경고담이다.
중요
다음 이야기는 대략 1년쯤 된 기술을 바탕으로 한다. 우리는 Next.js14.1.3과 React 18을 사용했다. 앞으로의 버전에 대해서는 나중에 이야기하겠다.
최근 Andrew Kelly가 Zig 프로그래밍 언어에 대해 발표하는 영상을 봤는데, 그의 논지는 “소프트웨어는 완벽해야 한다”였다.
그는 완벽을 이렇게 정의한다:
그리고 소프트웨어가 실패하는 방법을 열거했다:
그리고 프로그래머인 우리는 가능한 한 많은 올바른 소프트웨어를 쓰기 위해 노력해야 한다고 말한다.
완벽한 소프트웨어에 대한 다음과 같은 정의로부터 시작하면서:
가능한 모든 입력에 대해 소프트웨어가 올바른 출력을 낸다.
예시로 다음 C 코드 조각을 보자:
bool perform_not(bool x) {
return !x;
}
그는 거듭 말한다,
이것은 분명 완벽한 소프트웨어다!
물론 매우 단순한 예시이긴 하지만, 틀린 말은 아니다.
그리고 바로 이 부분을 보던 중, 우리 코드에서 겪었던 정말 지독한 버그가 떠올랐다.
다음 자바스크립트 코드 조각을 보자:
const isOwner = (userMail: string, ownerMail: string) => {
return userMail === ownerMail;
};
위 정의에 비춰보면 이 함수 또한 완벽한 코드라고 주장할 수 있다. 두 개의 입력을 비교해 그 결과에 따라 불리언을 반환한다. 함수로서 이보다 더 완벽하기 어렵다. (복잡한) 상태도 없고, (복잡한) 분기나 (복잡한) 변경도 없다.
그런데 Next.js(React)와 자바스크립트 사이의 미묘한 상호작용 덕분에 이 함수가 우리의 보안 장치를 거의 무너뜨릴 뻔했다.
리소스의 소유자인지 여부를 묻는 기능을 구현하면서 다음과 같이 썼다:
if (isOwner(userMail, resource.ownerMail)) {
// grant access
}
...그런데 스테이징 환경에서 뭔가 심각하게 잘못됐다는 걸 금방 눈치챘다.
문제는? 우리의 보안 체크가 모두 통과되고 있었다. 누구나 모든 리소스에 접근할 수 있었다.
모든 테스트는 녹색이었다(맞다, 이 간단한 함수도 테스트했다). 그런데도 함수는 우리가 기대한 대로 동작하지 않았다.
잠깐의 디깅과 테스트 끝에 눈을 의심했다. 함수가 항상 true를 반환하고 있었던 것이다!
위 코드 조각은 ‘서버 함수’로 호출되고 있었다. 이는 클라이언트에서 서버 측 코드를 호출하는 React의 새로운 방식이다.
다시 함수를 보자:
const isOwner = (userMail: string, ownerMail: string) => {
return userMail === ownerMail;
};
서버 함수를 오래 써본 노련한 분들은 이미 악마를 보셨을 것이다. 모르는 분들을 위해 설명하겠다.
서버 함수는 본질적으로 비동기이며, React 문서에는 이렇게 적혀 있다:
기본이 되는 네트워크 호출은 항상 비동기이기 때문에, 'use server'는 오직 async 함수에서만 사용할 수 있습니다.
좋다, 타당하다. 하지만 나는 몇 가지 문제를 보았다:
use server를 붙인 “함수”에만 적용되는 것처럼 보이고, 파일 전체에는 적용되지 않는 듯 쓰여 있다.우리가 isOwner를 호출했을 때, 함수 시그니처에 async가 없었음에도 반환값이 Promise였다. 겉보기엔 동기 함수였지만, 보이지 않는 곳에서 비동기 함수로 변환된 것이다. 즉, 더는 불리언을 반환하지 않고 Promise를 반환했다.
그리고 자바스크립트에서, if 문 안의 Promise 객체의 값은 무엇일까?
if (Promise<void>) {
// 항상 true
}
그렇다, truthy다!
우리의 보안 체크 if (isOwner(...))는 더 이상 if (true) 또는 if (false)를 검사하지 않았다. if (new Promise(...))를 검사했고, 이는 항상 true로 평가되어 언제나 접근을 허용했다. 훌륭하군요!
참고
이 문제가 어떻게 발생하는지 보여주는 작은 Next.js 예제가 GitHub에 있다. 기본 브랜치는 14.1.3(문제가 있는 버전)이며 vitest가 포함돼 있다. 최신 14.2.33 버전도 있는데, 이 버전에서는 파일에서 에러를 표시하지만 여전히 에러 없이 빌드할 수 있다. 최신 15.x 및 새로운 16.x에서도 테스트했는데, 이들에서는 빌드 타임 에러가 발생한다.
해법은 간단하다. 함수에 async를 선언하고 호출부에서 await하라. 그러면 올바른 값을 얻게 된다. 하지만 이런 일이 가능(혹은 가능했던) 자체가 여전히 당혹스럽다.
우리는 겉보기엔 완벽하고 동기적인 함수를 썼다. 하지만 프레임워크가 뒤에서 마법을 부렸다. ‘서버 함수’였기 때문에, Next.js가 자동으로 이 함수를 async로 만들고 반환값을 Promise로 바꿔버린 것이다.
그리고 자바스크립트에서 if 문 안의 Promise 객체는 항상 true다.
버그의 전부가 이거였다. 우리의 if (isOwner(...))는 사실상 if (true)를 쓴 것과 같았고, 그래서 모두를 들여보냈다.
도움을 준다고 도입된 프레임워크의 ‘마법’이 정말 기묘한 문제를 만들 수 있음을 보여준다. 우리는 운 좋게 이 문제를 잡았고, 다행히 최신 Next.js 버전에서는 이제 이에 대해 경고하거나 아예 빌드를 거부한다.