·reactrscsecurity

RSC (2): 보안 취약점으로 알아보는 설계 철학


지난 글에서는 RSC의 빌드와 런타임 과정을 살펴봤다.

이번에는 최근 리액트 생태계를 뜨겁게 달궜던 보안 취약점들을 통해 RSC가 어떤 보안 철학을 가지고 설계되었는지 알아보려 한다. 편리한 기능 뒤에 숨겨진 치명적인 위험과 이를 막기 위한 프레임워크들의 노력을 정리했다.

CVE-2025-55182 (RCE)

가장 먼저 살펴볼 이슈는 CVSS 점수 10.0을 기록한 치명적인 원격 코드 실행 취약점이다. 공격자가 인증 없이도 서버에서 임의의 코드를 실행할 수 있는 매우 위험한 보안 허점이었다.

react-server-dom-webpack, react-server-dom-parcel, react-server-dom-turbopack 라이브러리의 19.0 ~ 19.2.0 버전이 영향을 받았다. 앱에서 직접 Server Function을 쓰지 않더라도, 해당 기능을 지원하는 환경이라면 취약할 수 있다.

원인은 리액트가 클라이언트로부터 받은 요청 페이로드를 서버에서 해석하는 역직렬화 과정에 있었다. 리액트 서버 컴포넌트는 클라이언트가 서버 함수를 호출할 때 HTTP 요청을 내부 함수 호출로 변환한다. 이 과정에서 페이로드를 충분히 검증하지 않고 바로 역직렬화하면서 문제가 발생했다.

원래는 서버에 아래와 같은 형식으로 페이로드를 보내 createUser 같은 허용된 함수를 실행해야 한다.

{
  "action": {
    "module": "./actions/user.ts",
    "export": "createUser"
  }
}

하지만 취약한 버전에서는 공격자가 아래와 같이 임의의 노드 모듈을 호출하는 페이로드를 보내도 서버가 이를 믿고 실행해버릴 수 있었다.

{
  "action": {
    "module": "node:child_process",
    "export": "exec"
  },
  "args": ["rm -rf /"]
}

이 문제는 서버가 미리 허용한 함수만 실행할 수 있도록 하는 화이트리스트 패치를 통해 해결되었다.

CVE-2025-55184, 67779

임의 코드 실행(RCE)을 막기 위한 React2Shell 패치가 도입되었지만, 공격자들은 실행 권한 외의 다른 빈틈을 찾아냈다.

함수 실행 권한은 화이트리스트로 제어할 수 있게 되었지만, 실행 과정의 안전성까지는 통제하지 못한 상태였다. 공격자가 매우 깊은 순환 참조 객체(예: 객체 A가 B를 참조하고, B가 다시 A를 참조하는 구조가 수천 번 반복되는 형태)를 Payload로 보내면, 서버 파서가 이를 해석하다가 무한 루프에 빠지거나 메모리를 과도하게 점유하여 서버가 마비되는 DoS 현상이 발생했다.

리액트 팀은 페이로드의 역직렬화 깊이(Depth) 제한을 강화하고, 파싱 과정에서 발생할 수 있는 재귀 호출의 한계를 설정하여 자원 고갈을 방지하는 로직을 추가했다.

리액트의 보안 철학: 왜 클라이언트를 불신하는가

그렇다면 왜 리액트는 클라이언트 요청에 암호학적 서명을 붙여서 검증하지 않을까? 여기에는 RSC의 핵심 기능인 스트리밍과 캐싱을 보존하려는 설계 의도가 담겨 있다.

첫 번째로, 브라우저는 기본적으로 신뢰 가능한 환경이 아니다. 개발자 도구만 열어도 자바스크립트 번들과 네트워크 요청을 다 볼 수 있기 때문에 서버와 클라이언트만 아는 비밀 키를 브라우저에 숨기는 것은 사실상 불가능하다.

두 번째로, 매 요청마다 서명 인증을 하게 되면 조각 단위로 데이터를 보내는 스트리밍이 어려워진다. 또한 요청마다 서명이 달라지면 CDN이나 에지 캐시를 활용할 수 없어 성능 이점이 사라진다.

따라서 리액트는 클라이언트는 자신을 증명하지 않고 서버가 모든 요청을 불신한다를 기본 원칙으로 세웠다. 대신 서버 레퍼런스 화이트리스트와 실행 경계 제한을 강화하여 보안을 챙긴다.

프레임워크가 짊어지는 보안 책임과 그 뒤에 철학

