hyunsu blog

홈동향

Next.js use cache 가 남의 캐시를 내주는 조건, 보안 패치 7건과 점검 순서

목차 · 8
  1. 01Cache Components 캐시 결함은 10월에 이어 다시 나왔다
  2. 027건은 캐시 넷과 응답·쿠키·이미지 셋으로 나뉜다
  3. 03루트 파라미터가 키에서 빠지는 틈으로 남의 값이 저장된다
  4. 04next start 공유 셸은 인증 없는 GET 한 번에 오염된다
  5. 05나머지 3건은 Cache Components 없이도 15·16 에 걸린다
  6. 06한국 팀은 자체 호스팅 16 앱부터, 16.3 이하는 마이너까지 올린다
  7. 07올린 뒤 남은 캐시까지 비워야 끝난다
  8. 08자주 묻는 질문

Next.js 가 이번 보안 릴리스에서 고친 7건 가운데 4건은 Cache Components 의 캐시가 다른 테넌트·사용자의 값을 내주거나 공유 셸을 오염시키는 문제다. cacheComponents: true 로 use cache 를 쓰는 16 계열 앱, 그중에서도 next start 로 직접 띄운 서버가 먼저 해당한다. 16.4.5 로 올린 뒤 남은 캐시까지 비워야 끝난다. 15 계열은 캐시 건과 상관없으니 응답·쿠키·이미지 쪽만 15.5.29 로 막으면 된다.

01 Cache Components 캐시 결함은 10월에 이어 다시 나왔다

use cache 는 함수나 컴포넌트의 결과를 캐시하라고 표시하는 지시어(directive)다. 15 에서 실험 기능으로 들어왔고, 16 에서 Cache Components 기능의 일부로 정식이 됐다. 이어 16.3 에서 next/root-params 가 더해지면서 캐시 함수 안에서 루트 경로 값을 인자 없이 읽을 수 있게 됐다. 이번 캐시 4건은 모두 이 기능 묶음 안에서 나왔다.

10월 14일 Next.js 는 정기 일정 밖의 보안 업데이트(16.4.1·15.5.28)로 세 건을 고쳤다. 그중 둘이 원격 코드 실행이었는데, 하나는 typed array 를 여러 개 돌려주는 use cache 함수에서, 다른 하나는 이미지 최적화가 쓰는 상위 라이브러리의 SVG 처리에서 나왔다. 나머지 하나는 Server Actions 를 노린 서비스 거부였다. 11월 3일 릴리스는 한 주 전 사전 공지로 날짜를 알린 정기 보안 릴리스다. 중첩 use cache 건(GHSA-vc7v-qv4w-wrv8)은 앞서 고친 GHSA-h694-7cp9-m8p3 의 변형이고 그때의 수정이 덮지 못한 경로라고 권고문 스스로 적었다. 이 글은 이번 릴리스 7건과 대응만 다루고, 10월 건의 세부는 다루지 않는다.

심각도 표기는 출처마다 다르다. Next.js 블로그는 unstable_cache 혼용 건을 High 로 적었지만, 같은 건의 GitHub 권고는 Moderate(CVSS v4 6.3)다. Netlify 는 7건 모두 Medium 으로 적었고 Pantheon 은 블로그 표기를 따랐다. 원격 코드 실행이던 10월보다는 급이 낮다. 그래도 남의 데이터가 내 화면에 찍히는 종류라서, 테넌트를 경로로 나누는 서비스라면 등급보다 조건이 맞는지를 먼저 보는 편이 낫다.

02 7건은 캐시 넷과 응답·쿠키·이미지 셋으로 나뉜다

