סניטציה – למה זה חשוב

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

אחרי המאמר הקודם על Trusted Types שבו כתבתי על סניטציה כבדרך אגב, קיבלתי תגובה וגם מייל (ואפשר תמיד לשלוח לי מייל ל [email protected]) שמבקשים ממני לכתוב על סניטציה ומה זה בכלל. אז הנה, הסבר פשוט ובהיר על סניטציה ולמה צריך את זה – שימושי למפתחי ווב שפשוט לא מכירים את זה. אני משתמש בו במגוון שפות: פייתון, PHP וג׳אווהסקריפט כי סניטציה זה לא עניין של שפה אלא של קונספט תכנותי שניתן לממש בכל שפה שהיא. כתבתי בכוונה בשפה פשוטה כדי להסביר ואז צללתי קצת יותר עמוק לשימוש כחלק מ-Trusted Type ב-CSP. אם אתם סטודנטים/מפתחים בתחילת דרכם, החלקים הראשונים יהיו יותר רלוונטיים עבורכם ואם אתם יותר מנוסים, החלק האחרון יהיה יותר רלוונטי.

אז בואו נתחיל:

ולידציה מול סניטציה

בלבול שתמיד קורה הוא בין ולידציה לסניטציה. ולידציה הוא לוודא שהקלט שאני מקבל מהמשתמש הוא באמת הקלט שאני צופה לקבל. אולי אני צריך לכתוב מאמר שלם על זה אבל בגדול – אם אני מצפה למשל לקבל מהמשתמש מספר, לוודא שמדובר במספר. אם אני מצפה לקבל מייל? אז לוודא שמדובר במייל. במידה ואני לא מקבל את הקלט הזה, תהליך הולידציה מחזיר שגיאה. ולידציה זה לפני שאני מתחיל לטפל בקלט והתוצאה שלו היא או הצלחה או כשלון. אם למשל אני מצפה לקלט של מספר והמשתמש שולח לי רווחים ואותיות, אני לא רוצה לנסות לנקות את הפלט שלו או לנחש למה הוא מתכוון. אני פשוט אומר שהקלט הזה שגוי ואזרוק שגיאה למשתמש (או אדווח על זה ללוג האבטחה).

סניטציה

סניטציה זה משהו אחר לגמרי. בתהליך סניטציה אני מטפל בפלט – בד״כ של יחידת תוכנה אחרת – למשל ממסד הנתונים שלי, API שאני מפעיל ממיקרוסרוויס בשליטתי וכך הלאה. הסניטציה יוצאת מנקודת הנחה שהפלט מורעל ויש לנקות אותו מגורמים זרים. אני אדגים למשל עם דוגמה מאד קלאסית. נניח והפלט הוא שם משתמש שאני מקבל ממקום כלשהו ואני רוצה להדפיס אותו ואני משתמש, תאמינו או לא, דווקא ב-PHP. אם אני מדפיס אותו כך, מה הבעיה?

<?php
$name = "Some User's Name";

echo $name;

אם למשל המידע שמתקבל ממסד הנתונים הוא Some User's Name אז אין בעיה, אבל אם המשתמש הזדוני מכניס משהו כזה:

<?php
$name = "Some User's Name<script>alert('xss')</script>";

echo $name;

אז ברכות ואיחולים! אנחנו נקבל alert של XSS – דבר שמראה שאנחנו רגישים להתקפת XSS. מה אפשר וצריך לעשות? לבצע סניטציה. למשל משהו כמו הפיכה של > או < לייצוגי ה-HTML שלהן: &lt ו- &gt;. כלומר לקחנו את הפלט וניקינו אותו. למשל, אני אדגים דוגמה מהחיים האמיתיים עם וורדפרס:

function sanitize_and_display_username() {
    // Check if the user is logged in
    if ( is_user_logged_in() ) {
        // Get the current user's data
        $current_user = wp_get_current_user();

        // Sanitize the username
        $safe_username = sanitize_text_field( $current_user->user_login );

        // Display the sanitized username
        echo 'Username: ' . esc_html( $safe_username );
    } else {
        echo 'No user is currently logged in.';
    }
}

// Now you can call this function wherever you need to display the sanitized username
sanitize_and_display_username();

השתמשנו כאן בפקודה שנקראת sanitize_text_field שזו פקודה של וורדפרס שעושה סניטציה. למה זה חשוב?

השתמשו בתשתיות לסניטציה ושימו לב היכן משתמשים בה

