צמצום של סקריפטים חוצי-אתרים (XSS) באמצעות מדיניות Content Security Policy מחמירה (CSP)

תמיכה בדפדפנים

  • Chrome: 52.
  • Edge: ‏ 79.
  • Firefox: 52.
  • Safari: 15.4.

מקור

Cross-site scripting‏ (XSS), היכולת להחדיר סקריפטים זדוניים לאפליקציית אינטרנט, היא אחת מנקודות החולשה הגדולות ביותר באבטחת האינטרנט כבר יותר מעשור.

Content Security Policy‏ (CSP) היא שכבת אבטחה נוספת שעוזרת לצמצם את הסיכון לפגיעות XSS. כדי להגדיר CSP, מוסיפים את כותרת ה-HTTP‏ Content-Security-Policy לדף אינטרנט ומגדירים ערכים שקובעים אילו משאבים סוכן המשתמש יכול לטעון לדף הזה.

בדף הזה מוסבר איך להשתמש ב-CSP שמבוסס על גיבובים או על גיבוב חד-פעמי (nonce) כדי לצמצם את הסיכון ל-XSS, במקום ב-CSP נפוצים שמבוססים על רשימת ההיתרים של המארח, שבדרך כלל משאירים את הדף חשוף ל-XSS כי אפשר לעקוף אותם ברוב ההגדרות.

מונח מפתח: nonce הוא מספר אקראי שמשמש רק פעם אחת, וניתן להשתמש בו כדי לסמן תג <script> כמהימן.

מונח מפתח: פונקציית גיבוב היא פונקציה מתמטית שממירה ערך קלט לערך מספרי דחוס שנקרא גיבוב. אפשר להשתמש בגיבוב (למשל, SHA-256) כדי לסמן תג <script> בקוד כמהימן.

מדיניות Content Security Policy שמבוססת על ערכים חד-פעמיים או על גיבוב נקראת לרוב CSP מחמירה. כשאפליקציה משתמשת ב-CSP מחמיר, תוקפים שמוצאים נקודות חולשה בהזרקת HTML בדרך כלל לא יכולים להשתמש בהן כדי לאלץ את הדפדפן להריץ סקריפטים זדוניים במסמך פגיע. הסיבה לכך היא ש-CSP מחמיר מאפשר רק סקריפטים מגובבים או סקריפטים עם ערך ה-nonce הנכון שנוצר בשרת, כך שמתקפות לא יכולות להריץ את הסקריפט בלי לדעת את ערך ה-nonce הנכון לתגובה נתונה.

למה כדאי להשתמש ב-CSP מחמיר?

אם באתר שלכם כבר יש CSP שנראה כמו script-src www.googleapis.com, סביר להניח שהוא לא יעיל נגד התקפות חוצות-אתרים. סוג כזה של CSP נקרא CSP ברשימת ההיתרים. הן דורשות הרבה התאמה אישית, ואפשר לדלג עליהן.

כדי להימנע מהמלכודות האלה, צריך להשתמש ב-CSPs מחמירים שמבוססים על נתוני nonce קריפטוגרפיים או על גיבוב (hash).

מבנה קפדני של CSP

מדיניות Content Security בסיסית וקפדנית משתמשת באחד מהכותרות הבאות של תגובת HTTP:

CSP מחמיר שמבוסס על קוד חד-פעמי (nonce)

Content-Security-Policy:
  script-src 'nonce-{RANDOM}' 'strict-dynamic';
  object-src 'none';
  base-uri 'none';
איך פועל CSP מחמיר שמבוסס על קוד חד-פעמי (nonce).

CSP קפדני מבוסס-גיבוב

Content-Security-Policy:
  script-src 'sha256-{HASHED_INLINE_SCRIPT}' 'strict-dynamic';
  object-src 'none';
  base-uri 'none';