권고잘못 나가는 것걸리는 조건영향 범위(권고문 기준)
GHSA-mfr8-8qww-v76h다른 테넌트의 캐시 값같은 use cache 함수를 직접도, unstable_cache 안에서도 부르고 그 함수가 루트 파라미터를 읽음16.3 이후
GHSA-vc7v-qv4w-wrv8다른 루트 파라미터 값의 화면중첩 use cache 의 안쪽이 루트 파라미터를 읽고 늦게 끝나는 값을 함께 돌려줌16.2 이후
GHSA-rhh4-g77r-r9qj다른 사용자의 캐시 값, 캐시 오염사용자를 가르는 바이너리 인자를 use cache 에 넘김16.0 이후
GHSA-vvgp-ww4w-46vf공유 셸 오염(엉뚱한 HTML, 계속되는 404)next start, partialPrefetching 꺼짐·미설정, 특정 라우트 모양16.4 이후
GHSA-pc58-hwjq-p385다른 클라이언트의 응답공격자가 꾸민 fetch() 응답을 그대로 돌려줌, HTTP/1.1 파이프라이닝 프록시 뒤16·15
GHSA-f3mh-rxg3-f5g2HttpOnly 쿠키 값Edge 라우트에서 NextResponse.cookies.set(), 같은 출처에서 공격자 JS 실행16·15
GHSA-7qx7-33qx-rqx2꺼 둔 SVG 파서에 닿음허용 출처의 이미지가 AVIF 로 오인됨, SVG 를 읽는 네이티브 백엔드16·15

권고문의 "고친 판" 칸은 비어 있거나 블로그와 다른 번호가 적힌 곳이 있어서, 이 글은 Next.js 블로그 공지를 기준으로 삼는다. 공지가 낸 판은 16.4.5(Active LTS)와 15.5.29(Maintenance LTS)다. Pantheon 은 16.4.5 가 7건을 모두 고친다고 정리했다. 15.5.29 는 표의 아래 세 건, 곧 Cache Components 와 무관한 건만 해당한다.

03 루트 파라미터가 키에서 빠지는 틈으로 남의 값이 저장된다

루트 파라미터는 루트 레이아웃보다 위에 놓인 동적 세그먼트다. app/[tenant]/layout.tsx 가 루트 레이아웃이면 tenant 가 루트 파라미터가 되고, 서버 컴포넌트 어디서든 next/root-params 에서 tenant() 를 가져와 읽는다. 공식 문서는 캐시 함수가 실제로 읽은 루트 파라미터만 캐시 키에 넣는다고 설명한다. 쓰지 않는 값 때문에 캐시가 쪼개지지 않게 한 설계인데, 아래 두 건은 그 "읽었다" 는 기록이 늦게 남거나 아예 검사되지 않는 틈에서 생겼다.

unstable_cache 와 섞어 부른 경로

프로세스가 막 떠서 캐시가 비어 있을 때 같은 함수를 직접 부른 요청과 unstable_cache 를 거친 요청이 겹치면, 중복 제거(deduplication) 경합으로 뒤 요청이 앞 요청의 결과를 받는다. 이때 루트 파라미터 검사를 건너뛰고, 결과는 루트 파라미터 없는 키로 저장된다. 그 뒤로는 재검증되거나 밀려날 때까지 같은 인자로 부르는 다른 테넌트가 그 값을 받는다. use cache 를 unstable_cache 안에서 부르는 것은 원래 지원하지 않는 조합이라 평소엔 오류가 나고, 권고문은 이 조합을 없애면 완전히 피할 수 있다고 적었다.

공식 문서는 16 부터 unstable_cache 대신 use cache 를 쓰라고 권하니, 두 API 를 오가며 옮기는 중인 코드라면 이 조합이 남아 있을 수 있다. 권고문은 프로세스가 뜬 뒤 첫 채우기가 끝나기 전에 요청이 겹쳐야 한다고 적었다. Netlify 는 실행이 요청마다 분리돼 이런 겹침이 생기지 않는다며 자기 플랫폼은 해당하지 않는다고 했다. 요청 여럿을 한 프로세스가 오래 받는 next start 서버가 바로 이 조건에 맞는 형태다.

중첩 use cache 의 늦게 끝나는 값

바깥 use cache 함수가 안쪽 use cache 를 부르고, 안쪽은 루트 파라미터를 읽은 뒤 바로 쓸 값과 아직 끝나지 않은 Promise 를 함께 돌려주는 모양이다. React Flight(서버 컴포넌트 결과를 흘려보내는 직렬화 형식)는 준비된 부분부터 바깥에 넘긴다. 그래서 바깥 함수가 안쪽의 루트 파라미터 의존이 기록되기 전에 끝나 버리고, 그 파라미터가 빠진 키로 항목이 저장된다. 권고문은 어떤 값이 샐지 공격자가 고를 수 없고 먼저 쓴 호출의 값이 나간다고 적었으며, 이 항목이 안쪽 캐시의 태그 무효화와 수명을 건너뛸 수 있다고 덧붙였다.

