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을 "선호를 나타내는 데 쓸 수 있다"고 적어요. 강제가 아니에요. 정상 서버는 목록에 맞춰 주지만, 공격자가 운영하는 서버나 중간에서 응답을 바꾼 프록시는 목록을 무시하고 아무 인코딩이나 보낼 수 있어요.
문제는 받는 쪽 라이브러리가 무엇을 기준으로 디코더를 고르느냐예요. 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 -19 | 33,007바이트(약 32 KB) | 약 32,531배 |
| brotli -q 5 -w 24 | 809바이트 | 약 132만 배 |
| gzip -9, 두 겹 | 1,844바이트 | 약 58만 배 |
| gzip -9, 세 겹 | 288바이트 | 약 373만 배 |
숫자에서 읽어야 할 것은 두 가지예요.
- deflate(gzip) 한 겹의 배율은 약 1,000배에서 멈춰요. deflate는 한 번에 최대 258바이트 반복을 짧은 부호로 줄이는 구조라서 0만 있어도 더 줄지 않아요. 그런데 gzip을 다시 gzip하면 1 MB짜리 압축 결과 자체가 또 반복투성이라 1,844바이트로, 세 겹이면 288바이트로 줄어요.
Content-Encoding: gzip, gzip, gzip처럼 여러 겹을 허용하는 디코더는 배율이 곱해져요. - Brotli와 zstd는 한 겹만으로도 훨씬 커요. 반복 길이를 길게 표현할 수 있어서 Brotli는 809바이트로 1 GiB를 만들었어요.
이런 이유로 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로 압축해 비교했어요.
정상 JSON·HTML의 확대 배율은 3~7배 안쪽이었어요. 그래서 실무에서는 두 가지 한도를 같이 둬요.
- 해제 뒤 절대 크기 한도: 그 엔드포인트가 받을 수 있는 최대 본문. API 요청 본문이라면 대개 수백 KB~수 MB로 충분해요.
- (선택) 배율 한도: 해제 크기가 압축 크기의 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일에야 나왔어요. 소스에 고쳐진 뒤 배포판·런타임에 실리기까지 몇 달이 걸릴 수 있다는 뜻이에요.
이런 공지가 나왔을 때 확인하는 순서는 디코더 종류가 달라도 같아요.
- 디코더가 어디에 들어 있는지 찾아요. 런타임 내장(Node.js는
process.versions.brotli로 번들 버전 확인), 언어 패키지(PyPIBrotli·brotlicffi), 시스템 라이브러리(libbrotli), 프록시 필터(Envoy 등) 네 곳이 흔해요. - 누가 그 경로를 여는지 봐요. 서버는 압축 요청 본문을 푸는 파서·필터, 클라이언트는 응답을 자동으로 푸는 HTTP 라이브러리예요.
- 고친 버전으로 올리고 다시 빌드해요. Node.js는 22.23.4·24.22.1·26.12.1이 이번 릴리스였어요. 파이썬
Brotli1.2.1과brotlicffi1.2.1.0은 PyPI Brotli 페이지에 10월 22일 올라왔어요. 컨테이너 이미지와 정적 링크 바이너리는 다시 빌드하고 프로세스를 재시작해야 반영돼요. - 패치 전까지는 1·2번 겹으로 버텨요. 해당 인코딩을 허용 목록에서 빼면 취약 디코더까지 가는 길이 닫혀요.
흔한 오해 세 가지
- "압축 본문 크기로 한도를 걸었다." 288바이트가 1 GiB가 돼요. 한도는 해제 뒤 크기로 걸어요.
- "Accept-Encoding에서 br을 뺐다." 앞의 실험처럼 응답 헤더가 디코더를 정해요. 빼려면 디코더 쪽에서 거부해야 해요.
- "응답이 깨져 보이면 인코딩 문제가 아니다." 압축 해제가 빠지거나 두 번 되면 본문이 이진 쓰레기로 보이거나 JSON 파싱이 실패해요. 응답 헤더부터 보는 습관은 API 응답 디버깅 6단계에 정리해 두었어요.
자주 묻는 질문
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처럼 꺼 두면 노출면이 크게 줄어요.
정리
- 디코더는 내가 보낸
Accept-Encoding이 아니라 상대가 붙인Content-Encoding을 따라요. urllib3와 Node.jsfetch()모두identity를 요청해도 br 응답을 풀었어요. - 1 GiB 압축 폭탄은 gzip 한 겹 1 MB, Brotli 809바이트, gzip 세 겹 288바이트였어요. 한도는 반드시 해제 뒤 바이트로 세요(
maxOutputLength, 스트림 합계, body-parserlimit). - 받을 필요 없는 인코딩과 여러 겹 인코딩은 파서 앞에서 415로 거부해요. 크기 한도로는 디코더 메모리 버그를 못 막아요.
- 디코더는 런타임·패키지·시스템 라이브러리·프록시 네 곳에 숨어 있어요. 공지가 나오면 올리고, 다시 빌드하고, 재시작까지 해요. 한도 값의 단위는 데이터 용량 변환기로 MB·MiB를 확인해 두세요.