דילוג לתוכן
New

DeepSeek V4.1 Flash free for 7 days: 10M tokens a day after a top-up of $10 or more. Until Nov 30.

How it works

מה זה CSRF ואיך עוגיות SameSite עוזרות

Cross-site request forgery מנצל את העוגיות של הדפדפן שלך נגדך. ההגנות לפי סדר החוזק שלהן, ומעבדת Flask לבדיקת SameSite.

ביניים10 דקות קריאהמאת NotHoly

Cross-site request forgery (CSRF) מאפשר לדף באתר אחד לגרום לדפדפן שלך לשלוח בקשה שמשנה מצב לאתר אחר שהחשבון שלך מחובר אליו. הדפדפן מצרף את עוגיית ה-session מעצמו, ולכן האתר השני מתייחס לבקשה כאילו באה ממך. ההגנות הן סוד לכל session שהתוקף לא יכול לקרוא, מאפיין העוגייה SameSite, בדיקת Origin וכלל שלפיו בקשות GET אף פעם לא משנות מצב. המאמר עובר על כל אחת מהן ואז מריץ מעבדת Flask קטנה שבה רואים את העוגייה זזה.

מה זה CSRF

session בווב בדרך כלל יושב בעוגייה. אחרי כניסה לאתר bank.example, הדפדפן שומר עוגייה עבור המארח הזה ושולח אותה בכל בקשה אליו. השרת קורא את העוגייה, מוצא את ה-session שלך ומבצע את הפעולה.

הדפדפן לא בודק מאיפה הגיעה הבקשה. אם בדף באתר evil.example יש טופס ששולח POST לכתובת https://bank.example/transfer, הדפדפן שולח את ה-POST עם העוגייה של bank.example מצורפת. הבנק רואה session תקף ומעביר את הכסף. התוקף לא רואה את העוגייה שלך ולא קורא את התשובה. כל מה שהתקיפה צריכה הוא שהשרת יבצע את הפעולה.

CWE-352 מתאר את החולשה כך: האפליקציה מבצעת פעולה שמשנה מצב עבור משתמש בלי לבדוק שהמשתמש בחר לשלוח את הבקשה. התיקון הוא לאפשר לשרת להבחין בין בקשה שהמשתמש שלח לבין בקשה שדף של צד שלישי זייף.

למה זה חשוב

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

sessions בעוגיות הם ברירת המחדל בהרבה פריימוורקים, והדפדפן שולח את העוגייה בין אם המפתח חשב על זה ובין אם לא. לכן CSRF עדיין מופיע ברשימות של OWASP ושל CWE.

איך התקיפה עובדת, שלב אחר שלב

  1. הקורבן נכנס לאתר bank.example. הדפדפן שומר את עוגיית ה-session עבור המארח הזה.
  2. הקורבן פותח דף אחר באותו דפדפן, בלי להתנתק. הדף שייך לתוקף.
  3. בדף יש טופס ששולח POST לכתובת https://bank.example/transfer עם ערכים שהתוקף בחר. הטופס יכול להישלח בטעינה, כך שהקורבן לא צריך ללחוץ על כלום.
  4. הדפדפן שולח את ה-POST ומצרף את העוגייה של bank.example.
  5. השרת מאמת את העוגייה, רואה משתמש מחובר ומבצע את ההעברה.

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

למה אסור ל-GET לשנות מצב

אפשר להפעיל בקשת GET בלי שום טופס. תג <img src="https://bank.example/delete?id=7"> גורם לדפדפן לשלוח בקשת GET ברגע שהדף מרונדר. גם prefetching, תצוגות מקדימות של קישורים וסורקים יכולים לשלוח בקשות GET. אם נתיב GET משנה נתונים, ל-CSRF לא צריך טופס בכלל.

הכלל פשוט: GET ו-HEAD קוראים נתונים ואף פעם לא משנים אותם. שינויים עוברים דרך POST, PUT, PATCH או DELETE. הכלל לבדו אינו הגנה, כי עדיין אפשר לזייף POST, אבל כל הגנה אחרת עובדת טוב יותר כשהוא מתקיים.

ההגנות, לפי סדר החוזק

1. טוקני סנכרון (Synchronizer tokens)

השרת יוצר טוקן אקראי לכל session, מכניס אותו לכל טופס כשדה מוסתר ובודק אותו בכל בקשה שמשנה מצב. בקשה בלי הטוקן הנכון נדחית.