바이너리 인자

서로 다른 바이너리 인자가 같은 캐시 식별자를 만든다. 권고문은 Uint8Array·Blob 을 예로 들었고, 공식 문서는 TypedArray·ArrayBuffer 를 use cache 인자로 받는다고 적어 둔다. 사용자나 계정을 이 인자로 가르고 나머지 키 입력이 같으면 남의 결과를 받거나, 내 결과가 남에게 나간다. 관련 없는 캐시 항목을 마음대로 읽는 수단까지는 아니라고 공지는 선을 그었다.

아래는 혼용 건과 바이너리 인자 건에 걸리는 코드 모양이다. 데이터 함수 이름은 예시다.

ts
import { unstable_cache } from 'next/cache'
import { tenant } from 'next/root-params'

// 루트 레이아웃이 app/[tenant]/layout.tsx 인 앱
export async function getPlan() {
  'use cache'
  const t = await tenant() // 루트 파라미터를 읽는다
  return loadPlan(t)
}

// 혼용 건: 평소엔 오류가 나지만, 첫 채우기 경합에서는 남의 값을 받을 수 있다
export const getPlanLegacy = unstable_cache(() => getPlan(), ['plan'])

// 바이너리 인자 건: 사용자를 가르는 값을 Uint8Array 로 넘긴다
export async function getProfile(userKey: Uint8Array) {
  'use cache'
  return loadProfile(userKey)
}

04 next start 공유 셸은 인증 없는 GET 한 번에 오염된다

Cache Components 는 라우트에서 요청과 무관한 부분을 미리 렌더링한 셸로 두고, 요청마다 다른 부분은 나중에 채운다. 정적 셸과 PPR 의 관계는 CSR·SSR·SSG·ISR·RSC·PPR 차이와 화면별로 고르는 기준 에 따로 정리했다. 빌드 때 만들지 않은 값으로 들어온 요청은 비슷한 라우트들이 함께 쓰는 대체 셸(fallback shell)을 받는데, GHSA-vvgp-ww4w-46vf 는 조작한 경로 세그먼트 하나로 이 공유 셸에 요청 값이 섞여 저장되는 문제다. 권고문이 든 조건은 다섯이고, 모두 맞아야 한다.

  1. cacheComponents: true 이고 partialPrefetching 이 꺼져 있거나 설정되지 않았다.
  2. next start 로 직접 호스팅한다.
  3. 미리 렌더링할 수 있는 동적 파라미터에 빌드 때 만들지 않은 값이 들어온다.
  4. 그 아래에 미리 렌더링되지 않는 파라미터가 둘 이상 있고, 레이아웃이 그중 얕은 쪽을 Suspense 경계 안에서 읽는다.
  5. 대체 셸이 블로킹 방식이다.

레이아웃이 그 값으로 notFound() 를 부르면 그 셸을 쓰는 모든 URL 이 404 를 계속 받고, 오류 로그도 남지 않는다. 권고문에 따르면 이 404 는 "revalidate": false 로 저장돼 서버를 다시 시작해도 남는다. 값을 그대로 그리는 레이아웃이면 서버 HTML 에 엉뚱한 값이 찍히지만, 권고문은 데이터 유출은 없고 클라이언트 하이드레이션이 바로잡는다고 적었다. 장애처럼 보이는데 로그가 조용하니 원인을 찾기가 오히려 어렵다.

권고문이 낸 임시 조치는 설정 한 줄이다.

ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true, // 셸 오염 건의 권고 우회법
}

export default nextConfig

