CSRF, XSS ו-SQL injection: לכתוב נכון בליבה הזו

איך כותבים בקרים, תבניות ושאילתות ב-WIZZO CMS בלי לפתוח CSRF, XSS ו-SQL injection: DB::escape, ה-builder, הגנת פלט ב-Smarty ואסימון CSRF בטפסי ניהול ובאתר.

⏱ 10 דק' קריאה 1875 מילים ערוך דף זה ב-GitHub

שלוש התקלות האלה הן רוב הפרצות באתרים שנבנים על הליבה. הליבה נותנת כלים לכל אחת, אבל לא אוכפת אותם: המפתח אחראי להפעיל אותם בכל מקום. בעמוד הזה לכל נושא יש מנגנון ההגנה הקיים, הדרך הנכונה לכתוב, ומה לא לעשות. הדוגמאות נכתבו מול הקוד של DB, MISC, COOKIES, ADMIN ו-FILES.

system/ בעמוד הזה הוא תיקיית הליבה הפרוסה (api/core בריפו), ו-{admin} הוא CONFIG::$admin_url.

אל תסתמכו על
MISC::init_security() הסינון האוטומטי שרץ בתחילת כל בקשה מחפש מילות מפתח של SQL ב-$_GET ובשמות מפתחות של $_POST בלבד, ובדיקת ערכי $_POST מושבתת בקוד. הוא יוצר רושם של הגנה ואינו כזה. כתבו את הקוד כאילו הוא לא קיים.

SQL injection#

1. הבילדר: הדרך המועדפת#

כשמעבירים ל-DB::query() מערך תנאים, הערכים מטופלים אוטומטית: מספר נכנס כמספר, null הופך ל-IS NULL, וכל מחרוזת אחרת עוברת DB::escape ומוקפת במרכאות. שמות הטבלאות בבילדר הם בלי CRM_.

// נכון: ערך מהמשתמש הוא ערך במערך התנאים
$row = DB::query("articles", [
    "slug"      => $_GET["slug"] ?? "",
    "is_active" => 1,
])->get_row();

// נכון: הכנסה
$ok = DB::update("contacts")
    ->set_var("name",  $_POST["name"] ?? "")
    ->set_var("email", $_POST["email"] ?? "")
    ->set_var("created", "NOW()")
    ->insert();

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

מקוםלמהמה עושים
מפתח במערך התנאיםמשורשר ל-SQL כמו שהואמפתחות קבועים בקוד בלבד
ערך שמתחיל בתו backtickנחשב SQL גולמילא מעבירים קלט משתמש כערך ראשון בתנאי בלי לבדוק
$q->filter, $q->fields, $q->group_by, $q->order_by, $q->limitSQL גולמירשימה לבנה, או (int)
DB::get_val($table, $string)מחרוזת לא מספרית היא WHERE גולמימעבירים מספר ((int)) או מערך
ערך שעובר is_numericנכנס ללא מרכאותבטוח כערך; הבעיה היא רק כשמעבירים כך מפתח או חלק מ-SQL גולמי

2. get_val: המלכודת הנפוצה#

// מסוכן: אם $_GET["id"] אינו מספר, המחרוזת נהפכת ל-WHERE גולמי
$row = DB::get_val("articles", $_GET["id"]);

// נכון
$row = DB::get_val("articles", (int)($_GET["id"] ?? 0));
// או
$row = DB::get_val("articles", ["id" => (int)($_GET["id"] ?? 0)]);

הכלל: ל-get_val מעבירים מספר שלם שהומר או מערך, אף פעם לא ערך שהגיע מהמשתמש כמות שהוא.

3. SQL גולמי ו-DB::escape#

public static function escape($str)

DB::escape עושה escape למחרוזת עבור הקשר של ערך בתוך מרכאות. הוא לא מוסיף את המרכאות בעצמו (ב-PDO הוא מסיר את המרכאות החיצוניות ש-quote() מוסיף), ולכן:

// נכון: ערך מחרוזת בין מרכאות
$rows = DB::get_all("SELECT id, title FROM CRM_articles WHERE slug = '" . DB::escape($slug) . "'");

// נכון: מספר מומר ל-int, לא עובר escape
$rows = DB::get_all("SELECT * FROM CRM_articles WHERE id = " . (int)$id);

// שגוי: escape בלי מרכאות לא עוצר כלום, הערך נשאר חלק מה-SQL
$rows = DB::get_all("SELECT * FROM CRM_articles WHERE id = " . DB::escape($id));

