경보 21건 중 조치 대상 0건: CVE 와 취약점의 구분
목록으로

경보 21건 중 조치 대상 0건: CVE 와 취약점의 구분

13 조회
의존성 보안
#Next.js#CVE#Dependabot#공급망 보안#pnpm

저장소 보안 탭에 빨간 숫자가 떴다. 21.

전부 Next.js 였다. high 가 다섯 개.

처음 든 생각은 하나였다.

pnpm add next@latest

안 그랬다. 그리고 다 뒤져본 다음에는, 고칠 코드가 한 줄도 없다는 걸 알았다.

경보는 "취약하다"는 말이 아니다

Dependabot 이 하는 일은 생각보다 단순하다. 저장소를 훑어서 package.json 같은 파일을 전부 찾고, 거기 적힌 패키지 이름과 버전을 취약점 데이터베이스랑 맞춰본다. 범위에 들면 경보를 만든다.

이 코드가 실제로 돌아가는지 확인하는 단계는 없다. 파일에 적힌 글자만 본다.

그래서 아무도 설치하지 않는 예제 프로젝트에서도 경보가 뜬다. 테스트에서만 쓰는 도구에서도 뜬다. 이미 다른 버전으로 덮어둔 패키지에서도 뜬다.

무시하라는 말이 아니다. 경보는 조사를 시작하라는 신호지 결론이 아니다.

보안에서 제일 자주 쓰는 단어가 도달 가능성 이다. 취약한 코드가 저장소 어딘가에 있는 것과, 공격자가 그 코드까지 닿을 수 있는 것은 다른 이야기다. 판단은 거의 항상 여기로 모인다.

경보 21건이 전부 한 파일에서 나왔다

제일 먼저 볼 건 경보가 가리키는 파일 경로 다. 웹 화면에도 나오지만 CLI 가 빠르다.

gh api repos/:owner/:repo/dependabot/alerts --paginate \
  -q '.[] | select(.state=="open") | [
        .security_advisory.severity,
        .dependency.package.name,
        .security_advisory.cve_id,
        .security_vulnerability.first_patched_version.identifier,
        .dependency.manifest_path
      ] | @tsv'

21줄이 나왔는데 마지막 칸이 전부 같았다.

docs/design-handoffs/.../examples/nextjs-app-router/package.json

docs/ 아래다. 디자인 핸드오프 번들에 딸려온 예제 프로젝트였고, 거기 next: ^14.2.13 이 박혀 있었다.

그럼 진짜 앱은 어디 있나. pnpm-workspace.yaml 을 보면 나온다.

packages:
  - "apps/*"
  - "packages/*"
  - "e2e"

