~ / posts / developer
💻 개발자

Content-Encoding 압축 해제 보안, 압축 폭탄과 크기 제한 설정

HTTP 응답이나 요청 본문을 받을 때 압축을 누가, 언제, 얼마나 푸는지 생각해 본 적 있으신가요? 결론부터 말하면 대부분의 HTTP 라이브러리는 상대가 보낸 Content-Encoding 헤더만 보고 gzip·br·zstd를 자동으로 풀고, 내가 보낸 Accept-Encoding은 이 동작을 막지 못해요. 그래서 압축 해제는 "남이 고른 바이트를 내 프로세스 안에서 돌리는 일"로 다뤄야 하고, 해제 뒤 크기 한도·허용 인코딩 목록·디코더 패치 세 가지를 같이 챙겨야 해요.

2026년 10월 21일 Node.js 보안 릴리스에 실린 Brotli 디코더 취약점(CVE-2026-111497)이 좋은 예예요. 응답 하나를 자동으로 풀었을 뿐인데 메모리가 오염될 수 있었어요. 이 글은 그 사건 요약이 아니라, 어떤 디코더 버그가 다시 나와도 쓸 수 있는 압축 해제 경로 점검법을 다뤄요. 압축 폭탄 크기는 직접 만들어 쟀고, 라이브러리 동작은 소스 코드와 로컬 실험으로 확인했어요.

광고

Accept-Encoding과 Content-Encoding 차이 — 협상은 희망, 헤더가 결정

RFC 9110(HTTP Semantics)에서 두 헤더의 역할은 분명히 나뉘어요.

헤더누가 보내나뜻받는 쪽이 하는 일
Accept-Encoding요청을 보내는 쪽받을 수 있는 압축 방식의 선호 목록서버가 참고해서 고름
Content-Encoding본문을 보내는 쪽(요청·응답 모두)이 본문에 실제로 적용된 압축, 적용 순서대로받는 쪽이 역순으로 풂

RFC 9110은 Accept-Encoding을 "선호를 나타내는 데 쓸 수 있다"고 적어요. 강제가 아니에요. 정상 서버는 목록에 맞춰 주지만, 공격자가 운영하는 서버나 중간에서 응답을 바꾼 프록시는 목록을 무시하고 아무 인코딩이나 보낼 수 있어요.

클라이언트가 Accept-Encoding으로 gzip과 br을 선호한다고 보내도, 서버가 Content-Encoding br로 응답하면 클라이언트의 디코더가 응답 헤더만 보고 br을 푸는 흐름
디코더는 내가 보낸 요청이 아니라 상대가 붙인 응답 헤더를 따라요

문제는 받는 쪽 라이브러리가 무엇을 기준으로 디코더를 고르느냐예요. urllib3(파이썬 requests의 아래층) response.py 소스를 보면, _init_decoder()가 응답의 content-encoding 값을 읽어 CONTENT_DECODERS 목록에 있으면 그 디코더를 붙여요. CONTENT_DECODERS에는 gzip·deflate가 기본으로 있고, brotli나 brotlicffi 패키지가 import되면 br이, zstd 모듈이 있으면 zstd가 붙어요. 요청에 무엇을 보냈는지는 보지 않아요.

직접 확인해 봤어요. 로컬에 Content-Encoding: br 응답을 무조건 돌려주는 테스트 서버를 띄우고, urllib3 2.8.0 + Brotli 1.2.1 환경과 Node.js 25.3.0의 fetch()로 각각 Accept-Encoding: identity(압축 받지 않음)를 보내 요청했어요. 두 클라이언트 모두 응답을 Brotli로 풀어서 넘겨줬어요. 요청 헤더로 br을 빼는 것은 방어가 되지 않는다는 뜻이에요.

압축 폭탄 크기 실측 — 1 GiB가 몇 바이트까지 줄어드나