המאפיינים הבאים הופכים את ה-CSP הזה ל'מחמיר' ולכן מאובטח:

  • הוא משתמש ב-nonces‏ 'nonce-{RANDOM}' או ב-hashes‏ 'sha256-{HASHED_INLINE_SCRIPT}' כדי לציין אילו תגים <script> למפתח האתר יש אמון בהפעלה שלהם בדפדפן של המשתמש.
  • היא מגדירה את 'strict-dynamic' כדי להפחית את המאמץ לפריסה של CSP שמבוסס על גיבוב או על צפן חד-פעמי, על ידי מתן הרשאה אוטומטית להפעלת סקריפטים שנוצרו על ידי סקריפט מהימן. הפעולה הזו גם מאפשרת להשתמש ברוב הווידג'טים ובספריות ה-JavaScript של צד שלישי.
  • הוא לא מבוסס על רשימות של כתובות URL מורשות, ולכן הוא לא חשוף לדרכים נפוצות לעקיפת CSP.
  • הוא חוסם סקריפטים מוטמעים לא מהימנים, כמו גורמים מטפלים באירועים מוטמעים או מזהי URI מסוג javascript:.
  • הוא מגביל את object-src כך שלא יוכל להשבית יישומי פלאגין מסוכנים כמו Flash.
  • הוא מגביל את base-uri כדי לחסום את ההזרקה של תגי <base>. כך אפשר למנוע מתוקפים לשנות את המיקומים של סקריפטים שנטענים מכתובות URL יחסיות.

שימוש ב-CSP קפדני

כדי להשתמש ב-CSP מחמיר, צריך:

  1. מחליטים אם להגדיר באפליקציה CSP שמבוסס על גיבוב (hash) או על צפן חד-פעמי.
  2. מעתיקים את ה-CSP מהקטע Strict CSP structure ומגדירים אותו ככותרת תגובה באפליקציה.
  3. מבצעים ריפרקטור של תבניות HTML וקוד בצד הלקוח כדי להסיר דפוסים שלא תואמים ל-CSP.
  4. פורסים את ה-CSP.

במהלך התהליך, תוכלו להשתמש בבדיקת שיטות מומלצות של Lighthouse (גרסה 7.3.0 ואילך עם הדגל --preset=experimental) כדי לבדוק אם יש באתר שלכם CSP, ואם הוא מספיק מחמיר כדי למנוע XSS.

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

שלב 1: מחליטים אם צריך CSP שמבוסס על גיבוב או על צפן חד-פעמי

כך פועלים שני הסוגים של CSP מחמיר:

CSP מבוסס-nonce

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

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

CSP מבוסס-גיבוב

ב-CSP שמבוסס על גיבוב, הגיבוב של כל תג סקריפט מוטמע מתווסף ל-CSP. לכל סקריפט יש גיבוב שונה. תוקף לא יכול לכלול סקריפט זדוני בדף או להריץ אותו, כי כדי להריץ אותו, ה-hash של הסקריפט צריך להיות ב-CSP.

משתמשים ב-CSP מבוסס-גיבוב (hash) לדפי HTML שמוצגים באופן סטטי, או לדפים שצריך לשמור במטמון. לדוגמה, אפשר להשתמש ב-CSP מבוסס-גיבוב לאפליקציות אינטרנט של דף יחיד שנוצרו באמצעות מסגרות כמו Angular, ‏ React או מסגרות אחרות, שמוצגות באופן סטטי בלי עיבוד בצד השרת.

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

כשמגדירים CSP, יש כמה אפשרויות:

  • מצב דיווח בלבד (Content-Security-Policy-Report-Only) או מצב אכיפה (Content-Security-Policy). במצב דיווח בלבד, ה-CSP עדיין לא יחסום משאבים, כך שלא תהיה תקלה באתר, אבל תוכלו לראות שגיאות ולקבל דוחות על כל מה שהיה חסום. באופן מקומי, כשמגדירים את ה-CSP, זה לא ממש משנה, כי בשני המצבים השגיאות מוצגות במסוף הדפדפן. מצב האכיפה יכול לעזור לכם למצוא משאבים שטיוטת ה-CSP חוסמת, כי חסימה של משאב יכולה לגרום לדף להיראות לא תקין. המצב 'דוח בלבד' שימושי במיוחד בשלב מאוחר יותר בתהליך (ראו שלב 5).
  • תג <meta> של כותרת או HTML. בפיתוח מקומי, תג <meta> יכול להיות נוח יותר כדי לשנות את ה-CSP ולראות במהירות איך הוא משפיע על האתר. עם זאת:
    • בהמשך, כשפורסים את ה-CSP בסביבת הייצור, מומלץ להגדיר אותו ככותרת HTTP.
    • אם רוצים להגדיר את ה-CSP במצב דיווח בלבד, צריך להגדיר אותו ככותרת, כי מטא תגי CSP לא תומכים במצב דיווח בלבד.