זה אולי הדבר הכי חשוב בסניטציה – אל תנסו לממש אותה לבד אלא תפעילו תשתית חיצונית שבדרך כלל באה עם הפריימוורק שאתם משתמשים בו ותעשו אותה בהתאם למידע שאנו עובדים איתו. למה? כי יש כל מיני הפתעות שונות ומשונות.

כדי להמחיש זאת, בואו ונסתכל בפייתון על הקוד הזה שעשיתי לו סניטציה עם מודול html שבא כנייטיב. לא צריך הרבה הבנה בפייתון, שימו לב שכאן אני משתמש ב-escape שיש לה מקבילה בג׳אווהסקריפט, ב-PHP ובעצם בכל שפה אפשרית.

import html

# User input
user_input = "some name"

# Escaping HTML characters
safe_input = html.escape(user_input)

# Inserting into a JavaScript context within a URL
html_content = f"<input placeholder={safe_input} />"

נראה עובד, נכון? וזה גם עובד. אבל מה אם המשתמש הזדוני יכניס את המידע הזה?

import html

# User input
user_input = "onfocus=alert(1) autofocus"

# Escaping HTML characters
safe_input = html.escape(user_input)

# Inserting into a JavaScript context within a URL
html_content = f"<input placeholder={safe_input} />"

יהיה לנו כאן XSS.

הנה דוגמה נוספת שעוברת את html.escape

import html

# User input
user_input = "javascript:alert('XSS')"

# Escaping HTML characters
safe_input = html.escape(user_input)

# Inserting into a JavaScript context within a URL
html_content = f"<script> window.location.href = '{safe_input}'; </script>"

print(html_content)

אלו דוגמאות קטנות וגם קצת (הרבה) מלאכותיות אבל יש המון דוגמאות שבהן אם נשתמש בסניטציה הבסיסית מבלי להתחשב במה המידע שאנו רוצים לעשות לו סניטציה (מייל? שם משתמש? מספר?) והיכן הוא נכנס – אז אנחנו נאכל קש ונחשוף את עצמנו – זו הסיבה שאם אנחנו משתמשים בפריימוורק, כדאי להציץ ב Sanitize functions שלו. אם אנחנו לא משתמשים בפריימוורק, כן כדאי להשתמש בספריה נפרדת שמתוחזקת היטב (למשל nh3 של פייתון).

דוגמה לשימוש – DOMPurify

אני אדגים את הכוח של ספריה כזו עם DOMPurify – ספריית ג׳אווהסקריפט לצד לקוח ולצד שרת. אם אתם לא מתכנתי ג׳אווהסקריפט אז עדיין אפשר להתרשם מהפשטות והקלות. מדובר בספריה מאד פשוטה לשימוש מצד אחד ומהצד השני ניתנת לקינפוג מלא. בגלל זה אני כל כך מרוצה ממנה. אפשר לצרוך אותה כספריה בצד הלקוח אפילו ב-CDN. את הדוגמאות האלו לקחתי מ-README, אפשר לראות שלא מתבצע escaping אלא ה-HTML נשמר ויש גם הסרה של המידע שיכול להיות בעייתי וגם במגוון סקריפטים ודוגמאות שונות ומשונות:

<!DOCTYPE html>
<html>
  <head>
    <title>Some page with DOMPurify client examples</title>
    <meta
      http-equiv="Content-Security-Policy"
      content="default-src 'self'; script-src 'self' 'sha256-dqw7HbZOgBrT05me59GznrMHGLa7ohJr6Sj5Pq73dHk' https://cdnjs.cloudflare.com; style-src 'self' https://cdnjs.cloudflare.com;"
    />

    <script
      src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.0.6/purify.min.js"
      integrity="sha256-6ksJCCykugrnG+ZDGgl2eHUdBFO5xSpNLHw5ohZu2fw="
      crossorigin="anonymous"
    ></script>
  </head>
  <body>
    <h1>DOMPurify examples</h1>
  </body>
  <script>
    console.log(DOMPurify.sanitize("<img src=x onerror=alert(1)//>")); // becomes <img src="x">
    console.log(DOMPurify.sanitize("<svg><g/onload=alert(2)//<p>")); // becomes <svg><g></g></svg>
    console.log(
      DOMPurify.sanitize("<p>abc<iframe//src=jAva&Tab;script:alert(3)>def</p>")
    ); // becomes <p>abc</p>
    console.log(DOMPurify.sanitize("<TABLE><tr><td>HELLO</tr></TABL>")); // becomes <table><tbody><tr><td>HELLO</td></tr></tbody></table>
    console.log(DOMPurify.sanitize("<UL><li><A HREF=//google.com>click</UL>")); // becomes <ul><li><a href="//google.com">click</a></li></ul>
  </script>
