טיפול בטוח ב-DOM באמצעות Sanitizer API

המטרה של Sanitizer API החדש היא ליצור מעבד חזק שאפשר להשתמש בו כדי להוסיף מחרוזות שרירותיות לדף בצורה בטוחה.

Jack J
Jack J

אפליקציות מטפלות במחרוזות לא מהימנות כל הזמן, אבל יכול להיות מאתגר להציג את התוכן הזה בצורה בטוחה כחלק ממסמך HTML. בלי תשומת לב מספקת, קל ליצור בטעות הזדמנויות לפריצה מסוג XSS (cross-site scripting) שאפשר לתקוף באמצעותן.

כדי לצמצם את הסיכון הזה, מטרת ההצעה החדשה ל-Sanitizer API היא ליצור מעבד חזק שאפשר להשתמש בו כדי להוסיף מחרוזות שרירותיות לדף בצורה בטוחה. במאמר הזה נסביר מהו ה-API ואיך משתמשים בו.

// Expanded Safely !!
$div.setHTML(`<em>hello world</em><img src="" onerror=alert(0)>`, new Sanitizer())

בריחה מקלט של משתמשים

כשמוסיפים ל-DOM קלט של משתמש, מחרוזות של שאילתות, תוכן של קובצי cookie וכו', צריך להשתמש בהימלטות (escape) נכונה של המחרוזות. חשוב להקפיד במיוחד על מניעת מניפולציה של DOM דרך .innerHTML, מאחר שמחרוזות ללא תווי בריחה הן מקור אופייני ל-XSS.

const user_input = `<em>hello world</em><img src="" onerror=alert(0)>`
$div.innerHTML = user_input

אם תשתמשו בתו בריחה (escape) כדי לסמן תווים מיוחדים של HTML במחרוזת הקלט שלמעלה או תרחיבו אותה באמצעות .textContent, הפונקציה alert(0) לא תבוצע. עם זאת, מכיוון ש-<em> שנוסף על ידי המשתמש מתרחב גם כמחרוזת כפי שהוא, אי אפשר להשתמש בשיטה הזו כדי לשמור על עיצוב הטקסט ב-HTML.

הדבר הטוב ביותר לעשות כאן הוא לא בריחה, אלא ניקוי.

טיהור הקלט של המשתמשים

ההבדל בין בריחה (escaping) לטיהור (sanitizing)

תו בריחה (escape) הוא תו שמחליף תווים מיוחדים ב-HTML בישויות HTML.

'ניקוי' מתייחס להסרת חלקים שעשויים להזיק מבחינה סמנטית (כמו הפעלת סקריפט) ממחרוזות HTML.

דוגמה

בדוגמה הקודמת, <img onerror> גורם להפעלה של בורר השגיאות, אבל אם בורר השגיאות onerror יוסר, אפשר יהיה להרחיב אותו ב-DOM בבטחה בלי לפגוע ב-<em>.

// XSS 🧨
$div.innerHTML = `<em>hello world</em><img src="" onerror=alert(0)>`
// Sanitized ⛑
$div.innerHTML = `<em>hello world</em><img src="">`

כדי לבצע ניקוי בצורה נכונה, צריך לנתח את מחרוזת הקלט כ-HTML, להשמיט תגים ומאפיינים שנחשבים מזיקים ולשמור את אלה שאינם מזיקים.

הצעה למפרט של Sanitizer API נועדה לספק עיבוד כזה כ-API רגיל לדפדפנים.

Sanitizer API

משתמשים ב-Sanitizer API באופן הבא:

const $div = document.querySelector('div')
const user_input = `<em>hello world</em><img src="" onerror=alert(0)>`
$div.setHTML(user_input, { sanitizer: new Sanitizer() }) // <div><em>hello world</em><img src=""></div>

עם זאת, { sanitizer: new Sanitizer() } הוא הארגומנט שמוגדר כברירת מחדל. למשל:

$div.setHTML(user_input) // <div><em>hello world</em><img src=""></div>