הטוקן עובד כי הדף של התוקף לא יכול לקרוא אותו. מדיניות same-origin מונעת מהאתר evil.example לקרוא את ה-HTML שהאתר bank.example החזיר, ולכן התוקף לא יכול להעתיק את השדה המוסתר לטופס מזויף. בקשה מזויפת מגיעה עם העוגייה ובלי הטוקן.

הטוקן חייב להיות בלתי צפוי (מחולל אקראיות קריפטוגרפי), קשור ל-session של המשתמש, ונבדק בכל מתודה לא בטוחה, כולל ה-endpoints שה-JavaScript שלך קורא להם. רוב הפריימוורקים מגיעים עם ההגנה הזו. מסיבה זו ה-Cheat Sheet של OWASP מציג הגנות מבוססות טוקן ראשונות.

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

2. מאפיין העוגייה SameSite

SameSite אומר לדפדפן מתי לצרף עוגייה לבקשה שמתחילה באתר אחר. למאפיין יש שלושה ערכים.

  • Strict שולח את העוגייה רק בבקשות שמתחילות באותו אתר. קישור מאימייל לאפליקציה שלך מגיע בלי העוגייה, ולכן בביקור הראשון המשתמש נראה לא מחובר.
  • Lax שולח את העוגייה בבקשות מאותו אתר ובניווטי GET ברמה העליונה מאתר אחר, כמו מעבר בקישור. הוא לא שולח אותה בבקשות POST בין אתרים, וגם לא בבקשות שטוענות תמונות, frames וסקריפטים מאתר אחר. Lax חוסם את התקיפה הקלאסית מבוססת הטופס.
  • None שולח את העוגייה גם בבקשות בין אתרים. הוא מחייב את המאפיין Secure, כך שהעוגייה עוברת רק ב-HTTPS. כדאי להשתמש ב-None רק כשתהליך אמיתי בין אתרים צריך את העוגייה, כמו ווידג׳ט משובץ של צד שלישי.

MDN מתעד את המאפיין ואת הערכים שלו בדף העזר של Set-Cookie. טיוטת RFC 6265bis מגדירה "אתר" לפי הדומיין הניתן לרישום (הסיומת הציבורית ועוד התווית שמשמאלה, כלומר site.example עבור www.site.example). מספרי פורט לא נחשבים. תתי־דומיינים נחשבים לאותו אתר. זה ישפיע בהמשך המעבדה, כי localhost וגם 127.0.0.1 הם מארחים שונים, ולכן אתרים שונים.

SameSite חזק, אבל לא שלם. תת־דומיין שכן כמו forum.bank.example נחשב לאותו אתר של bank.example, ולכן Lax לא עוצר תקיפה שיוצאת משם. דפדפנים ישנים שמתעלמים מהמאפיין שולחים את העוגייה לכל מקום. ו-Lax עדיין שולח את העוגייה בניווטי GET ברמה העליונה, ולכן כלל ה-GET נשאר בתוקף. צריך להתייחס ל-SameSite כשכבה אחת.

3. בדיקות Origin ו-Referer

דפדפנים שולחים כותרת Origin בבקשות POST וברוב הבקשות בין אתרים. הכותרת מכילה את הסכמה, המארח והפורט של הדף שהתחיל את הבקשה, כך שהשרת יכול להשוות אותה ל-origin שלו. Sec-Fetch-Site נותן אות דומה, עם ערכים כמו same-origin, same-site וגם cross-site.

הבדיקה פשוטה. לדחות בקשות שמשנות מצב כשהכותרת Origin קיימת ולא תואמת ל-origin שלך. אם הכותרת Origin חסרה, לחזור לכותרת Referer. אם שתיהן חסרות בבקשה שמשנה מצב, לדחות אותה. צריך לפרסר את הכותרת כ-URL ולהשוות את ה-origin המלא. בדיקת תת־מחרוזת נכשלת: Origin: https://bank.example.evil.test מכיל את הדומיין שלך אבל שייך למישהו אחר.

בדיקות Origin תופסות בקשות ברורות בין אתרים, אבל הן תלויות בכותרות שחלק מהלקוחות משמיטים ושהגדרות פרטיות יכולות להסיר. כדאי להשתמש בהן כמסנן, ולהשאיר טוקן למקרים שהכותרות מפספסות.

