قائمة فحص المخاطر الأمنية لمعالجة 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 الدمج الذي يليه هو الخطير
/**
* دمج عميق غير آمن: يكتب مفاتيح كائن المصدر مباشرة في الكائن الهدف.
* @param target الكائن الهدف
* @param source الكائن المصدر (قد يأتي من JSON المستخدم)
* @returns الكائن المدموج
*/
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 — الموقع كله ملوّث
constructor.prototype نقطة دخول أخرى من النوع نفسه:
{ "constructor": { "prototype": { "isAdmin": true } } }
1.3 كيفية الإصلاح
/** أسماء المفاتيح التي يجب رفضها */
const FORBIDDEN_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
/**
* دمج عميق آمن: يرفض المفاتيح المرتبطة بالنموذج الأولي ويتعامل مع الخصائص المملوكة فقط.
* @param target الكائن الهدف
* @param source الكائن المصدر
* @returns كائن مدموج جديد
*/
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;
}
// استخدم defineProperty حتى لا يستطيع setter إطلاق تحوّل في النموذج الأولي
Object.defineProperty(result, key, {
value: sourceValue,
enumerable: true,
writable: true,
configurable: true,
});
}
return result;
}
النهج الأكثر شمولاً هو استخدام Object.create(null) كحاوية (لا توجد سلسلة نموذج أولي لتلويثها)، أو ببساطة Map.
عند استخدام مكتبة جاهزة، تأكد من أنها تتعامل بالأصل مع هذه المفاتيح الثلاثة — فقد أُبلغ عن إصدارات قديمة كثيرة من merge في lodash بسبب هذا بالضبط.
1.4 الإسناد عبر المسار خطير بالقدر نفسه
// نمط مرفوض: المسار يأتي من إدخال المستخدم
for (const segment of pathSegments) {
current = current[segment] ?? (current[segment] = {});
}
عندما يظهر __proto__ في pathSegments، فهذا يعادل التلوث أعلاه. يجب أن تتحقق تطبيقات JSON Pointer / JSON Patch من كل مقطع token.
2. هجمات استنزاف الموارد
| الهجوم | التكتيك | التخفيف |
|---|---|---|
| تداخل عميق | مئة ألف مستوى من [[[[...]]]] |
حدّ عمق التداخل (عادةً 32–64) وامسح مسبقاً قبل التحليل |
| مصفوفات ضخمة | مصفوفة بعشرات ملايين العناصر | حدّ حجم جسم الطلب وطول المصفوفة |
| نصوص طويلة جداً | نص واحد بمئات الميغابايت | حدّ طول الحقل |
| انفجار المفاتيح المكررة | عدد ضخم من المفاتيح المكررة | حدّ عدد مفاتيح الكائن |
أكثر دفاعين فعّالية على الخادم هما حدود حجم جسم الطلب وحدود العمق، ويجب أن يسري كلاهما قبل حدوث أي تحليل.
/**
* يفحص عمق التداخل بتكلفة منخفضة قبل التحليل لمنع تجاوز المكدس أثناء التحليل.
* @param text نص JSON
* @param maxDepth أقصى عمق مسموح
* @returns ما إذا كان العمق ضمن النطاق المسموح
*/
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 المصدري يطلق خطأ صياغة.
الإصلاح:
/**
* يسلسل JSON بأمان لسكربت مضمّن.
* @param value كائن البيانات
* @returns نص يمكن وضع علامة سكربت حوله بأمان
*/
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 ليجعل الخادم ينسخ صنفاً اعتباطياً،
ويجمع ذلك مع سلسلة gadget قابلة للاستغلال لتحقيق تنفيذ كود عن بعد. ظهرت ثغرات من هذا النوع مراراً (أصيبت كل من Fastjson وJackson).
المبدأ: يجب أن يحدد كود الخادم نوع الهدف في إلغاء التسلسل بشكل ثابت، ويجب أبداً أن يحدده محتوى الحمولة.
6. حجب الخدمة في التعبيرات النمطية وSchemas
patternفي JSON Schema وmatch()في JSONPath كلاهما يقبل تعبيرات نمطية؛- أنماط مثل
(a+)+$تتراجع أُسّياً على مدخلات طويلة؛ - إذا كان regex يأتي من المستخدمين (قواعد تحقق مخصصة)، فيجب أن تحدّ من طوله، وتمنع المراجع الخلفية والكميات المتداخلة، أو تنتقل إلى محرك خطي مثل RE2.
7. السجلات والإخفاء
سجلات JSON أسهل طريقة لتسريب معلومات حساسة:
/** أسماء الحقول التي يجب إخفاؤها في السجلات (تُطابَق بأحرف صغيرة) */
const SENSITIVE_KEYS = new Set(['password', 'token', 'secret', 'idcard', 'phone', 'authorization']);
/**
* يخفي الحقول الحساسة في كائن بشكل عودي لخرج السجل.
* @param value القيمة الأصلية
* @returns نسخة مخفية
*/
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؛ - [ ] يتم التحقق من tokens مسارات JSON Pointer / Patch مقابل قائمة بيضاء؛
- [ ] حجم جسم الطلب وعمق التداخل لهما حدود صلبة، وتلك الحدود تسري قبل التحليل؛
- [ ] JSON المضمّن في HTML يهرّب
<و>و&وU+2028وU+2029؛ - [ ] APIs تستخدم كائناً في المستوى الأعلى والاستجابات ترسل
nosniff؛ - [ ] أنواع هدف إلغاء التسلسل محددة ثابتاً في الكود؛
- [ ] التعبيرات النمطية التي يتحكم بها المستخدم لها حدود تعقيد؛
- [ ] حقول السجلات لها قواعد إخفاء وتلك القواعد مختبرة.
FAQ
كيف أتحقق مما إذا كان دمجي العميق عرضة لتلوث النموذج الأولي؟
مرّر JSON.parse('{"__proto__": {"isAdmin": true}}') إلى دالة الدمج، ثم تحقق مما إذا كان {}.isAdmin قد أصبح true؛ وكرر مع حمولة constructor.prototype. مع مكتبة طرف ثالث، تأكد من أنها ترفض __proto__ وprototype وconstructor — فقد أُبلغ عن إصدارات قديمة كثيرة من merge في lodash بسبب هذا بالضبط. الإسناد القائم على المسار يحتاج فحصاً خاصاً به، لأن token __proto__ في المسار يلوّث بالطريقة نفسها.
لماذا لا يكون JSON.parse نفسه مصدر تلوث النموذج الأولي؟
لأنه ينشئ __proto__ كخاصية مملوكة عادية ولا يعيد كتابة سلسلة النموذج الأولي أبداً، لذا يبقى كل من data.polluted و{}.polluted مساوياً undefined. يحدث التلوث لاحقاً: دمج عميق غير آمن يكتب مفاتيح كائن المصدر مباشرة في الهدف، وconstructor.prototype نقطة دخول من النوع نفسه. التعامل مع JSON.parse كسبب جذري يعني إصلاح المكان الخطأ.
هل يجب دائمًا تهريب JSON المضمّن في HTML؟
نعم. </script> داخل نص يغلق كتلة السكربت مبكراً ويُحلّل الباقي كـHTML — وهو XSS كلاسيكي؛ ويحتاج < و> و& وU+2028 وU+2029 إلى هروب أيضاً. الخيار الأكثر أماناً هو وضع البيانات داخل <script type="application/json">، الذي لا يُنفّذ محتواه، وقراءته بـJSON.parse على textContent.