חשוב לציין ש-setHTML() מוגדר ב-Element. מכיוון שזוהי שיטה של Element, ההקשר לניתוח מובן מאליו (<div> במקרה הזה), הניתוח מתבצע פעם אחת באופן פנימי והתוצאה מורחבת ישירות ל-DOM.

כדי לקבל את התוצאה של הניקוי כמחרוזת, אפשר להשתמש ב-.innerHTML מהתוצאות של setHTML().

const $div = document.createElement('div')
$div.setHTML(user_input)
$div.innerHTML // <em>hello world</em><img src="">

התאמה אישית באמצעות הגדרה

כברירת מחדל, Sanitizer API מוגדר להסיר מחרוזות שעלולות להפעיל את ביצוע הסקריפט. עם זאת, אפשר גם להוסיף התאמות אישיות משלכם לתהליך הסינון באמצעות אובייקט תצורה.

const config = {
  allowElements: [],
  blockElements: [],
  dropElements: [],
  allowAttributes: {},
  dropAttributes: {},
  allowCustomElements: true,
  allowComments: true
};
// sanitized result is customized by configuration
new Sanitizer(config)

האפשרויות הבאות קובעות איך תוצאת הסינון צריכה לטפל ברכיב שצוין.

allowElements: שמות של רכיבים שהכלי לניקוי צריך לשמור.

blockElements: שמות של רכיבים שהכלי לניקוי צריך להסיר, תוך שמירה על הצאצאים שלהם.

dropElements: שמות הרכיבים שהכלי לניקוי צריך להסיר, יחד עם הצאצאים שלהם.

const str = `hello <b><i>world</i></b>`

$div.setHTML(str)
// <div>hello <b><i>world</i></b></div>

$div.setHTML(str, { sanitizer: new Sanitizer({allowElements: [ "b" ]}) })
// <div>hello <b>world</b></div>

$div.setHTML(str, { sanitizer: new Sanitizer({blockElements: [ "b" ]}) })
// <div>hello <i>world</i></div>

$div.setHTML(str, { sanitizer: new Sanitizer({allowElements: []}) })
// <div>hello world</div>

אפשר גם לקבוע אם הכלי לניקוי נתונים יאפשר או ידחה מאפיינים ספציפיים באמצעות האפשרויות הבאות:

  • allowAttributes
  • dropAttributes

המאפיינים allowAttributes ו-dropAttributes מצפים לרשימות התאמה של מאפיינים – אובייקטים שהמפתחות שלהם הם שמות המאפיינים והערכים שלהם הם רשימות של רכיבי יעד או תו הכללי *.

const str = `<span id=foo class=bar style="color: red">hello</span>`

$div.setHTML(str)
// <div><span id="foo" class="bar" style="color: red">hello</span></div>

$div.setHTML(str, { sanitizer: new Sanitizer({allowAttributes: {"style": ["span"]}}) })
// <div><span style="color: red">hello</span></div>

$div.setHTML(str, { sanitizer: new Sanitizer({allowAttributes: {"style": ["p"]}}) })
// <div><span>hello</span></div>

$div.setHTML(str, { sanitizer: new Sanitizer({allowAttributes: {"style": ["*"]}}) })
// <div><span style="color: red">hello</span></div>

$div.setHTML(str, { sanitizer: new Sanitizer({dropAttributes: {"id": ["span"]}}) })
// <div><span class="bar" style="color: red">hello</span></div>

$div.setHTML(str, { sanitizer: new Sanitizer({allowAttributes: {}}) })
// <div>hello</div>

allowCustomElements היא האפשרות לאשר או לדחות רכיבים מותאמים אישית. אם הן מותרות, עדיין חלות הגדרות אחרות של רכיבים ומאפיינים.

const str = `<custom-elem>hello</custom-elem>`

$div.setHTML(str)
// <div></div>

const sanitizer = new Sanitizer({
  allowCustomElements: true,
  allowElements: ["div", "custom-elem"]
})
$div.setHTML(str, { sanitizer })
// <div><custom-elem>hello</custom-elem></div>

ממשק API

השוואה ל-DomPurify