4. לעולם לא לשנות מצב ב-GET

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

מעבדה מעשית: לעקוב אחרי העוגייה והמאפיין

צריך Python 3.12 ושלושה חלונות טרמינל. בהרצה של המאמר הזה השתמשתי ב-Python 3.12.10 על Windows 11 עם Git Bash. המעבדה כוללת אפליקציית Flask אחת בכתובת 127.0.0.1:5000 ודף סטטי פשוט בכתובת localhost:8001. ה-origin השני הוא מה שהופך את הבקשה לבין־אתרית.

הכנת הסביבה

ליצור סביבה וירטואלית ולהתקין Flask. ב-Linux וב-macOS מפעילים אותה עם . .venv/bin/activate. ב-Windows עם Git Bash, סקריפט ההפעלה נמצא בנתיב .venv/Scripts/activate.

bash
python3 -m venv .venv && . .venv/bin/activate && pip install flask

בהרצה הזו הגרסאות שהותקנו היו Flask 3.1.3 ו-Werkzeug 3.1.9.

כתיבת האפליקציה

לשמור את הקוד הזה בקובץ app.py. הנתיב /login מגדיר עוגיית session עם SameSite=Lax ועם HttpOnly. הנתיב /transfer לא משנה כלום. הוא רק מדפיס אם העוגייה הגיעה, יחד עם שתי כותרות בקשה שעוזרות לקרוא את התוצאה.

python
import os

from flask import Flask, make_response, request

app = Flask(__name__)

# Change this to "None" to compare. Browsers reject SameSite=None without Secure.
SAMESITE = os.environ.get("LAB_SAMESITE", "Lax")
SECURE = SAMESITE == "None"


@app.get("/login")
def login():
    resp = make_response("logged in\n")
    resp.set_cookie("session", "lab", samesite=SAMESITE, secure=SECURE, httponly=True)
    return resp


@app.post("/transfer")
def transfer():
    arrived = request.cookies.get("session") == "lab"
    print(
        f"POST /transfer cookie_arrived={arrived} "
        f"origin={request.headers.get('Origin')} "
        f"sec_fetch_site={request.headers.get('Sec-Fetch-Site')}",
        flush=True,
    )
    return f"cookie_arrived={arrived}\n"

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

בטרמינל הראשון:

bash
flask --app app run --host 127.0.0.1 --port 5000

בטרמינל שני, לבקש את נתיב הכניסה ולקרוא את הכותרת:

bash
curl -i http://127.0.0.1:5000/login

הפלט מההרצה הזו, בלי השורה Date:

text
HTTP/1.1 200 OK
Server: Werkzeug/3.1.9 Python/3.12.10
Content-Type: text/html; charset=utf-8
Content-Length: 10
Set-Cookie: session=lab; HttpOnly; Path=/; SameSite=Lax
Connection: close

logged in

החלק החשוב הוא השורה Set-Cookie. המאפיין HttpOnly מרחיק את העוגייה מ-JavaScript של הדף. הוא לא מונע מהדפדפן לשלוח אותה, ולכן HttpOnly אינו הגנה מפני CSRF. המאפיין שמשנה את מה שהדפדפן שולח בין אתרים הוא SameSite=Lax.

הדמיה של מה שהשרת רואה

לפני שמערבים דפדפן, כדאי לשלוח בקשות ידנית כדי לדעת מה השרת רושם:

bash
curl -s -X POST -d "amount=100" http://127.0.0.1:5000/transfer
curl -s -X POST -H "Origin: http://localhost:8001" -H "Sec-Fetch-Site: cross-site" -b "session=lab" -d "amount=100" http://127.0.0.1:5000/transfer
curl -s -X POST -H "Origin: http://127.0.0.1:5000" -H "Sec-Fetch-Site: same-origin" -b "session=lab" -d "amount=100" http://127.0.0.1:5000/transfer

התשובות מההרצה הזו, שורה לכל פקודה:

text
cookie_arrived=False
cookie_arrived=True
cookie_arrived=True

השורות שטרמינל ה-Flask הדפיס עבור שלוש הבקשות האלה:

text
POST /transfer cookie_arrived=False origin=None sec_fetch_site=None
POST /transfer cookie_arrived=True origin=http://localhost:8001 sec_fetch_site=cross-site
POST /transfer cookie_arrived=True origin=http://127.0.0.1:5000 sec_fetch_site=same-origin