ב-SQL גולמי חייבים את הקידומת המלאה CRM_, וכל ערך חיצוני עובר DB::escape (בתוך מרכאות) או (int) (מספרים).

4. LIKE, ORDER BY ושמות עמודות#

DB::escape לא מתמודד עם תווי הכללה של LIKE, ולא יכול להגן על שם עמודה או כיוון מיון. לשניים האחרונים משתמשים ברשימה לבנה:

$allowed = ["created" => "created", "title" => "title", "views" => "views"];
$col = $allowed[$_GET["sort"] ?? ""] ?? "created";
$dir = (($_GET["dir"] ?? "") === "asc") ? "ASC" : "DESC";

$q = DB::query("articles", ["is_active" => 1]);
$q->order_by = $col . " " . $dir;
$q->limit    = "0," . (int)($_GET["per_page"] ?? 20);
$rows = $q->get_all();
// חיפוש: קודם escape, ואחר כך מנטרלים % ו-_ של המשתמש
$term = DB::escape((string)($_GET["q"] ?? ""));
$term = str_replace(["%", "_"], ["\\%", "\\_"], $term);
$rows = DB::get_all("SELECT id, title FROM CRM_articles WHERE title LIKE '%" . $term . "%' LIMIT 50");

הסדר חשוב: DB::escape מכפיל לוכסנים הפוכים, ולכן הלוכסן של \% מתווסף אחרי ה-escape ולא לפניו.

5. נתונים שכבר בבסיס הנתונים#

נתונים ששמורים בטבלה כבר לא "בטוחים": אם הם נכנסו מקלט משתמש והם משורשרים לשאילתה נוספת (למשל cropper_data או שם קובץ), הם צריכים escape בדיוק כמו קלט חדש. וגם ערך שמגיע מעוגייה הוא קלט משתמש: DELETE או UPDATE ידני שמשרשר אותו חייב DB::escape בתוך מרכאות.

XSS#

1. Smarty לא עושה escape אוטומטי#

FILES::create_smarty() לא מפעילה escape_html. כלומר:

{* פלט כמו שהוא: אם $title מכיל HTML, הוא ירונדר *}
<h1>{$title}</h1>

כל משתנה שמקורו במשתמש (שם, תגובה, פרמטר מה-URL, שדה מטופס, ערך מבסיס נתונים שמשתמשים מילאו) עובר |escape:

<h1>{$title|escape}</h1>
<a href="/search?q={$q|escape:'url'}">חיפוש</a>
<input type="text" name="name" value="{$name|escape}">
<div data-label="{$label|escape}">...</div>

הליבה עצמה כותבת את התבניות שלה כך (למשל {$log.status|escape}). שימו לב שב-Smarty כל פונקציית PHP קיימת זמינה כ-modifier, כלומר מי שעורך תבניות בפאנל מריץ קוד PHP, ולכן עריכת תבניות שמורה למפתחים (תבניות Smarty).

2. בקונטרולר#

כשמייצרים HTML ב-PHP ולא ב-Smarty, משתמשים ב-MISC::special_chars():

public static function special_chars($text)

היא מבצעת htmlspecialchars וגם מחליפה ' ב-&#39;, כך שהיא בטוחה גם בתוך תכונה עם גרשיים בודדים.

echo '<span title="' . MISC::special_chars($name) . '">' . MISC::special_chars($name) . '</span>';

3. הקשרים אחרים#

הקשרמה עושים
ערך בתוך תכונת HTML`
ערך בתוך <script>json_encode($value, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ולא שרשור מחרוזות
ערך בתוך URL`
כתובת ששומרים מהמשתמש (קישור)בודקים סכמה: רק http: או https:
HTML מכוון (עורך CKEditor)אינו escaped. מותר רק כשהכותב הוא מנהל, ועדיף לנקות בשמירה
// בקונטרולר
$this->view("page", [
    "cfg_json" => json_encode($cfg, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT),
]);
<script>
    var cfg = {$cfg_json};
</script>

ה-flags של JSON_HEX_* מונעים מערך שמכיל </script> לסגור את התג. ל-json_encode אין צורך ב-|escape, ואסור להוסיף אותו (הוא ישבור את ה-JSON).

עוגיות שאינן HttpOnly
COOKIES::set שולחת עוגיות ללא HttpOnly, ולכן XSS בודד באתר מאפשר גניבה של עוגיית ההתחברות של משתמשי האתר. עוגיות הניהול (admin_session) נשלחות עם HttpOnly. הגנה מפני XSS היא גם הגנה על סשנים.