압축 해제의 두 번째 위험은 디코더에 버그가 없어도 생겨요. 작게 보낸 본문이 풀리면서 메모리나 디스크를 다 먹는 압축 폭탄(decompression bomb)이에요. 얼마나 작아지는지 0바이트로 채운 1 GiB(1,073,741,824바이트) 파일을 macOS 기본 gzip, brotli 1.2.0, zstd 1.5.7 CLI로 압축해 쟀어요.

압축 방식압축된 크기확대 배율(해제 크기 ÷ 압축 크기)
gzip -9, 한 겹1,043,660바이트(약 1.0 MB)약 1,029배
zstd -1933,007바이트(약 32 KB)약 32,531배
brotli -q 5 -w 24809바이트약 132만 배
gzip -9, 두 겹1,844바이트약 58만 배
gzip -9, 세 겹288바이트약 373만 배
1 GiB(0바이트)를 압축한 결과 크기
gzip 한 겹gzip 한 겹 · 1,019KB1,019KBzstd -19zstd -19 · 32.2KB32.2KBgzip 두 겹gzip 두 겹 · 1.8KB1.8KBbrotli -q 5brotli -q 5 · 0.79KB0.79KBgzip 세 겹gzip 세 겹 · 0.28KB0.28KB

모두 풀면 똑같이 1 GiB예요. gzip 한 겹만 1 MB 근처이고 나머지는 수십 KB 이하예요.

단위: KB · 자료: 1,073,741,824바이트 0 채움 파일을 gzip(Apple gzip 475)·zstd 1.5.7·brotli 1.2.0 CLI로 직접 압축해 측정(2026-10-25), 1 KB = 1,024바이트
표로 보기
구분압축된 크기
gzip 한 겹1,019
zstd -1932.2
gzip 두 겹1.8
brotli -q 50.79
gzip 세 겹0.28

숫자에서 읽어야 할 것은 두 가지예요.

이런 이유로 urllib3는 2.6.0(2025-12-05)부터 연결된 인코딩 수를 5개로 제한했어요(urllib3 변경 이력). 같은 변경 이력에는 스트리밍 API의 압축 폭탄 방어가 리다이렉트를 따라갈 때(2.6.3), 공식 Brotli 라이브러리로 풀 때 두 번째 읽기에서(2.7.0) 우회되던 문제를 고친 기록도 있어요. 라이브러리가 막아 준다고 믿기보다 앱에서 한 번 더 세는 편이 안전한 이유예요.

1 GiB와 1 GB, MiB와 MB 차이가 헷갈리면 데이터 용량 변환기로 1000·1024 기준을 나란히 보세요. 한도 값을 설정 파일에 적을 때 단위가 섞이면 GiB와 GB는 7% 넘게 차이가 나요.

정상 데이터는 얼마나 줄어드나 — 한도 값을 정하는 기준

폭탄을 막으려면 "정상 본문이 풀리면 이 정도"라는 감이 있어야 한도를 정할 수 있어요. 이 사이트의 실제 파일 세 개(글 HTML 한 편, 도구 목록 JS, 해시 목록 JSON)를 같은 CLI로 압축해 비교했어요.

실제 웹 파일 압축 후 크기 (원본 대비 %)
gzip -6brotli -q 11zstd -3
010203040글 HTML 99.8KB · gzip -6 26.9%글 HTML 99.8KB · brotli -q 11 21.6%글 HTML 99.8KB · zstd -3 27.6%글 HTML 99.8KB도구 JS 234KB · gzip -6 23.6%도구 JS 234KB · brotli -q 11 19.7%도구 JS 234KB · zstd -3 24.8%도구 JS 234KB해시 JSON 118KB · gzip -6 31.8%해시 JSON 118KB · brotli -q 11 15%해시 JSON 118KB · zstd -3 23.3%해시 JSON 118KB

정상 텍스트는 3~7배 정도 줄어요. 수백 배 이상 부풀어 오르는 본문은 정상 데이터일 가능성이 낮아요.