되돌리기는 쉽지만 동작이 바뀐다. 공식 문서에 따르면 이 옵션을 켜면 링크마다 하던 프리페치가 라우트당 하나를 재사용하는 App Shell 로 바뀌고, params·searchParams 에 기대는 내용은 기본적으로 이동한 뒤에 채워진다. 설정하지 않으면 false 처럼 동작하면서 경고만 남긴다. 문서는 다음 메이저에서 두 기능이 어디서나 켜지고 옵션이 없어진다고 적었지만 날짜는 밝히지 않았다. 패치를 바로 못 올리는 서버라면 스테이징에서 주요 화면 전환을 눌러 본 뒤 이 줄을 넣는 편이 낫다.

05 나머지 3건은 Cache Components 없이도 15·16 에 걸린다

업스트림 응답을 그대로 돌려주는 Route Handler

fetch() 로 받은 응답을 손대지 않고 return 하는 자체 호스팅 앱이 대상이다. 압축을 푼 본문에 원래 압축 응답의 프레이밍 헤더가 붙은 채 나가는데, 공지는 어느 헤더인지 이름을 들지 않았다. 앞단 HTTP/1.1 리버스 프록시가 파이프라이닝된 요청을 공유 백엔드 연결에 줄 세우는 경우, 이 어긋남 때문에 응답이 엉뚱한 클라이언트에게 간다. 공격자가 그 업스트림 응답을 꾸밀 수 있고 연결에 요청을 끼워 넣을 수 있어야 하며, 공지는 모든 리버스 프록시 설정에 해당하지는 않는다고 적었다.

Edge 라우트의 쿠키가 내부 헤더로 새는 건

App Router 의 Edge Route Handler 나 Pages Router 의 Edge API 라우트가 NextResponse.cookies.set() 으로 쿠키를 심으면, 직렬화된 쿠키가 내부 헤더 x-middleware-set-cookie 에도 실려 나간다. Set-Cookie 와 달리 이 헤더는 같은 출처의 JavaScript 가 읽을 수 있어서 HttpOnly 세션 쿠키도 드러난다. 피해가 나려면 그 응답이 민감한 쿠키를 심고, 공격자 JavaScript 가 이미 같은 출처에서 돌고 있어야 한다. 공지에 따르면 재현된 유출은 Node.js Route Handler 와 cookies().set() 경로에는 닿지 않았다.

공식 문서 기준으로 Edge 런타임은 지원 중단(deprecated) 상태이고, Cache Components 를 켜면 App Router 라우트에 runtime 설정을 둘 수 없다. 그러니 App Router 쪽은 Cache Components 를 아직 켜지 않은 앱에서, Pages Router 쪽은 Edge API 라우트에서 찾으면 된다. Netlify 는 자기 플랫폼에서는 미들웨어가 심은 쿠키도 이 문제에 걸리고, 이 경우는 Next.js 만 올려서는 막히지 않으며 어댑터 @netlify/plugin-nextjs v5.16.6 이 고친다고 적었다. 어댑터를 자동 설치로 쓰는 사이트는 다시 배포하면 되고, 어댑터 판을 직접 고정한 사이트만 따로 올리면 된다.

SVG 거부를 우회하는 이미지

허용한 출처에서 온, 공격자가 만든 이미지가 AVIF 로 잘못 분류되면 images.dangerouslyAllowSVG: false 를 지나친다. 공식 문서상 SVG 최적화는 원래 꺼져 있으니, 따로 켜지 않은 앱도 이 상태에 해당한다. 그 뒤 이미지 최적화 백엔드가 원래 바이트를 SVG 로 알아보고 디코딩해 꺼 둔 파서까지 닿는다. 출처 허용 목록은 그대로 적용되고 SVG 를 읽을 수 있는 네이티브 백엔드가 있어야 하며, 공지는 XSS 나 원격 코드 실행까지 입증된 것은 아니라고 적었다. 10월 원격 코드 실행이 바로 SVG 처리 쪽에서 나왔으니, 가볍게 넘길 노출면은 아니다.

06 한국 팀은 자체 호스팅 16 앱부터, 16.3 이하는 마이너까지 올린다