אפשרות א': CSP שמבוסס על Nonce

מגדירים באפליקציה את כותרת התגובה הבאה של HTTP‏ Content-Security-Policy:

Content-Security-Policy:
  script-src 'nonce-{RANDOM}' 'strict-dynamic';
  object-src 'none';
  base-uri 'none';

יצירת קוד חד-פעמי ל-CSP

מספר חד-פעמי הוא מספר אקראי שמשמש רק פעם אחת בכל טעינה של דף. אפשר להשתמש ב-CSP שמבוסס על קוד חד-פעמי כדי לצמצם את הסיכון ל-XSS רק אם תוקפים לא יכולים לנחש את ערך ה-nonce. מחרוזת חד-פעמית (nonce) של CSP חייבת להיות:

  • ערך אקראי חזק מבחינה קריפטוגרפית (רצוי באורך של 128 ביט ומעלה)
  • נוצר מחדש לכל תשובה
  • בקידוד Base64

ריכזנו כאן כמה דוגמאות לאופן שבו אפשר להוסיף nonce של CSP במסגרות בצד השרת:

const app = express();

app.get('/', function(request, response) {
  // Generate a new random nonce value for every response.
  const nonce = crypto.randomBytes(16).toString("base64");

  // Set the strict nonce-based CSP response header
  const csp = `script-src 'nonce-${nonce}' 'strict-dynamic'; object-src 'none'; base-uri 'none';`;
  response.set("Content-Security-Policy", csp);

  // Every <script> tag in your application should set the `nonce` attribute to this value.
  response.render(template, { nonce: nonce });
});

הוספת מאפיין nonce לרכיבי <script>

ב-CSP שמבוסס על nonce, לכל רכיב <script> חייב להיות מאפיין nonce שתואם לערך ה-nonce האקראי שצוין בכותרת ה-CSP. לכל הסקריפטים יכול להיות אותו קוד חד-פעמי. השלב הראשון הוא להוסיף את המאפיינים האלה לכל הסקריפטים כדי שה-CSP יאפשר אותם.

אפשרות ב': כותרת תגובה של CSP שמבוססת על גיבוב

מגדירים באפליקציה את כותרת התגובה הבאה של HTTP‏ Content-Security-Policy:

Content-Security-Policy:
  script-src 'sha256-{HASHED_INLINE_SCRIPT}' 'strict-dynamic';
  object-src 'none';
  base-uri 'none';

אם רוצים להשתמש בכמה סקריפטים בקוד, התחביר הוא: 'sha256-{HASHED_INLINE_SCRIPT_1}' 'sha256-{HASHED_INLINE_SCRIPT_2}'.

טעינה דינמית של סקריפטים שמקורם ב-Google

אפשר לטעון סקריפטים של צד שלישי באופן דינמי באמצעות סקריפט מוטמע.

דוגמה לאופן שבו אפשר להוסיף את הסקריפטים לקוד.
מותר על ידי CSP
<script>
  var scripts = [ 'https://example.org/foo.js', 'https://example.org/bar.js'];

  scripts.forEach(function(scriptUrl) {
    var s = document.createElement('script');
    s.src = scriptUrl;
    s.async = false; // to preserve execution order
    document.head.appendChild(s);
  });
</script>
כדי להריץ את הסקריפט הזה, צריך לחשב את הגיבוב של הסקריפט בקוד ולהוסיף אותו לכותרת התגובה של CSP, במקום placeholder‏ {HASHED_INLINE_SCRIPT}. כדי לצמצם את כמות הגיבוב, אפשר למזג את כל הסקריפטים שמוצגים בקוד לתוך סקריפט אחד. כדי לראות איך זה עובד, אפשר לעיין בדוגמה הזו ובקוד שלה.
נחסם על ידי CSP
<script src="https://example.org/foo.js"></script>
<script src="https://example.org/bar.js"></script>
CSP חוסם את הסקריפטים האלה כי הם לא נוספו באופן דינמי ואין להם מאפיין integrity שתואם למקור מורשה.

שיקולים לגבי טעינת סקריפטים

