JSON 처리 보안 위험 체크리스트
핵심 요약: JSON.parse 자체는 안전합니다. 위험은 그 이후에 일어나는 일입니다. 딥 머지, 경로 할당, HTML 인라인, 무제한 깊이와 크기.

핵심부터 말하면, 두 진입점을 먼저 닫으세요. 모든 딥 머지와 경로 할당이 proto, prototype, constructor를 거부해야 하고, 요청 본문 크기와 중첩 깊이에 파싱 전에 적용되는 하드 제한이 필요합니다. JSON.parse 자체만 지켜보면 위험한 문이 크게 열려 있습니다.
공격자는 새 취약점을 발명할 필요가 없습니다. JSON에 키 하나만 더하면 됩니다.
1. 프로토타입 오염: 가장 흔하고 가장 간과되는 것
1.1 JSON.parse 자체는 안전
const data = JSON.parse('{"__proto__": {"polluted": true}}');
console.log(data.polluted); // undefined
console.log({}.polluted); // undefined
JSON.parse는 __proto__를 평범한 자체 속성으로 만들고 프로토타입 체인을 재작성하지 않습니다. 따라서 "JSON.parse가 프로토타입 오염을 일으킨다"는 주장은 정확하지 않습니다.
1.2 위험한 것은 뒤따르는 머지
/**
* Unsafe deep merge: writes the source object's keys straight into the target object.
* @param target Target object
* @param source Source object (may come from user JSON)
* @returns Merged object
*/
function unsafeMerge(target, source) {
for (const key of Object.keys(source)) {
if (typeof source[key] === 'object' && source[key] !== null) {
target[key] = target[key] ?? {};
unsafeMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
unsafeMerge({}, JSON.parse('{"__proto__": {"isAdmin": true}}'));
console.log({}.isAdmin); // true — the entire site is polluted
constructor.prototype도 같은 종류의 진입점입니다:
{ "constructor": { "prototype": { "isAdmin": true } } }
1.3 고치는 방법
/** Key names that must be rejected */
const FORBIDDEN_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
/**
* Safe deep merge: rejects prototype-related keys and handles own properties only.
* @param target Target object
* @param source Source object
* @returns New merged object
*/
function safeMerge(target, source) {
const result = { ...target };
for (const key of Object.keys(source)) {
if (FORBIDDEN_KEYS.has(key)) {
continue;
}
const sourceValue = source[key];
const isPlainObject =
typeof sourceValue === 'object' && sourceValue !== null && !Array.isArray(sourceValue);
if (isPlainObject) {
result[key] = safeMerge(target[key] ?? {}, sourceValue);
continue;
}
// Use defineProperty so a setter cannot trigger prototype mutation
Object.defineProperty(result, key, {
value: sourceValue,
enumerable: true,
writable: true,
configurable: true,
});
}
return result;
}
더 철저한 접근은 Object.create(null)을 컨테이너로 쓰거나(오염할 프로토타입 체인이 없습니다) 그냥 Map을 쓰는 것입니다.
기성 라이브러리를 쓴다면 이미 이 세 키를 처리하는지 확인하세요. 오래된 lodash merge의 많은 버전이 정확히 이 문제로 보고되었습니다.
1.4 경로 할당도 똑같이 위험
// Anti-pattern: the path comes from user input
for (const segment of pathSegments) {
current = current[segment] ?? (current[segment] = {});
}
pathSegments에 __proto__가 나타나면 위의 오염과 같습니다. JSON Pointer / JSON Patch 구현은 모든 토큰 세그먼트를 검증해야 합니다.
2. 자원 고갈 공격
| 공격 | 수법 | 완화 |
|---|---|---|
| 깊은 중첩 | 10만 단계의 [[[[...]]]] |
중첩 깊이 제한(대개 32–64), 파싱 전 사전 스캔 |
| 거대한 배열 | 수천만 요소의 배열 | 요청 본문 크기와 배열 길이 제한 |
| 극단으로 긴 문자열 | 수백 MB의 단일 문자열 | 필드 길이 제한 |
| 중복 키 폭발 | 엄청난 수의 중복 키 | 객체 키 수 제한 |
서버에서 가장 효과적인 두 방어는 요청 본문 크기 제한과 깊이 제한이며, 둘 다 파싱이 일어나기 전에 효력을 가져야 합니다.
/**
* Cheaply checks nesting depth before parsing to prevent stack overflow during parsing.
* @param text JSON text
* @param maxDepth Maximum allowed depth
* @returns Whether the depth is within the allowed range
*/
function isDepthAcceptable(text: string, maxDepth = 64): boolean {
let currentDepth = 0;
let isInsideString = false;
let isEscaped = false;
for (const character of text) {
if (isInsideString) {
if (isEscaped) {
isEscaped = false;
} else if (character === '\\') {
isEscaped = true;
} else if (character === '"') {
isInsideString = false;
}
continue;
}
if (character === '"') {
isInsideString = true;
} else if (character === '{' || character === '[') {
currentDepth += 1;
if (currentDepth > maxDepth) {
return false;
}
} else if (character === '}' || character === ']') {
currentDepth -= 1;
}
}
return true;
}
3. HTML에 인라인할 때의 주입
JSON을 페이지에 직접 쓰는 것은 흔한 관행이지만 세 부류의 문제를 끌어옵니다:
<script>
window.__DATA__ = {"title": "</script><script>alert(1)</script>"};
</script>
문자열 안의 </script>가 스크립트 블록을 조기에 닫고 나머지가 HTML으로 파싱됩니다. 고전적인 XSS입니다.
문자 두 개가 같은 방식으로 동작합니다. \u2028(줄 구분자)과 \u2029(문단 구분자)는 JSON에서 합법이지만 JS 소스에 내장하면 문법 오류를 유발합니다.
수정:
/**
* Serializes JSON safely for an inline script.
* @param value Data object
* @returns String that can be safely placed inside a script tag
*/
function serializeForInlineScript(value: unknown): string {
return JSON.stringify(value)
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026')
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029');
}
더 견고한 접근은 데이터를 <script type="application/json"> 안에 넣고(내용이 실행되지 않음) JSON.parse(document.getElementById('data').textContent)로 읽는 것입니다.
4. JSON 하이재킹과 JSONP
초기 브라우저는 교차 출처 <script src="https://api.example.com/user">가 반환된 JavaScript를 직접 실행하게 했고,
최상위가 배열인 JSON은 우연히도 유효한 JS 표현식이라, 공격자가 Array 생성자를 재정의해 데이터를 읽을 수 있었습니다.
현대 브라우저에는 이미 보호가 있지만 여전히 권장합니다:
- API 최상위에 배열이 아니라 객체를 씁니다(
{"data": [...]}). X-Content-Type-Options: nosniff를 보내고Content-Type: application/json을 엄격히 설정합니다.- JSONP 사용을 중단하고, 교차 출처 접근이 진실로 필요하면 CORS를 씁니다.
5. 객체로 역직렬화할 때의 타입 혼동
Jackson에서 기본 타입 지정이 켜지면(enableDefaultTyping), 공격자가 @class 필드를 지정해 서버가 임의 클래스를 인스턴스화하게 하고,
악용 가능한 가젯 체인과 결합해 원격 코드 실행을 달성할 수 있습니다. 이런 취약점은 여러 번 나타났습니다(Fastjson과 Jackson 모두 피해).
원칙: 역직렬화 대상 타입은 서버 코드가 정적으로 지정해야 하며, 페이로드 내용이 결정해서는 절대 안 됩니다.
6. 정규식과 스키마의 서비스 거부
- JSON Schema의
pattern과 JSONPath의match()는 모두 정규식을 받습니다. (a+)+$같은 패턴은 긴 입력에서 지수적으로 백트래킹합니다.- 정규식이 사용자에게서 온다면(사용자 정의 검증 규칙) 길이를 제한하고, 역참조와 중첩 수량자를 금지하거나 RE2 같은 선형 엔진으로 전환해야 합니다.
7. 로깅과 마스킹
JSON 로그는 민감 정보를 유출하는 가장 쉬운 길입니다:
/** Field names that must be masked in logs (matched in lowercase) */
const SENSITIVE_KEYS = new Set(['password', 'token', 'secret', 'idcard', 'phone', 'authorization']);
/**
* Recursively masks sensitive fields in an object for log output.
* @param value Original value
* @returns Masked copy
*/
function maskSensitive(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map((item) => maskSensitive(item));
}
if (typeof value === 'object' && value !== null) {
return Object.fromEntries(
Object.entries(value).map(([entryKey, entryValue]) => {
if (SENSITIVE_KEYS.has(entryKey.toLowerCase())) {
return [entryKey, '***'];
}
return [entryKey, maskSensitive(entryValue)];
}),
);
}
return value;
}
마스킹은 로그가 쓰이기 전에 일어나야 하지, 로깅 플랫폼에서 나중에 정리하는 것이 아님을 기억하세요.
8. 출시 전 체크리스트
- [ ] 모든 딥 머지 함수가
__proto__/prototype/constructor를 거부합니다 - [ ] JSON Pointer / Patch 경로 토큰이 화이트리스트로 검증됩니다
- [ ] 요청 본문 크기와 중첩 깊이에 하드 제한이 있고, 그 제한이 파싱 전에 적용됩니다
- [ ] HTML에 인라인한 JSON이
<,>,&,U+2028,U+2029를 이스케이프합니다 - [ ] API가 최상위에 객체를 쓰고 응답이
nosniff를 보냅니다 - [ ] 역직렬화 대상 타입이 코드에 정적으로 지정됩니다
- [ ] 사용자 제어 정규식에 복잡도 제한이 있습니다
- [ ] 로그 필드에 마스킹 규칙이 있고 그 규칙이 테스트됩니다
FAQ
딥 머지가 프로토타입 오염에 취약한지 어떻게 확인하나?
JSON.parse('{"__proto__": {"isAdmin": true}}')를 머지 함수에 넣고 {}.isAdmin이 true가 되는지 확인한 뒤, constructor.prototype 페이로드로 반복하세요. 서드파티 라이브러리는 __proto__, prototype, constructor를 거부하는지 확인하세요. 오래된 lodash merge의 많은 버전이 정확히 이 문제로 보고되었습니다. 경로 기반 할당은 자체 검사가 필요합니다. 경로의 __proto__ 토큰도 같은 방식으로 오염시킵니다.
JSON.parse 자체가 프로토타입 오염의 원천이 아닌 이유는?
__proto__를 평범한 자체 속성으로 만들고 프로토타입 체인을 절대 재작성하지 않으므로 data.polluted와 {}.polluted가 모두 undefined로 남습니다. 오염은 이후에 일어납니다. 안전하지 않은 딥 머지가 소스 객체의 키를 대상에 바로 쓰고, constructor.prototype도 같은 종류의 진입점입니다. JSON.parse를 근본 원인으로 보는 것은 틀린 곳을 고치는 일입니다.
HTML에 인라인한 JSON은 항상 이스케이프해야 하나?
예. 문자열 안의 </script>가 스크립트 블록을 조기에 닫고 나머지가 HTML으로 파싱되는 고전 XSS이며, <, >, &, U+2028, U+2029도 이스케이프가 필요합니다. 더 안전한 선택은 데이터를 <script type="application/json"> 안에 넣고(내용이 실행되지 않음) textContent를 JSON.parse로 읽는 것입니다.