자체 호스팅이 먼저인 까닭은 캐시가 오래 사는 자리이기 때문이다. 공식 문서는 기본 인메모리 캐시 항목이 서버리스에서는 대개 요청을 넘어 남지 않고, 자체 호스팅에서는 요청을 넘어 남는다고 설명한다. 클라우드 VM 이나 컨테이너에서 next start 로 띄운 서버라면 한 번 잘못 저장된 값이 더 오래, 더 많은 요청에 나간다는 뜻이다. 셸 오염 건은 아예 next start 만 대상으로 한다.

우리 앱이걸리는 건언제
cacheComponents: true + 루트에 [tenant]·[shop] 같은 테넌트 세그먼트 + next start캐시 4건이번 주 배포
cacheComponents: true + 루트가 [lang]·[locale] 인 다국어 사이트루트 파라미터 2건(다른 언어 화면), 셸 오염이번 주 배포
Cache Components 를 켜지 않은 16·15 앱응답·쿠키·이미지 3건해당 코드가 있으면 이번 주, 없으면 정기 업그레이드
Netlify 같은 플랫폼 호스팅플랫폼이 정리한 건만의존성을 올리고 다시 배포

16.3 이하 16 앱은 패치만 골라 받을 수 없다. 이번 16 쪽 패치는 16.4.5 하나뿐이고, 지원 정책상 Active LTS 의 마이너에는 보안 수정과 함께 새 기능과 버그 수정도 들어온다. 그래서 되돌릴 길부터 만든다. --save-exact 로 판을 고정하고, 지금 도는 이미지나 배포본을 남겨 둔 채 스테이징에 먼저 올리면 이전 판으로 돌아가는 일이 태그 하나를 되돌리는 것으로 끝난다.

15 계열은 Maintenance LTS 다. 지원 정책은 원래 2026년 10월 21일이던 끝을 2027년 1월 31일까지 늘렸다고 밝혔다. 정책이 15 의 보안 릴리스를 약속하는 기간은 거기까지이니, 이번 패치와 별개로 16 으로 옮길 일정을 지금 잡아 두는 편이 낫다. 14 이하는 이미 지원이 끝났다.

플랫폼에 올렸어도 할 일은 남는다. Netlify 는 SVG 우회·unstable_cache 혼용·응답 섞임 3건이 자기 플랫폼에는 닿지 않는다고 정리했고, 응답 섞임 건은 패치 전 판에서 브라우저가 응답을 풀지 못할 수 있다고 덧붙였다. Pantheon 은 앱 의존성은 고객이 직접 올리고 다시 배포해야 한다고 안내했다. 공지는 Vercel 에 올린 앱이 어느 건에 해당하는지 따로 적지 않았다.

하지 않아도 되는 경우도 있다. Cache Components 를 켜지 않았고, Edge 런타임에서 쿠키를 심지 않으며, 공격자가 내용을 정할 수 있는 이미지(외부 URL·사용자 업로드)를 최적화하지 않고, 업스트림 응답을 그대로 돌려주는 Route Handler 도 없다면 7건 어디에도 걸리지 않는다. 이때는 배포 창을 급히 열 필요 없이 다음 정기 업그레이드에 넣으면 된다.

07 올린 뒤 남은 캐시까지 비워야 끝난다

먼저 공지의 패치판으로 올리고 확인한다.

bash
# 지금 설치된 판
npm ls next

# 16 계열: 공지의 패치판으로 올리고 판을 고정한다
npm install --save-exact [email protected]
# 15 계열이라면
# npm install --save-exact [email protected]

# 설치된 판을 다시 본다
npm ls next

이어서 걸리는 코드가 있는지 가린다. 바이너리 인자는 grep 으로 가리기 어려우니, use cache 함수 가운데 Uint8Array 같은 값을 받는 것을 직접 훑는다.

bash
# 켜 둔 기능
grep -nE "cacheComponents|partialPrefetching|dangerouslyAllowSVG|remotePatterns" next.config.*

# unstable_cache·루트 파라미터를 쓰는 파일
grep -rl "unstable_cache" app src lib 2>/dev/null
grep -rl "next/root-params" app src lib 2>/dev/null

# runtime 을 edge 로 둔 라우트
grep -rnE "runtime.{0,4}['\"]edge['\"]" app pages src 2>/dev/null