CSRF#

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

מה קיים בליבה#

  • עוגיית הניהול admin_session נשלחת עם SameSite=Lax, כך שבקשת POST חוצת-אתר לא נושאת אותה. זו שכבת הגנה בסיסית, לא מלאה.
  • ADMIN::generate_csrf_cookie() מחזירה אסימון (64 תווי hex). אם עוגיית admin_csrf כבר קיימת, מוחזר הערך שלה, אחרת נוצר אסימון חדש ונשמר בעוגייה (HttpOnly, Secure, SameSite=Lax, שעה אחת).
  • ADMIN::verify_csrf_token($posted) משווה את הערך שנשלח לעוגייה ב-hash_equals, ומחזירה false אם אחד מהם ריק. זהו דפוס double-submit.
  • הליבה אוכפת אותו רק בפעולות הכתיבה של מנהל הקבצים ובטופס ההתחברות. שאר פעולות ה-POST בפאנלים, וכל נקודת קצה שאתם כותבים, לא מוגנות אלא אם הוספתם בדיקה.
  • ל-COOKIES::set ברירת מחדל SameSite=None: בקשות חוצות-אתר כן נושאות את עוגיות משתמשי האתר. CSRF בטפסי האתר הוא באחריותכם.

פעולה בפאנל ניהול שלכם#

בבקר שמרנדר את הטופס מוציאים את האסימון לתבנית, ובבקר שמקבל את ה-POST מאמתים:

// application/admin/Orders.php: class ADMINMODULE_Orders extends bgl_controller
function index()
{
    // application/views/admin/orders.tpl
    return $this->view("admin/orders", [
        "csrf"      => ADMIN::generate_csrf_cookie(),
        "admin_url" => CONFIG::$admin_url,
    ]);
}

function cancel()
{
    if (!ADMIN::verify_csrf_token($_POST["csrf"] ?? ""))
    {
        http_response_code(403);
        return json_encode(["success" => false, "error" => "csrf"]);
    }

    DB::update("orders")->set_var("status", "cancelled")->update((int)($_POST["id"] ?? 0));
    return json_encode(["success" => true]);
}
<form method="post" action="/{$admin_url|escape}/Orders/cancel">
    <input type="hidden" name="csrf" value="{$csrf|escape}">
    <input type="hidden" name="id" value="{$order.id|escape}">
    <button type="submit">ביטול הזמנה</button>
</form>

בקריאות AJAX אפשר לשלוח את האסימון בכותרת (מנהל הקבצים משתמש ב-X-WZ-CSRF). העוגייה פגה אחרי שעה: כשהאימות נכשל, בקשו אסימון חדש ושלחו שוב, כפי שעושה assets/file_manager/app.js.

טפסי האתר (משתמשים רגילים)#

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

function form()
{
    $token = SESSION::get("csrf");
    if ($token === "")
    {
        $token = bin2hex(random_bytes(32));
        SESSION::set("csrf", $token);
    }
    return $this->view("account/form", ["csrf" => $token]);
}

function save()
{
    $sent = (string)($_POST["csrf"] ?? "");
    if ($sent === "" || !hash_equals((string)SESSION::get("csrf"), $sent))
    {
        http_response_code(403);
        return "ERROR csrf";
    }
    // ... שמירה ...
}

הערות לשימוש נכון:

  • GET לא משנה מצב. מחיקה, ביטול ושינוי הולכים ב-POST בלבד, אחרת כל תמונה בדף זר מפעילה אותם.
  • אפשר גם להקשיח את העוגיות: COOKIES::$samesite = "Lax"; ב-application/includes/init.php משנה את ברירת המחדל של COOKIES::set מ-None ל-Lax. אל תעשו זאת אם האתר חייב לשלוח עוגיות בתוך iframe של צד שלישי.
  • בדיקת Referer אינה תחליף לאסימון.

רשימת בדיקה לכל בקר חדש#

  • כל ערך מ-$_GET / $_POST / עוגייה עובר דרך הבילדר, (int) או DB::escape בתוך מרכאות.
  • אין DB::get_val עם מחרוזת מהמשתמש.
  • order_by, שמות עמודות וכיווני מיון נבחרים מרשימה לבנה.
  • כל משתנה שמגיע ממשתמש מודפס עם |escape או MISC::special_chars().
  • פעולה שמשנה מצב היא POST ומאמתת אסימון CSRF.
  • בקר תחת system/ או נקודת קצה ציבורית בודק אימות בעצמו.

ראו גם#

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