curl לא מיישם SameSite. הוא שולח כל מה שמעבירים לו עם -b. לכן השורות האלה מוכיחות שהנתיב קורא את העוגייה ואת הכותרות, ולא מוכיחות מה דפדפן שולח. את זה בודקים בשלב הבא.

הגשת טופס מ-origin שני

ליצור תיקייה בשם attacker ולשמור את זה בקובץ attacker/index.html:

html
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Second origin (lab)</title></head>
<body>
<h1>Form on a second origin</h1>
<form method="POST" action="http://127.0.0.1:5000/transfer">
  <input type="hidden" name="amount" value="100">
  <button type="submit">Send transfer</button>
</form>
</body>
</html>

בטרמינל שלישי, להפעיל שרת סטטי מהתיקייה שמכילה את attacker/:

bash
python3 -m http.server 8001 --bind 127.0.0.1

בהרצה הזו, בקשה לשרת הסטטי החזירה HTTP 200. הדף נטען אחר כך בכתובת http://localhost:8001/attacker/. כדאי לשים לב לשם המארח. localhost וגם 127.0.0.1 הם מארחים שונים, ולכן הטופס שמוגש מהכתובת localhost:8001 שולח POST לאתר שונה ממארח העוגייה. זו ההגדרה הבין־אתרית שצריך.

השוואה בין SameSite=Lax ל-SameSite=None

קודם לפתוח בדפדפן את http://127.0.0.1:5000/login, כדי שהעוגייה תישמר עבור המארח 127.0.0.1 בפרופיל הדפדפן הזה. אחר כך לפתוח באותו דפדפן את http://localhost:8001/attacker/ וללחוץ על Send transfer. לקרוא את הטרמינל של Flask.

עם SameSite=Lax, POST של טופס בין אתרים לא אמור לשאת את העוגייה. שורת הלוג הצפויה היא cookie_arrived=False. הדפדפן משמיט את העוגייה כי Lax לא שולח אותה בבקשות POST בין אתרים. שלב הדפדפן לא הורץ עבור המאמר הזה, ולכן צריך לבדוק בעצמך את שורת הלוג מול הציפייה הזו.

עכשיו להפעיל מחדש את האפליקציה עם None:

bash
LAB_SAMESITE=None flask --app app run --host 127.0.0.1 --port 5000

לקרוא את הכותרת בשרת החדש:

bash
curl -i http://127.0.0.1:5000/login

הפלט מההרצה הזו:

text
Set-Cookie: session=lab; Secure; HttpOnly; Path=/; SameSite=None

SameSite=None לבדו לא מספיק. דפדפנים דוחים עוגיית None שחסר בה Secure, והמאפיין Secure מחייב HTTPS. המעבדה מגישה HTTP רגיל על 127.0.0.1, והשאלה אם דפדפן ישמור עוגיית Secure מה-origin הזה תלויה בדפדפן. צריך לפתוח את העוגייה בכלי המפתחים של הדפדפן ולראות אם היא קיימת. אם היא לא נשמרה, לוג השרת יראה cookie_arrived=False, אבל הסיבה היא הכלל של Secure ולא SameSite. אם היא נשמרה, להריץ את הטופס שוב. שורת cookie_arrived=True עם None פירושה שהדפדפן שלח עוגייה ב-POST בין אתרים. ההתנהגות הזו הופכת את None למסוכן עבור עוגיות session, ולכן כדאי לבחור Lax או Strict אלא אם תהליך אמיתי בין אתרים צריך None.

בסיום, לעצור כל שרת עם Ctrl+C.

מה המעבדה לא מוכיחה

המעבדה מראה איך עוגיות ו-SameSite משנים את מה שהשרת מקבל. היא לא מוסיפה הגנה. הנתיב /transfer עדיין מקבל כל POST שנושא את העוגייה, ולכן עוגיית SameSite=None תאפשר לטופס מזויף להצליח. כדי לסגור את הפער, צריך להוסיף לטופס טוקן סנכרון ולבדוק אותו בנתיב. המעבדה עוצרת כאן כדי להראות קודם את המאפיין לבדו.