בדוגמה של הסקריפט שמוצג בקוד, הוסיפו את s.async = false כדי לוודא ש-foo יפעל לפני bar, גם אם bar נטען קודם. בקטע הקוד הזה, s.async = false לא חוסם את המנתח בזמן טעינת הסקריפטים, כי הסקריפטים מתווספים באופן דינמי. המנתח מפסיק לפעול רק בזמן שהסקריפטים פועלים, כמו שהוא עושה בסקריפטים של async. עם זאת, חשוב לזכור:

  • יכול להיות שאחד מהסקריפטים או שניהם יפעלו לפני שההורדה של המסמך תסתיים. אם רוצים שהמסמך יהיה מוכן עד שהסקריפטים יפעלו, צריך להמתין לאירוע DOMContentLoaded לפני שמצרפים את הסקריפטים. אם הדבר גורם לבעיה בביצועים כי הסקריפטים לא מתחילים להוריד מוקדם מספיק, צריך להשתמש בתגי טעינה מראש מוקדם יותר בדף.
  • defer = true לא עושה כלום. אם אתם זקוקים להתנהגות הזו, תוכלו להריץ את הסקריפט באופן ידני כשצריך.

שלב 3: שינוי תבניות HTML וקוד בצד הלקוח

אפשר להשתמש במטפלי אירועים בקוד (כמו onclick="…", ‏ onerror="…") וב-URI של JavaScript (<a href="javascript:…">) כדי להריץ סקריפטים. המשמעות היא שפורץ שמוצא באג XSS יכול להחדיר את סוג ה-HTML הזה ולבצע JavaScript זדוני. CSP שמבוסס על גיבוב או על מפתח גיבוב חד-פעמי (nonce) אוסר על שימוש בסימון מהסוג הזה. אם האתר שלכם משתמש באחד מהדפוסים האלה, תצטרכו לבצע להם רפאקציה (refactor) ולהפוך אותם לחלופות בטוחות יותר.

אם הפעלתם את CSP בשלב הקודם, תוכלו לראות במסוף הפרות של CSP בכל פעם ש-CSP חוסם דפוס לא תואם.

דוחות על הפרות של CSP במסוף הפיתוח של Chrome.
שגיאות במסוף לגבי קוד חסום.

ברוב המקרים, התיקון פשוט:

שינוי מבנה של גורמים שמטפלים באירועים בקוד

מותר על ידי CSP
<span id="things">A thing.</span>
<script nonce="${nonce}">
  document.getElementById('things').addEventListener('click', doThings);
</script>
CSP מאפשר להשתמש בגורמים מטפלים באירועים שנרשמים באמצעות JavaScript.
נחסם על ידי CSP
<span onclick="doThings();">A thing.</span>
CSP חוסם גורמים מטפלים באירועים בקוד.

שינוי מבנה של מזהי URI מסוג javascript:

מותר על ידי CSP
<a id="foo">foo</a>
<script nonce="${nonce}">
  document.getElementById('foo').addEventListener('click', linkClicked);
</script>
CSP מאפשר להשתמש בגורמים מטפלים באירועים שנרשמים באמצעות JavaScript.
נחסם על ידי CSP
<a href="javascript:linkClicked()">foo</a>
CSP חוסם URIs מסוג javascript: ‎.

הסרת eval() מקודק ה-JavaScript

אם באפליקציה שלכם נעשה שימוש ב-eval() כדי להמיר סריאליזציות של מחרוזות JSON לאובייקטים של JS, עליכם לבצע רפאקציה של המופעים האלה ל-JSON.parse(), שגם מהיר יותר.

אם אתם לא יכולים להסיר את כל השימושים ב-eval(), עדיין תוכלו להגדיר מדיניות CSP מחמירה שמבוססת על קוד חד-פעמי, אבל תצטרכו להשתמש במילות המפתח של מדיניות CSP ‏'unsafe-eval', וכך רמת האבטחה של המדיניות תהיה נמוכה יותר.

דוגמאות נוספות לשיפורים כאלה מופיעות ב-Codelab הזה בנושא CSP קפדני:

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

תמיכה בדפדפנים

  • Chrome: 52.
  • Edge: ‏ 79.
  • Firefox: 52.
  • Safari: 15.4.

מקור