리액트 팀은 RSC를 단독으로 사용하기보다 보안 가드 역할을 해줄 프레임워크와 함께 쓰기를 강력히 권고한다. 이는 리액트가 런타임 성능에 집중하는 동안, 프레임워크는 사용자의 요청이 안전한지 검증하는 문지기 역할을 맡기 때문이다. 각 프레임워크가 보안 취약점에 대응하는 방식은 그들이 추구하는 철학에 따라 확연히 갈린다.

프레임워크 React2Shell 방어 실행 화이트리스트 프레임워크 가드
Next.js 거의 완벽 ID 및 매니페스트 기반 매우 강력함
Waku 패치 전까지 취약 별도 화이트리스트 없음 거의 없음
React Router 부분적 방어 라우트 명시 확인 중간 수준
TanStack Start 구조적으로 안전 명시적 RPC 스타일 높음

1. Next.js: 사용자는 몰라도 되는 안전 (Secure by Default)

Next.js는 사용자가 일일이 보안 설정을 하지 않아도 프레임워크 차원에서 안전망을 제공하는 것을 목표로 한다.

빌드 타임에 실행 가능한 모든 서버 함수에 고유한 ID를 부여하고 이를 기록한 매니페스트를 만든다. 클라이언트에서 요청이 올 때 이 지도에 없는 ID라면 아예 실행조차 하지 않는다. 이는 관리자가 승인한 명단만 출입할 수 있는 폐쇄적인 요새와 같다. 덕분에 이번 RCE 취약점에서도 리액트 코어보다 한 단계 앞서 요청을 걸러낼 수 있었다.

2. Waku: 리액트 코어와의 일치 (Lightweight & Transparent)

Waku는 RSC의 본질을 가장 순수하게 구현하려는 철학을 가지고 있다. 프레임워크가 두꺼운 보호막을 치기보다는 리액트의 표준 동작 방식을 그대로 따르는 것을 선호한다.

이런 투명하고 가벼운 구조는 개발자에게 높은 자유도를 주지만, 리액트 코어에 취약점이 생겼을 때 프레임워크 수준에서의 방어막이 얇다는 단점이 있다. 이번 사태에서 Waku가 가장 먼저 패치 대상이 되었던 이유도 리액트 코어의 동작에 가장 밀접하게 붙어 있었기 때문이다.

3. React Router: 라우트가 곧 경계 (Route-Driven Security)

React Router(구 Remix)는 모든 데이터 흐름이 라우트 단위를 중심으로 돌아간다고 믿는다.

보안 역시 특정 페이지나 경로에 귀속된 액션들만 실행 가능하도록 제한하는 방식을 취한다. 웹 표준을 중시하기 때문에 브라우저의 기본 동작을 해치지 않는 선에서 보안 경계를 설정한다. Next.js만큼 엄격한 ID 기반 통제는 아니지만, 라우트라는 물리적인 경계를 통해 허용되지 않은 접근을 차단한다.

4. TanStack Start: 타입이 곧 보안 (Type-Safe RPC)

TanStack Start는 모든 함수 호출이 타입 시스템 안에서 안전하게 정의되어야 한다는 철학을 가진다.

서버 함수를 마치 원격 프로시저 호출(RPC)처럼 다루며, 실행 가능한 함수들을 명시적으로 정의한다. 공격자가 페이로드를 조작하더라도 타입 시스템이 기대하는 형식이 아니면 파싱 단계에서 거부될 확률이 높다. 명시적인 정의를 중시하기 때문에 모호한 요청이 서버로 흘러 들어가는 것을 구조적으로 방해한다.


결국 어떤 프레임워크를 선택하느냐는 보안에 대한 책임의 무게를 어디에 둘 것인가를 결정하는 문제이기도 하다. Next.js처럼 프레임워크가 모든 것을 책임지고 관리해주는 안락함을 선택할 수도 있고, Waku처럼 기술의 본질에 가까운 가벼움을 선택하되 보안 이슈에 더 민감하게 반응할 수도 있다.

중요한 것은 내가 사용하는 도구의 철학을 이해하고, 그 도구가 제공하는 보안 가드가 어디까지인지 파악하는 것이다.

정리하며

RSC 보안 이슈를 살펴보면 결국 편리함과 성능을 유지하면서도 외부의 악의적인 입력을 어떻게 걸러낼 것인가에 대한 끊임없는 싸움이라는 생각이 든다. 리액트는 보안 책임을 프레임워크로 넘기는 대신 최고의 런타임 성능을 선택했다.

개발자로서 우리가 할 일은 자신이 사용하는 프레임워크가 어떤 보안 가드를 제공하는지 이해하고, 항상 최신 보안 패치를 유지하는 것이다. 보안은 완성되는 것이 아니라 계속해서 관리해 나가는 과정임을 잊지 말아야겠다.