JSON Toolkit

JSON 처리 보안 위험 체크리스트

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

중급약 12분 소요2026-10-30 발행2026-10-30 업데이트
JSON 처리 보안 위험 체크리스트 표지

핵심부터 말하면, 두 진입점을 먼저 닫으세요. 모든 딥 머지와 경로 할당이 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로 읽는 것입니다.