단위: % · 자료: smith lab 저장소의 posts/…/index.html, assets/js/tools.js, assets/og/.hash.json을 직접 압축해 측정(2026-10-25)
표로 보기
구분gzip -6brotli -q 11zstd -3
글 HTML 99.8KB26.921.627.6
도구 JS 234KB23.619.724.8
해시 JSON 118KB31.81523.3

정상 JSON·HTML의 확대 배율은 3~7배 안쪽이었어요. 그래서 실무에서는 두 가지 한도를 같이 둬요.

  1. 해제 뒤 절대 크기 한도: 그 엔드포인트가 받을 수 있는 최대 본문. API 요청 본문이라면 대개 수백 KB~수 MB로 충분해요.
  2. (선택) 배율 한도: 해제 크기가 압축 크기의 100배를 넘으면 끊기 같은 규칙. 정상 텍스트가 10배를 잘 넘지 않으니 오탐이 적어요.

참고로 RFC 9659는 HTTP의 zstd 콘텐츠 코딩에서 디코더가 최대 8 MB 창(window)을 지원해야 하고, 인코더는 그보다 큰 창을 쓰지 말라고 정했어요. 해제 결과 크기 한도와는 별개로, 디코더가 미리 잡는 메모리 상한이에요.

해제 크기 한도 거는 코드 — Node.js·파이썬

한도는 반드시 압축을 푼 뒤 바이트로 세야 해요. Content-Length나 압축 본문 크기로 거는 한도는 288바이트짜리 폭탄을 그대로 통과시켜요.

Node.js: zlib 편의 함수는 maxOutputLength

Node.js zlib 문서의 maxOutputLength 옵션은 brotliDecompressSync()·gunzipSync() 같은 편의 함수의 출력 크기를 제한해요. 기본값은 buffer.kMaxLength라서 사실상 무제한이에요.

import { brotliDecompressSync, gunzipSync } from "node:zlib";

const LIMIT = 10 * 1024 * 1024; // 해제 뒤 10 MiB

export function decodeBody(buf, encoding) {
  const opts = { maxOutputLength: LIMIT };
  switch (encoding) {
    case "gzip": return gunzipSync(buf, opts);
    case "br":   return brotliDecompressSync(buf, opts);
    case undefined:
    case "identity": return buf;
    default: throw new Error(`허용하지 않는 인코딩: ${encoding}`);
  }
}

앞의 809바이트 Brotli 폭탄을 넣으면 ERR_BUFFER_TOO_LARGE(Cannot create a Buffer larger than 10485760 bytes) 에러로 멈췄어요. fetch()처럼 자동으로 푸는 API는 이 옵션을 줄 곳이 없으니, 본문을 스트림으로 읽으며 합계를 세고 한도를 넘으면 reader.cancel()로 끊어요.

const res = await fetch(url);
const reader = res.body.getReader();
let total = 0;
for (;;) {
  const { done, value } = await reader.read();
  if (done) break;
  total += value.length;               // 이미 풀린 바이트 수
  if (total > LIMIT) { await reader.cancel(); throw new Error("본문이 너무 큼"); }
}

파이썬: urllib3 스트림과 zlib max_length

urllib3·requests에서는 preload_content=False(requests는 stream=True)로 받고, 풀린 조각의 합계를 세요.

import urllib3

LIMIT = 10 * 1024 * 1024

def get_capped(url: str) -> bytes:
    resp = urllib3.request("GET", url, preload_content=False)
    body = bytearray()
    try:
        for chunk in resp.stream(64 * 1024, decode_content=True):
            body += chunk
            if len(body) > LIMIT:
                raise ValueError("해제 뒤 크기 한도 초과")
    finally:
        resp.release_conn()
    return bytes(body)

같은 Brotli 폭탄 서버에 이 함수를 돌리면 약 10.5 MB(10,551,296바이트)를 받은 시점에 멈췄어요. 표준 라이브러리 zlib로 직접 풀 때는 decompressobj().decompress(data, max_length)의 두 번째 인자로 한 번에 내놓을 최대 바이트를 정할 수 있어요. max_length를 한도+1로 주고 결과가 한도를 넘으면 거부하면 돼요.