</html>

אם תסתכלו בדוקומנטציה תוכלו לראות שאפשר להכניס קונפיגורציה יותר מורכבת – כמו למשל להוריד גם אלמנטים של HTML או להוריד אלמנטים מסוימים.

אגב, בדוגמה שהבאתי יש שימוש ב-CSP – גם SRI על המשאב החיצוני (שאני יכול לעשות כי יש גרסאות ואני לא משתמש ב-latest) וגם hash על ה-inline. שימוש ב-CSP וגם ב-Trusted Type הוא חלק חשוב ממיטיגציה של XSS ויחד עם הסניטציה מהווה את חומת ההגנה שלנו מהזרקות אלו ואחרות.

שימוש בספריה עם Trusted Type

מה שיפה ב-DOM Purifty היא שהיא עובדת יפה עם Trusted Type הישר מהקופסה. כשאנו משתמש ב-CDN, יש לנו הגדרה של מדיניות בשם DOMPurify שאנחנו יכולים להוסיף למדיניות ה-CSP שלנו.

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

  1. הכנסת DOMPurify למדינות CSP – באמצעות trusted-types dompurify.
  2. להשתמש ב-DOMPurify.sanitize איפה שיש innerHTML.

הנה דוגמה:

<!DOCTYPE html>
<html>
  <head>
    <title>Some page with DOMPurify client examples</title>
    <meta
      http-equiv="Content-Security-Policy"
      content="require-trusted-types-for 'script'; trusted-types dompurify"
    />

    <script
      src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.0.6/purify.min.js"
      integrity="sha256-6ksJCCykugrnG+ZDGgl2eHUdBFO5xSpNLHw5ohZu2fw="
      crossorigin="anonymous"
    ></script>
  </head>
  <body>
    <h1>DOMPurify examples</h1>
    <div id="contentDiv"></div>
  </body>
  <script>
    const dirty = '<img src="x" onerror="alert(1)">';
    document.getElementById("contentDiv").innerHTML = DOMPurify.sanitize(
      dirty,
      { RETURN_TRUSTED_TYPE: true }
    );
  </script>
</html>

היתרון הוא שאני לא צריך לממש Trusted Types אלא יכול להשתמש ב DOMPurify.sanitize שהיא פונקציה שקל לקנפג. בדוגמה שלעיל אני משתמש ב:

DOMPurify.sanitize(
      dirty,
      { RETURN_TRUSTED_TYPE: true }
    );

אבל אני יכול להשתמש ב:

DOMPurify.sanitize(
      dirty,
      { ALLOWED_TAGS: ['b', 'q'] }
    );

שבניגוד ל RETURN_TRUSTED_TYPE שמאפשר אלמנטי HTML, אני משתמש בALLOWED_TAGS ומעביר את האלמנטים שאני רוצה. שוב, בהתאם לצורך ולעניין. ואת זה אני יכול לעשות מבלי להגדיר שוב פונקציה שמנהלת Trusted Types באופן יותר גנרי. אני יודע שיש אלרגיה למילה ״גנרי״ אצל ארכיטקטים מנוסים יותר אבל במקרה הזה באמת כדאי להשתמש בספריה הזו כי היא חוסכת המון פונקציות שמגדירות Trusted type בהתאם לצורך. כי לפעמים אני צריך משהו שיאפשר את כל תגיות ה-HTML ולפעמים אני לא צריך בכלל. לפעמים אני צריך משהו שמוריד סוגריים מסולסלות כי אני רוצה להשתמש בקלט במקום שמקבל templates ולפעמים לא. אפשר לראות את שפע ההגדרות שיש ל-DOMPurify.

סיכום

מומלץ להשתמש בסניטציה גם בצד שרת וגם בצד לקוח. בצד השרת – במידע שאנחנו שולחים אל צד הלקוח (בקלט משתמשים/מסד נתונים מומלץ לעשות ולידציה) ובצד הלקוח – במידע שאנו מקבלים מצד השרת – כלומר יש לנו כאן בצל – גם צד הלקוח וגם הצד השרת מנקים מהצד שלהם ויחד עם מדיניות CSP נבונה אפשר למזער את ה-XSS.

פוסטים נוספים שכדאי לקרוא

גלילה לראש העמוד