그다음이 캐시다. use cache 항목은 키에 빌드 ID 가 들어가서 새 빌드를 배포하면 지난 항목을 다시 쓰지 않고, 태그 무효화를 건너뛸 수 있는 중첩 건의 항목도 이 경로로 정리된다. 다만 deploymentId 를 설정했다면 그 값이 빌드 ID 대신 키에 쓰이니, 배포마다 바뀌지 않는 값이라면 저장소를 직접 비운다. unstable_cache 는 공식 문서가 요청과 배포를 넘어 결과를 남긴다고 설명하는 API 라, 혼용 건으로 잘못 저장된 값은 패치 배포 뒤에도 남을 수 있다. 해당 태그를 무효화하거나 저장소를 비운다. 셸 오염 건으로 저장된 404 도 재시작으로는 사라지지 않는다고 권고문이 적었으니, 패치 뒤 해당 라우트가 정상으로 응답하는지 확인하고 아니면 저장된 캐시를 비운다. Netlify 에 올렸다면 새 배포가 같은 배포 컨텍스트의 캐시만 지운다는 안내대로 브랜치 배포와 미리보기 배포를 따로 비운다.

Edge 쿠키는 응답 헤더로 바로 보인다. 쿠키를 심는 Edge 라우트에 패치 전후로 같은 요청을 보내, 내부 헤더가 실려 오는지 비교한다.

bash
curl -s -o /dev/null -D - -X POST https://example.com/api/session \
  | grep -i '^x-middleware-set-cookie'

오늘 올리지 못하는 서버라면 아래로 버틴다.

  • 셸 오염: 권고문의 우회법대로 partialPrefetching: true 를 넣는다.
  • unstable_cache 혼용: 권고문이 완전한 우회라고 적은 대로 use cache 함수를 unstable_cache 로 감싼 경로를 없앤다.
  • 바이너리 인자: 권고문에 우회법이 없으니, 사용자를 가르는 값은 hex 같은 문자열로 바꿔 넘기는 편이 낫다(권고문이 다룬 것은 바이너리 인자뿐이다).
  • Edge 쿠키: 민감한 쿠키를 심는 라우트는 재현된 유출이 닿지 않은 Node.js 런타임이나 cookies().set() 경로로 옮긴다.
  • SVG 우회: images.remotePatterns 를 직접 관리하는 출처로 좁힌다(공식 문서도 가능한 한 구체적으로 적으라고 권한다).
  • 응답 섞임: 업스트림 응답을 그대로 돌려주는 Route Handler 를 목록으로 뽑고, 앞단 프록시가 백엔드 연결에서 파이프라이닝을 쓰는지 확인한다.

08 자주 묻는 질문

cacheComponents 를 켜지 않은 15 앱도 올려야 하나?

응답 프레이밍·Edge 쿠키·SVG 우회 3건은 15 에도 걸리고, Pantheon 정리로는 15.5.29 가 이 세 건을 고친다. 캐시 4건은 권고문 영향 범위가 16 이후라 15 와는 상관없다.

partialPrefetching: true 를 켠 뒤에도 특정 링크는 예전처럼 미리 받을 수 있나?

공식 문서에 따르면 <Link prefetch={true}> 를 단 링크는 App Shell 에 더해 params·searchParams 같은 URL 데이터까지 미리 받는다. 세그먼트에서 prefetch 값을 따로 내보내 그 라우트만 다르게 둘 수도 있지만, 그렇게 되돌린 라우트까지 셸 오염 우회가 통하는지는 권고문이 말하지 않는다.

서버를 다시 시작하면 잘못 저장된 캐시가 사라지나?

종류마다 다르다. use cache 는 기본 저장소가 메모리라 프로세스와 함께 비워지지만, cacheHandlers 로 외부 저장소를 붙였다면 남는다. 셸 오염 건의 404 는 재시작해도 남는다고 권고문이 적었고, unstable_cache 는 배포를 넘어서도 남는다.

15 계열에 계속 머물러도 되나?

2027년 1월 31일까지는 정책상 보안 릴리스가 나온다. 그 뒤는 약속이 없으니 16 으로 옮길 일정을 함께 잡는다.