허용 인코딩 목록 — 받지 않을 압축은 디코더 앞에서 거부

크기 한도는 "얼마나"를 막고, 허용 목록은 "무엇을"을 막아요. 디코더 버그는 한도에 닿기 전, 처음 몇 바이트에서 터질 수 있어서 이 둘은 서로 대신하지 못해요.

서버: Express·body-parser

body-parser README에 따르면 모든 파서가 gzip·br·deflate·zstd를 자동으로 풀고(inflate 기본값 true), limit 기본값은 100kb예요. 소스(lib/read.js)를 보면 압축된 요청은 Content-Length 대신 해제 스트림에 limit을 걸어서, 크기 한도는 풀린 뒤 기준으로 동작해요. 압축 요청을 받을 필요가 없는 API라면 inflate: false로 끄면 415 encoding.unsupported로 거부돼요.

필요한 인코딩만 남기려면 파서 앞에 허용 목록 미들웨어를 둬요. RFC 9110은 지원하지 않는 요청 콘텐츠 코딩에 415(Unsupported Media Type)와 함께 Accept-Encoding 헤더로 받을 수 있는 목록을 알려 주라고 권해요.

const ALLOWED = new Set(["identity", "gzip"]);

function contentEncodingAllowlist(req, res, next) {
  const raw = req.get("content-encoding");
  if (!raw) return next();
  const codings = raw.toLowerCase().split(",").map((s) => s.trim()).filter(Boolean);
  const ok = codings.length <= 1 && codings.every((c) => ALLOWED.has(c));
  if (ok) return next();
  res.set("Accept-Encoding", "gzip");
  return res.status(415).json({ error: "unsupported content-encoding" });
}

app.use(contentEncodingAllowlist);         // 반드시 본문 파서보다 먼저
app.use(express.json({ limit: "1mb" }));   // 해제 뒤 1 MB

codings.length <= 1 조건으로 gzip 여러 겹도 같이 막았어요. 정상 클라이언트가 요청 본문을 두 겹으로 압축해 보낼 일은 거의 없어요.

클라이언트: 풀기 전에 헤더 확인

외부 URL을 대신 받아 오는 크롤러·링크 미리보기·웹훅 수신기 같은 코드가 가장 위험해요. 상대 서버를 공격자가 고를 수 있기 때문이에요. urllib3에서는 decode_content=False로 받은 뒤 resp.headers.get("content-encoding")을 먼저 보고, 허용하지 않는 값이면 본문을 읽지 않고 연결을 닫아요. 지금 환경에서 urllib3가 무엇을 광고하는지는 python -c "from urllib3.util.request import ACCEPT_ENCODING; print(ACCEPT_ENCODING)"로 볼 수 있어요. 결과에 br이 있으면 Brotli 패키지가 어딘가에서 설치돼 있다는 뜻이에요.

허용 인코딩 목록과 층 수 제한, 해제 뒤 크기 한도, 디코더 패치 세 겹의 방어가 각각 막는 위협과 막지 못하는 위협을 나란히 비교한 표
한 겹씩 빈칸이 있어서 세 겹을 같이 둬요

디코더 패치 점검 — Brotli 사례로 보는 순서

세 번째 겹은 디코더 자체를 최신으로 유지하는 거예요. CVE-2026-111497에서 Node.js 보안 릴리스 공지는 잘못된 블록 전환이 영구 형식 오류가 아니라 "입력이 더 필요함"으로 보고되어, 스트리밍 디코더가 8바이트 내부 버퍼에 입력을 계속 덧붙였다고 설명해요. 수정은 google/brotli 커밋 0ed4268로 2026년 2월 27일에 들어갔지만, Brotli 1.2.1·1.1.1·1.0.10 릴리스는 10월 21일에야 나왔어요. 소스에 고쳐진 뒤 배포판·런타임에 실리기까지 몇 달이 걸릴 수 있다는 뜻이에요.