טעויות נפוצות

  • לסמוך על CORS שיעצור CSRF. CORS קובע אם דף יכול לקרוא תשובה. הוא לא מונע מהדפדפן לשלוח POST פשוט בין אתרים. הבקשה עדיין מגיעה לשרת.
  • לחשוב ש-HttpOnly עוצר CSRF. HttpOnly מסתיר את העוגייה מ-JavaScript. הדפדפן עדיין מצרף אותה לבקשות מזויפות.
  • לבדוק את הטוקן רק בחלק מהנתיבים. endpoints שקריאות AJAX משתמשות בהם מדלגים הרבה פעמים על הבדיקה. תוקפים מחפשים את הנתיב היחיד שמדלג.
  • לשים את הטוקן ב-URL. כתובות URL מופיעות בלוגים של השרת, בכותרות Referer ובהיסטוריית הדפדפן.
  • לתת לנתיבי GET לשנות נתונים. אז תג תמונה רגיל מספיק לתקיפה, ו-SameSite=Lax כבר לא מגן על הנתיב.
  • להגדיר None בלי Secure. דפדפנים דוחים את זה. שינוי המאפיין כדי לתקן תהליך שבור עלול לפתוח נתיב בין־אתרי שלא התכוונו לפתוח.
  • להשוות Origin לפי תת־מחרוזת. גם evil-bank.example וגם bank.example.evil.test מכילים את bank.example. צריך להשוות origins מלאים.
  • להסתמך על SameSite בלבד. הוא חזק מול אתרים לא קשורים, אבל תתי־דומיינים שכנים נחשבים לאותו אתר, ודפדפנים ישנים עלולים להתעלם מהמאפיין.

איך ממשיכים

לקרוא את ה-Cheat Sheet של OWASP מתחילתו ועד סופו. הוא מכסה את תבניות הטוקנים, את בדיקות הכותרות ואת המקרים שכל אחת מפספסת. לקרוא את טיוטת RFC 6265bis בשביל כללי SameSite המדויקים, כולל ברירת המחדל כשהמאפיין חסר והטיפול בניווטים ברמה העליונה. הדף של Set-Cookie ב-MDN הוא מקור מהיר יותר לערכי המאפיין.

באפליקציה שלך, כדאי להשתמש בהגנת ה-CSRF שהפריימוורק מספק, ולבדוק אותה. בדיקה אחת שולחת POST לנתיב שמשנה מצב בלי טוקן ומצפה לדחייה. בדיקה שנייה שולחת POST עם טוקן מ-session אחר ומצפה גם היא לדחייה. ב-Flask כדאי להסתכל על ההרחבה CSRFProtect של Flask-WTF. כדאי לבדוק בכלי המפתחים של הדפדפן אזהרות על עוגיות, כי Chrome מדווח על בעיות SameSite בחלונית Issues.

לשכבת התעבורה שמתחת לעוגיות האלה, כדאי לקרוא מה HTTPS ו-TLS עושים. למאפיין Secure יש משמעות רק כשהחיבור מוצפן. המאמר הבא בסדרה, איך לכתוב את דוח האבטחה הראשון שלך, מראה איך לתעד ממצא כזה עבור הצוות שאחראי על הקוד.

מקורות

מקורות

  1. OWASP Cross-Site Request Forgery Prevention Cheat Sheet
  2. MDN, Set-Cookie header
  3. CWE-352, Cross-Site Request Forgery (CSRF)
  4. IETF draft-ietf-httpbis-rfc6265bis, HTTP State Management Mechanism

עוד בנושא אבטחת Web ו-API

אבטחת Web ו-API

קריאת JavaScript בעזרת AI כדי למצוא endpoints

ה-JavaScript בצד הלקוח מפרט את ה-API שהאפליקציה מדברת איתו. בעזרת מודל AI קוראים bundles ומעלים endpoints ופרמטרים, ואז מאמתים כל אחד בעצמך.

מתחילים2 דקות קריאה

אבטחת Web ו-API

בדיקת באגים נפוצים בווב בעזרת AI

מתודולוגיה ל-IDOR, XSS, SSRF ו-auth שבור עם מודל AI כקורא וכדעה שנייה, בתוספת איך מאמתים finding ונמנעים מ-false positives.

מתחילים3 דקות קריאה

אבטחת Web ו-API

בדיקת API בעזרת AI: OpenAPI ו-GraphQL

APIs חושפים את הצורה שלהם דרך OpenAPI specs ו-GraphQL introspection. בעזרת מודל AI קוראים את ה-schema ומתכננים בדיקות authorization שאתה מאמת.

ביניים3 דקות קריאה