אם אתם צריכים לתמוך בגרסאות ישנות יותר של הדפדפן:

  • כדי להשתמש ב-strict-dynamic, צריך להוסיף את https: כחלופה לגרסאות קודמות של Safari. כשעושים את זה:
    • כל הדפדפנים שתומכים ב-strict-dynamic מתעלמים מהחלופה https:, כך שהדבר לא יפחית את חוזק המדיניות.
    • בדפדפנים ישנים, סקריפטים שמקורם חיצוני יכולים להיטען רק אם הם מגיעים ממקור HTTPS. האפשרות הזו פחות מאובטחת ממדיניות CSP מחמירה, אבל היא עדיין מונעת חלק מהגורמים הנפוצים למתקפות XSS, כמו הזרקות של מזהי URI מסוג javascript:.
  • כדי להבטיח תאימות לגרסאות דפדפן ישנות מאוד (שנמצאות בשימוש כבר יותר מ-4 שנים), אפשר להוסיף את unsafe-inline כחלופה. כל הדפדפנים העדכניים מתעלמים מ-unsafe-inline אם יש nonce או גיבוב של CSP.
Content-Security-Policy:
  script-src 'nonce-{random}' 'strict-dynamic' https: 'unsafe-inline';
  object-src 'none';
  base-uri 'none';

שלב 5: פריסת ה-CSP

אחרי שתאשרו שה-CSP לא חוסם סקריפטים חוקיים בסביבת הפיתוח המקומית, תוכלו לפרוס את ה-CSP בסביבת ייצור, ואז בסביבת הייצור:

  1. (אופציונלי) פורסים את ה-CSP במצב דיווח בלבד באמצעות הכותרת Content-Security-Policy-Report-Only. מצב דיווח בלבד שימושי לבדיקה של שינוי שעלול לגרום לשיבושים, כמו הוספת ספק ניהול תוכן חדש בסביבת הייצור, לפני שמתחילים לאכוף את ההגבלות של ספק ניהול התוכן. במצב דיווח בלבד, ה-CSP לא משפיע על התנהגות האפליקציה, אבל הדפדפן עדיין יוצר שגיאות במסוף ודוחות על הפרות כשהוא נתקל בדפוסים שלא תואמים ל-CSP, כדי שתוכלו לראות מה היה נשבר אצל משתמשי הקצה. למידע נוסף, ראו Reporting API.
  2. אחרי שתהיה לכם ודאות שה-CSP לא יגרום לשיבושים באתר שלכם אצל משתמשי הקצה, תוכלו לפרוס את ה-CSP באמצעות כותרת התגובה Content-Security-Policy. מומלץ להגדיר את ה-CSP באמצעות כותרת HTTP בצד השרת, כי היא מאובטחת יותר מתג <meta>. אחרי השלמת השלב הזה, ה-CSP יתחיל להגן על האפליקציה מפני XSS.

מגבלות

בדרך כלל, מדיניות CSP מחמירה מספקת שכבת אבטחה נוספת חזקה שעוזר לצמצם את הסיכון למתקפות XSS. ברוב המקרים, CSP מפחית באופן משמעותי את שטח הפנים להתקפה על ידי דחייה של דפוסים מסוכנים כמו מזהי URI מסוג javascript:. עם זאת, בהתאם לסוג ה-CSP שבו אתם משתמשים (צפנים חד-פעמיים, גיבובים, עם או בלי 'strict-dynamic'), יש מקרים שבהם גם ה-CSP לא מגן על האפליקציה:

  • אם מגדירים קוד חד-פעמי (nonce) לסקריפט, אבל יש הזרקה ישירות לגוף או לפרמטר src של אותו רכיב <script>.
  • אם יש הזרקות למיקומים של סקריפטים שנוצרו באופן דינמי (document.createElement('script')), כולל פונקציות ספרייה שיוצרות צמתים של DOM מסוג script על סמך הערכים של הארגומנטים שלהן. הרשימה הזו כוללת כמה ממשקי API נפוצים, כמו .html() של jQuery, וגם .get() ו-.post() ב-jQuery בגרסאות פחות מ-3.0.
  • אם יש הזרקות של תבניות באפליקציות AngularJS ישנות. תוקף שיכול להחדיר קוד לתבנית AngularJS יכול להשתמש בה כדי להריץ JavaScript אקראי.
  • אם המדיניות מכילה את 'unsafe-eval', הזרקות ל-eval(),‏ setTimeout() ולעוד כמה ממשקי API שמשמשים לעתים רחוקות.

מפתחים ומהנדסי אבטחה צריכים לשים לב במיוחד לדפוסים כאלה במהלך בדיקות הקוד ובדיקות האבטחה. פרטים נוספים על המקרים האלה זמינים במאמר Content Security Policy: A Successful Mess Between Hardening and Mitigation.

קריאה נוספת