JSON Toolkit

چک‌لیست ریسک امنیتی برای پردازش JSON

جمع‌بندی: خود JSON.parse امن است؛ خطر در چیزی است که پس از آن رخ می‌دهد: ادغام عمیق، انتساب بر اساس مسیر، جاسازی در HTML، عمق و اندازه نامحدود.

متوسطحدود 12 دقیقه مطالعهانتشار 2026-10-30به‌روزرسانی 2026-10-30
تصویر جلد چک‌لیست ریسک امنیتی برای پردازش JSON

جمع‌بندی: اول دو نقطه ورود را ببندید — هر ادغام عمیق و انتساب مسیر باید __proto__، prototype و constructor را رد کند، و اندازه بدنه درخواست به‌علاوه عمق تودرتویی باید محدودیت سختی داشته باشد که پیش از تجزیه اعمال می‌شود. فقط پایش خود JSON.parse درهای خطرناک را کاملاً باز می‌گذارد.

مهاجم نیاز ندارد آسیب‌پذیری جدیدی اختراع کند؛ فقط یک کلید دیگر به JSON اضافه می‌کند.

۱. آلودگی prototype: رایج‌ترین و نادیده‌گرفته‌شده‌ترین

۱.۱ خود JSON.parse امن است

const data = JSON.parse('{"__proto__": {"polluted": true}}');
console.log(data.polluted);        // undefined
console.log({}.polluted);          // undefined

JSON.parse مقدار __proto__ را به‌عنوان یک ویژگی خودی معمولی می‌سازد و زنجیره prototype را بازنویسی نمی‌کند. پس ادعای «JSON.parse باعث آلودگی prototype می‌شود» نادرست است.

۱.۲ ادغامی که پس از آن می‌آید خطرناک است