이런 공지가 나왔을 때 확인하는 순서는 디코더 종류가 달라도 같아요.

  1. 디코더가 어디에 들어 있는지 찾아요. 런타임 내장(Node.js는 process.versions.brotli로 번들 버전 확인), 언어 패키지(PyPI Brotli·brotlicffi), 시스템 라이브러리(libbrotli), 프록시 필터(Envoy 등) 네 곳이 흔해요.
  2. 누가 그 경로를 여는지 봐요. 서버는 압축 요청 본문을 푸는 파서·필터, 클라이언트는 응답을 자동으로 푸는 HTTP 라이브러리예요.
  3. 고친 버전으로 올리고 다시 빌드해요. Node.js는 22.23.4·24.22.1·26.12.1이 이번 릴리스였어요. 파이썬 Brotli 1.2.1과 brotlicffi 1.2.1.0은 PyPI Brotli 페이지에 10월 22일 올라왔어요. 컨테이너 이미지와 정적 링크 바이너리는 다시 빌드하고 프로세스를 재시작해야 반영돼요.
  4. 패치 전까지는 1·2번 겹으로 버텨요. 해당 인코딩을 허용 목록에서 빼면 취약 디코더까지 가는 길이 닫혀요.

흔한 오해 세 가지

자주 묻는 질문

Accept-Encoding을 안 보내면 서버가 압축을 안 하나요?

RFC 9110 기준으로 헤더가 없으면 어떤 코딩이든 받을 수 있다고 해석될 수 있어요. 압축을 원하지 않으면 identity를 명시하는 게 맞지만, 그래도 상대가 압축해 보내면 라이브러리는 풀어요. 결국 받는 쪽에서 허용 목록으로 걸러야 해요.

압축 폭탄 방어에 적당한 한도는 얼마인가요?

그 엔드포인트가 받는 정상 본문의 최대치에 여유를 둔 값이 기준이에요. 일반 JSON API라면 body-parser 기본값 100kb~수 MB 사이가 많아요. 정상 텍스트는 3~7배 정도만 줄어서, 해제 크기가 압축 크기의 100배를 넘는 본문을 끊는 규칙을 더해도 오탐이 적어요.

gzip만 허용하면 안전한가요?

Brotli·zstd 디코더 버그의 노출면은 사라지지만, gzip도 한 겹에 약 1,000배, 여러 겹이면 그 이상 부풀어요. 층 수를 1로 제한하고 해제 뒤 크기 한도를 같이 걸어야 해요. zlib 자체도 과거에 취약점이 있었으니 패치 관리도 그대로 필요해요.

크기 한도를 걸었으면 디코더 취약점도 막히나요?

막히지 않아요. 메모리 오염 버그는 디코더가 처음 몇 바이트를 처리하는 단계에서 일어날 수 있어서, 출력이 한도에 닿기 전에 피해가 생겨요. 그래서 허용 목록과 패치가 따로 필요해요.

서버에서 압축된 요청 본문을 꼭 받아야 하나요?

대부분의 API는 필요 없어요. 큰 로그·업로드를 압축해서 받아야 하는 엔드포인트만 허용하고, 나머지는 inflate: false처럼 꺼 두면 노출면이 크게 줄어요.

정리

참고한 자료

#Content-Encoding 보안#Accept-Encoding 차이#압축 폭탄#gzip bomb#Brotli 디코더 취약점#maxOutputLength#urllib3 압축 해제#body-parser inflate
← 이전 글깃 저장소 관리 판단표, 클론 방식·용량·브랜치 수·gc 설정 고르는 법다음 글 →한국어 LLM 토큰 비용 비교, 토크나이저별 1,000자 토큰 수와 세는 법

더 둘러보기

💻 개발자 다른 도구

🔥 많이 찾는 도구

🔮 재미로 한 번 더

📝 읽어 볼 만한 글

글 전체 보기 →