צ'קליסט סיכוני אבטחה לעיבוד JSON
שורה תחתונה: JSON.parse עצמו בטוח; הסכנה היא מה שקורה אחריו: מיזוג עמוק, השמה לפי נתיב, הטמעה ב-HTML, עומק וגודל בלתי מוגבלים.

שורה תחתונה: סגרו קודם את שתי נקודות הכניסה — כל מיזוג עמוק וכל השמה לפי נתיב חייבים לדחות proto, prototype ו-constructor, וגודל גוף הבקשה ועומק הקינון צריכים מגבלות קשיחות שפועלות לפני הניתוח. מעקב אחרי JSON.parse עצמו משאיר את הדלתות המסוכנות פתוחות לרווחה.
תוקפים לא צריכים להמציא פגיעות חדשה; הם רק מוסיפים עוד מפתח אחד ל-JSON.
1. זיהום prototype: הנפוץ והמתעלמים ממנו ביותר
1.1 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" אינה מדויקת.
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']);
/**
* מיזוג עמוק בטוח: דוחה מפתחות הקשורים ל-prototype ומטפל רק במאפיינים עצמיים.
* @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 כדי שסטר לא יוכל להפעיל שינוי prototype
Object.defineProperty(result, key, {
value: sourceValue,
enumerable: true,
writable: true,
configurable: true,
});
}
return result;
}
גישה יסודית יותר היא להשתמש ב-Object.create(null) כמכל (אין שרשרת prototype לזהם), או פשוט ב-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) וסריקה מקדימה לפני הניתוח |
| מערכים ענקיים | מערך עם עשרות מיליוני elements | הגבלת גודל גוף בקשה ואורך מערך |
| מחרוזות ארוכות מאוד | מחרוזת אחת של מאות MB | הגבלת אורך שדה |
| פיצוץ מפתחות כפולים | מספר עצום של מפתחות כפולים | הגבלת מספר המפתחות באובייקט |
שתי ההגנות היעילות ביותר בשרת הן הגבלת גודל גוף הבקשה והגבלת עומק, ושתיהן חייבות להיכנס לתוקף לפני שמתרחש ניתוח כלשהו.
/**
* בדיקה זולה של עומק הקינון לפני הניתוח כדי למנוע הצפת מחסנית בזמן הניתוח.
* @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 מחרוזת שאפשר להניח בבטחה בתוך תג script
*/
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 נפגעו).
עקרון: טיפוס היעד של הדה-סריאליזציה חייב להיות מוגדר סטטית על ידי קוד השרת ולעולם לא להיקבע מתוכן ה-payload.
6. Denial of Service בביטויים רגולריים ובסכימות
- ה-
patternשל JSON Schema וה-match()של JSONPath מקבלים שניהם ביטויים רגולריים; - דפוסים כמו
(a+)+$מבצעים backtracking אקספוננציאלי על קלטים ארוכים; - אם ה-regex מגיע ממשתמשים (כללי אימות מותאמים), חייבים להגביל את אורכו, לאסור backreferences ו-quantifiers מקוננים, או לעבור למנוע ליניארי כמו 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 עובר escaping ל-
<,>,&,U+2028ו-U+2029; - [ ] ה-APIs משתמשים באובייקט ברמה העליונה והתשובות שולחות
nosniff; - [ ] טיפוסי היעד של הדה-סריאליזציה מוגדרים סטטית בקוד;
- [ ] לביטויים רגולריים בשליטת משתמש יש מגבלות מורכבות;
- [ ] לשדות הלוג יש כללי מיסוך, והכללים נבדקים בבדיקות.
FAQ
איך בודקים אם מיזוג עמוק שלי פגיע לזיהום prototype?
הזינו JSON.parse('{"__proto__": {"isAdmin": true}}') לפונקציית המיזוג, ואז בדקו אם {}.isAdmin הפך ל-true; חזרו על הבדיקה עם payload של constructor.prototype. עם ספריית צד שלישי, ודאו שהיא דוחה __proto__, prototype ו-constructor — גרסאות רבות וישנות של merge ב-lodash דווחו בדיוק על כך. השמה מבוססת נתיב צריכה בדיקה משלה, כי token של __proto__ בנתיב מזהם באותו אופן.
מדוע JSON.parse עצמו אינו מקור זיהום ה-prototype?
הוא יוצר את __proto__ כמאפיין עצמי רגיל ולעולם לא משכתב את שרשרת ה-prototype, ולכן גם data.polluted וגם {}.polluted נשארים undefined. הזיהום קורה אחר כך: מיזוג עמוק לא בטוח כותב את מפתחות אובייקט המקור ישירות ליעד, ו-constructor.prototype הוא נקודת כניסה מאותו סוג. להתייחס ל-JSON.parse כשורש הבעיה פירושו לתקן את המקום הלא נכון.
האם JSON שמוטמע ב-HTML צריך תמיד לעבור escaping?
כן. </script> בתוך מחרוזת סוגר את בלוק הסקריפט מוקדם והשאר נפרס כ-HTML — XSS קלאסי; גם <, >, &, U+2028 ו-U+2029 צריכים escaping. אפשרות בטוחה יותר היא להניח את הנתונים בתוך <script type="application/json">, שתוכנו אינו מבוצע, ולקרוא אותם עם JSON.parse על textContent.