DOMPurify היא ספרייה ידועה שמציעה פונקציונליות של סניטיזציה. ההבדל העיקרי בין Sanitizer API לבין DOMPurify הוא ש-DOMPurify מחזיר את תוצאת הניקוי כמחרוזת, שצריך לכתוב אותה ברכיב DOM באמצעות .innerHTML.

const user_input = `<em>hello world</em><img src="" onerror=alert(0)>`
const sanitized = DOMPurify.sanitize(user_input)
$div.innerHTML = sanitized
// `<em>hello world</em><img src="">`

DOMPurify יכול לשמש כחלופה במקרה ש-Sanitizer API לא מוטמע בדפדפן.

ליישום של DOMPurify יש כמה חסרונות. אם מחרוזת מוחזרת, מחרוזת הקלט נבדקת פעמיים, על ידי DOMPurify ועל ידי .innerHTML. הניתוח הכפול הזה מבזבז זמן עיבוד, אבל הוא גם עלול להוביל לנקודות חולשה מעניינות שנגרמות במקרים שבהם התוצאה של הניתוח השני שונה מהתוצאה של הניתוח הראשון.

כדי לנתח HTML, נדרש גם הקשר. לדוגמה, <td> הגיוני ב-<table>, אבל לא ב-<div>. מכיוון ש-DOMPurify.sanitize() מקבל רק מחרוזת כארגומנט, היה צריך לנחש את הקשר הניתוח.

Sanitizer API משפר את הגישה של DOMPurify, והוא נועד למנוע את הצורך בניתוח כפול ולהבהיר את הקשר של הניתוח.

סטטוס ה-API ותמיכה בדפדפנים

Sanitizer API נמצא בבדיקה בתהליך התקינה, ואנחנו בתהליך הטמעה שלו ב-Chrome.

שלב סטטוס
1. יצירת הסבר הושלם
2. יצירת טיוטה של מפרט הושלם
3. איסוף משוב וביצוע שינויים בעיצוב הושלם
4. גרסת מקור לניסיון ב-Chrome הושלם
5. הפעלה כוונת שליחה ב-M105

Mozilla: לדעת החברה, ההצעה הזו שווה פיתוח אב טיפוס, והיא מפעילה אותה באופן פעיל.

WebKit: התשובה מופיעה ברשימת התפוצה של WebKit.

איך מפעילים את Sanitizer API

הפעלה באמצעות about://flags או אפשרות CLI

Chrome

אנחנו בתהליך הטמעת Sanitizer API ב-Chrome. ב-Chrome 93 ואילך, אפשר לנסות את ההתנהגות הזו על ידי הפעלת הדגל about://flags/#enable-experimental-web-platform-features. בגרסאות קודמות של Chrome Canary ושל ערוץ הפיתוח, אפשר להפעיל את התכונה באמצעות --enable-blink-features=SanitizerAPI ולנסות אותה כבר עכשיו. איך מריצים את Chrome עם דגלים

Firefox

ב-Firefox, Sanitizer API מוטמע גם כתכונה ניסיונית. כדי להפעיל אותה, מגדירים את הדגל dom.security.sanitizer.enabled לערך true בקובץ about:config.

זיהוי תכונות

if (window.Sanitizer) {
  // Sanitizer API is enabled
}

משוב

אם תנסו את ה-API הזה ותהיה לכם משוב, נשמח לשמוע אותו. אתם יכולים לשתף את דעתכם על בעיות ב-GitHub של Sanitizer API ולנהל דיון עם מחברי המפרט ועם אנשים שמתעניינים ב-API הזה.

אם נתקלתם באגים או בהתנהגות בלתי צפויה בהטמעה של Chrome, דווחו על כך. בוחרים את הרכיבים של Blink>SecurityFeature>SanitizerAPI ומשתפים את הפרטים כדי לעזור למטמיעים לעקוב אחרי הבעיה.

הדגמה (דמו)

כדי לראות את Sanitizer API בפעולה, אפשר להיכנס למגרש המשחקים של Sanitizer API של Mike West:

קובצי עזר


צילום: Towfiqu barbhuiya ב-Unsplash.