/**
 * 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 } } }

۱.۳ چگونه اصلاحش کنیم

/** 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) به‌عنوان ظرف است (زنجیره prototype‌ای برای آلوده کردن وجود ندارد)، یا صرفاً یک Map. هنگام استفاده از کتابخانه آماده، تأیید کنید که از قبل این سه کلید را مدیریت می‌کند — بسیاری نسخه‌های قدیمی merge در lodash دقیقاً به همین دلیل گزارش شده‌اند.

۱.۴ انتساب بر اساس مسیر به همان اندازه خطرناک است

// Anti-pattern: the path comes from user input
for (const segment of pathSegments) {
  current = current[segment] ?? (current[segment] = {});
}

وقتی __proto__ در pathSegments ظاهر شود، این معادل آلودگی بالاست. پیاده‌سازی‌های JSON Pointer / JSON Patch باید هر بخش توکن را اعتبارسنجی کنند.

۲. حملات تمام کردن منابع

حمله تاکتیک کاهش
تودرتویی عمیق صد هزار سطح [[[[...]]]] محدود کردن عمق تودرتویی (معمولاً ۳۲ تا ۶۴) و پویش اولیه پیش از تجزیه
آرایه‌های بیش‌ازحد بزرگ آرایه‌ای با ده‌ها میلیون عنصر محدود کردن اندازه بدنه درخواست و طول آرایه
رشته‌های فوق‌العاده بلند یک رشته صدها مگابایتی محدود کردن طول فیلد
انفجار کلید تکراری تعداد عظیم کلید تکراری محدود کردن تعداد کلیدهای شیء

دو دفاع مؤثرتر روی سرور محدودیت اندازه بدنه درخواست و محدودیت عمق هستند و هر دو باید پیش از هر تجزیه‌ای اعمال شوند.

/**
 * 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;
}

۳. تزریق هنگام جاسازی در 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).

۴. JSON hijacking و 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 استفاده کنید.

۵. سردرگمی نوع هنگام deserialize به شیء

وقتی تایپ پیش‌فرض در Jackson فعال شود (enableDefaultTyping)، مهاجم می‌تواند فیلد @class را مشخص کند تا سرور کلاس دلخواهی را نمونه‌سازی کند، و آن را با زنجیره gadget بهره‌پذیر ترکیب کند تا اجرای کد از راه دور به دست آورد. آسیب‌پذیری‌هایی از این نوع بارها ظاهر شده‌اند (هم Fastjson و هم Jackson هدف بوده‌اند).

اصل: نوع هدف deserialization باید به‌صورت ایستا توسط کد سرور مشخص شود و هرگز توسط محتوای payload تعیین نشود.

۶. منع سرویس در عبارت‌های باقاعده و Schemaها

  • pattern در JSON Schema و match() در JSONPath هر دو عبارت باقاعده می‌پذیرند؛
  • الگوهایی مانند (a+)+$ روی ورودی بلند به‌صورت نمایی backtrack می‌کنند؛
  • اگر regex از کاربران می‌آید (قواعد اعتبارسنجی سفارشی)، باید طولش را محدود کنید، backreferenceها و quantifierهای تودرتو را ممنوع کنید، یا به موتور خطی مانند RE2 بروید.

۷. لاگ و ماسک کردن

لاگ‌های 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;
}

توجه کنید ماسک کردن باید پیش از نوشتن لاگ رخ دهد، نه با پاک‌سازی بعدی در پلتفرم لاگ.

۸. چک‌لیست پیش از انتشار

  • [ ] همه توابع ادغام عمیق __proto__ / prototype / constructor را رد می‌کنند؛
  • [ ] توکن‌های مسیر JSON Pointer / Patch در برابر فهرست سفید اعتبارسنجی می‌شوند؛
  • [ ] اندازه بدنه درخواست و عمق تودرتویی محدودیت سخت دارند و آن محدودیت‌ها پیش از تجزیه اعمال می‌شوند؛
  • [ ] JSON جاسازی‌شده در HTML <، >، &، U+2028 و U+2029 را escape می‌کند؛
  • [ ] APIها در سطح بالا از شیء استفاده می‌کنند و پاسخ‌ها nosniff می‌فرستند؛
  • [ ] نوع‌های هدف deserialization به‌صورت ایستا در کد مشخص‌اند؛
  • [ ] عبارت‌های باقاعده تحت کنترل کاربر محدودیت پیچیدگی دارند؛
  • [ ] فیلدهای لاگ قواعد ماسک دارند و آن قواعد آزمون شده‌اند.

پرسش‌های متداول

چگونه بررسی کنم آیا ادغام عمیقم در برابر آلودگی prototype آسیب‌پذیر است؟

JSON.parse('{"__proto__": {"isAdmin": true}}') را به تابع ادغام بدهید، سپس بررسی کنید آیا {}.isAdmin برابر true شد؛ با payload constructor.prototype تکرار کنید. با کتابخانه شخص ثالث، تأیید کنید که __proto__، prototype و constructor را رد می‌کند — بسیاری نسخه‌های قدیمی merge در lodash دقیقاً به همین دلیل گزارش شده‌اند. انتساب مسیرمحور به بررسی خودش نیاز دارد، چون توکن __proto__ در مسیر به همان شکل آلوده می‌کند.

چرا خود JSON.parse منبع آلودگی prototype نیست؟

__proto__ را به‌عنوان ویژگی خودی معمولی می‌سازد و هرگز زنجیره prototype را بازنویسی نمی‌کند، پس هم data.polluted و هم {}.polluted مقدار undefined می‌مانند. آلودگی بعداً رخ می‌دهد: یک ادغام عمیق ناامن کلیدهای شیء منبع را مستقیم در هدف می‌نویسد و constructor.prototype نقطه ورودی از همان نوع است. تلقی کردن JSON.parse به‌عنوان ریشه یعنی اصلاح جای اشتباه.

آیا JSON جاسازی‌شده در HTML باید همیشه escape شود؟

بله. </script> داخل رشته بلوک اسکریپت را زودتر می‌بندد و بقیه به‌عنوان HTML تجزیه می‌شود — یک XSS کلاسیک؛ <، >، &، U+2028 و U+2029 هم escape لازم دارند. گزینه امن‌تر گذاشتن داده داخل <script type="application/json"> است که محتوایش اجرا نمی‌شود، و خواندنش با JSON.parse روی textContent.