docs/** 는 목록에 없다. pnpm install 이 저 디렉터리를 아예 안 쳐다본다. 락파일에 안 들어가고, 빌드도 안 되고, 배포도 안 된다. 그 안의 next 는 그냥 문서에 적힌 글자다.

실제 배포되는 apps/frontend 는 next 16.2.12 로 안전하고, 워크스페이스 밖 문서 예제만 next 14.2.13 에 머물러 경보 21건을 만든 구조도

실제로 배포되는 앱은 next 16.2.12 였다. 이건 눈으로 보고 넘어가면 안 되니까 데이터로 확인했다.

gh api "/advisories?ecosystem=npm&affects=next&per_page=100" > next-adv.json
# advisory 64건 중 16.2.12 에 해당하는 것: 0건

여기서 할 일이 정해졌다. 긴급 업그레이드가 아니라 문서 예제의 버전 표기를 고치는 것. 경보 21건이 사라지고, 예제가 실제 앱이랑 같은 버전을 쓰게 됐다.

덤이 하나 있다. 그 예제를 복사해서 새 프로젝트를 시작하는 사람이 네 세대 낡은 설정을 물려받는 일도 같이 막혔다. 문서에 적힌 버전도 결국 누군가 복사한다. 문서는 코드가 아니라고 놔두면 문서가 취약점의 배포 경로가 된다.

"15.5.10 이상으로 올리세요" 를 그대로 따르면 버전이 내려간다

권고문에는 보통 이렇게 적혀 있다.

Patched versions: 15.5.21

여기서 많이 밟는 지뢰가 있다. 16.x 를 쓰는데 15.5.21 로 "올리는" 것.

15.5.21 은 16.2.12 보다 낮다. 업그레이드가 아니라 다운그레이드다.

Next.js 처럼 여러 릴리스 라인을 동시에 관리하는 프로젝트는 같은 취약점을 라인마다 따로 고쳐서 내려보낸다. 이걸 백포트라고 한다. 실제 데이터를 열어보면 이렇게 생겼다.

GHSA-89xv-2m56-2m9x (CVE-2026-64649, high)
  affected: >= 14.1.1, < 15.5.21   ->  patched: 15.5.21
  affected: >= 16.0.0, < 16.2.11   ->  patched: 16.2.11

한 취약점에 범위가 두 줄이다.

같은 CVE 가 Next.js 15 라인은 15.5.21, 16 라인은 16.2.11 에서 패치된다는 것을 보여주는 릴리스 라인 비교도

그러니까 순서가 이렇다. 내가 쓰는 메이저 라인을 먼저 확정하고, 그 라인에 해당하는 범위를 찾고, 그 줄의 first_patched_version 을 본다. 권고문 맨 위에 큼직하게 박힌 숫자가 내 숫자가 아닐 수 있다.

우리 앱은 16.2.12 니까 16 라인 하한선인 16.2.11 을 이미 넘었다. 그래서 할 게 없었다.

그럼 그냥 최신으로 올리면 되지 않나

이 저장소는 일부러 16.2 패치 라인에 머물러 있다. 보안 때문이 아니라 lint 때문이다.

16.3 이 추가한 no-location-assign-relative-destination 규칙이 --max-warnings 0 설정이랑 부딪힌다. 올리는 순간 CI 가 전부 빨개진다.

최신이 항상 정답은 아니다. 보안 하한선만 넘겼으면 그 위에서 어디에 머물지는 팀이 정하면 된다. 다만 그 선택에 이유가 적혀 있어야 한다. 안 적어두면 여섯 달 뒤에 누가 와서 "이거 왜 안 올렸지" 하고 처음부터 다시 판다. 대개 그 사람은 나다.

Next.js 취약점은 몇 가지 패턴을 반복한다

이름만 보고 감이 오면 대응이 빨라진다. 자주 나오는 건 네 종류다.

유형대표무슨 일이 생기나버전 말고 할 것
미들웨어 우회CVE-2025-29927특정 헤더를 붙이면 미들웨어를 건너뛴다인증을 미들웨어에만 두지 않는다
이미지 최적화 DoS다수조작된 이미지로 CPU·메모리를 태운다images.remotePatterns 를 좁게
캐시 오염다수A 의 응답이 B 에게 간다사용자별 라우트에 캐시 설정 명시
SSRF다수서버가 공격자가 준 URL 을 대신 찔러준다URL 허용 목록, 사설 IP 차단

제일 유명한 건 첫 줄이다. 작년에 나온 CVE-2025-29927 은 특정 내부 헤더를 요청에 실으면 미들웨어 실행 자체가 건너뛰어졌다. 인증을 middleware.ts 에서만 하고 있었다면 로그인 없이 보호된 페이지에 들어갈 수 있었다는 뜻이다.

여기서 배울 건 패치 버전 숫자가 아니다. 미들웨어는 1차 방어선이지 유일한 방어선이 아니라는 것. 데이터를 실제로 읽고 쓰는 자리 — API 핸들러, Server Action, DB 쿼리 — 에서 한 번 더 확인해야 한다.

네 유형 다 버전만 올리면 끝나는 문제가 아니다. 패치는 이번에 발견된 구멍을 막을 뿐이고, 같은 유형의 다음 구멍은 또 나온다. 유형을 알면 패치를 기다리는 동안에도 방어할 수 있다.

패치가 아예 없는 취약점을 만났다

pnpm audit 을 돌렸더니 하나가 남았다.

high | image-size | CVE-2025-71330 | vulnerable: <=2.0.2 | patched: <0.0.0

patched: <0.0.0. 처음 보면 오타 같다. 아니다. 0.0.0 보다 작은 버전은 있을 수 없으니 패치된 버전이 존재하지 않는다 는 말이다. npm 에 올라온 최신이 2.0.2 인데 그게 곧 취약한 버전이다. 올릴 데가 없다.

이때 초보가 하는 게 둘이다. 그냥 넘어가거나, audit 설정에서 빼서 숫자만 0 으로 만들거나. 앞엣것은 여섯 달 뒤에 아무도 기억을 못 하고, 뒤엣것은 판단이 아니라 은폐다.

해야 할 건 도달 가능성 따지기. 질문 두 개면 끝난다.

패치 유무와 프로덕션 도달 가능성에 따라 업그레이드, 의존성 제거, 문서화된 예외로 갈라지는 CVE 판단 흐름도

어디서 들어왔나.

pnpm why image-size
# apps/frontend > @storybook/nextjs-vite
#   > vite-plugin-storybook-nextjs > image-size

Storybook 이었다. devDependencies 다. next build 결과물에는 안 들어간다.

공격자 입력이 여기 닿나.

이 취약점은 조작된 ICNS / JXL / HEIF 이미지 헤더를 만나면 파서가 무한 루프에 빠지는 문제다. 공격이 성립하려면 공격자가 고른 파일이 저 파서에 들어가야 한다.

저 파서를 부르는 건 Storybook 의 이미지 처리 플러그인 하나뿐이고, 입력은 저장소에 커밋된 정적 파일이다. 인터넷에서 온 이미지가 거기 닿는 길이 없다.

그래서 수용하기로 했다. 다만 수용은 무시가 아니라서, 대장에 적었다.

## EXC-001 · image-size 무한 루프 DoS

| Advisory  | CVE-2025-71330, CVE-2025-71329 |
| 패치 버전 | 없음 (latest 2.0.2 가 취약)      |
| 유입 경로 | @storybook/nextjs-vite > … > image-size |
| 노출면    | devDependencies 전용             |

재검토 예정일: 2026-11-19

### 무효화 조건
1. 패치 릴리스가 나오면 즉시 올린다
2. image-size 가 어떤 패키지의 dependencies 로 올라오면 즉시 재평가
3. 사용자 업로드 이미지를 이 파서로 처리하는 코드가 생기면 전제가 깨진다

만료일과 무효화 조건이 제일 중요하다. 이게 없으면 예외 문서는 영원히 안 고칠 이유를 적어둔 종이가 된다. 있으면 다시 볼 날짜가 정해진 미결 항목이 된다.

판단은 사람이 하고 감시는 기계가 한다

여기서 멈추면 절반만 한 것이다.

이번 문제의 본질은 워크스페이스 밖 package.json 은 아무도 안 본다는 것이었다. 사람이 다음에도 안 볼 거니까, 저장소 전체를 훑는 테스트를 CI 에 넣었다. Node 내장 테스트 러너만 쓰면 외부 라이브러리도 필요 없다.

// 하한선은 advisory 의 first_patched_version 에서 가져온다.
// major 별로 따로 두는 이유는 Next 가 라인별 백포트를 하기 때문이다.
const NEXT_MINIMUM_BY_MAJOR = Object.freeze({
  15: '15.5.21',
  16: '16.2.11',
});

test('every declared next version is at or above its patched release line', () => {
  const violations = [];

  // node_modules 를 뺀 저장소 전체의 package.json 을 훑는다.
  for (const declaration of collectNextSpecifiers()) {
    const lowerBound = parseLowerBound(declaration.specifier);
    const minimum = NEXT_MINIMUM_BY_MAJOR[lowerBound.major];

    if (compareVersions(lowerBound, minimum) < 0) {
      violations.push(`${declaration.relativePath} is below ${minimum}`);
    }
  }

  assert.deepEqual(violations, []);
});

테스트를 먼저 쓰고 실패하는 걸 눈으로 봤다.

✖ every declared next version is at or above its patched release line
  docs/design-handoffs/.../package.json (dependencies.next=^14.2.13)
    targets unsupported next major 14; minimum supported major is 15

이 단계를 건너뛰면 아무것도 검사하지 않는 테스트를 통과시켜 놓고 안심하게 된다. 실제로 흔하다.

재밌는 건 나중에 확인해 본 것. 이 가드에 ^15.5.10 을 넣어봤더니 이렇게 나왔다.

is below the patched release 15.5.21

처음에 내가 따를 뻔했던 그 숫자를 가드가 잡는다.

같은 파일에 두 개를 더 넣었다. image-size 가 어떤 패키지의 dependencies 로 승격되면 실패한다 — 예외의 전제가 깨지는 순간을 잡는 장치다. 예외 문서가 없어지거나 재검토 날짜가 빠져도 실패한다.

CI 연결은 한 줄이고, pnpm install 보다 앞에 뒀다. 외부 의존성이 없으니 몇 초면 끝나고, 설치가 깨진 상황에서도 돈다.

- name: Enforce dependency supply-chain guard
  run: |
    node --test scripts/security/axios-supply-chain-guard.test.mjs
    node --test scripts/security/next-cve-guard.test.mjs

결국 바뀐 것

package.json 두 개의 버전 문자열, 문서 한 개, 테스트 파일 한 개, 워크플로 한 줄.

애플리케이션 코드는 한 줄도 안 바뀌었다.

경보를 받으면 순서는 이렇게 간다. 경로를 먼저 보고 그게 실제로 배포되는 파일인지 확인한다. 배포되는 게 맞으면 내 릴리스 라인의 패치 버전을 찾는다. 패치가 있으면 올린다. 없으면 도달 가능성을 따진다. 수용하기로 했으면 만료일과 무효화 조건을 적는다. 그리고 같은 게 다시 들어오지 못하게 가드를 건다.

보안 작업은 코드를 고치는 일이라고 생각했는데, 해보니까 대부분은 무엇을 고칠 필요가 없는지 증명하는 일이더라.


이 글이 나온 프로젝트

상상항해사(Imagine-Mate) 는 글 쓰는 사람들이 모여서 서로 읽어주고, 소일장으로 겨루고, 작가노트로 작업을 관리하는 커뮤니티다. 혼자 쓰다 보면 끊기는 글쓰기를 계속하게 만드는 장치들을 붙이는 게 목표다.

Next.js 프론트엔드와 NestJS 백엔드를 pnpm 모노레포로 묶어서 라즈베리파이 서버 두 대에 직접 올려 돌리고 있다. 이 글의 의존성 감사와 가드 테스트도 그 저장소에서 나왔다.


댓글

댓글 작성