שלוש התקלות האלה הן רוב הפרצות באתרים שנבנים על הליבה. הליבה נותנת כלים לכל אחת, אבל לא אוכפת אותם: המפתח אחראי להפעיל אותם בכל מקום. בעמוד הזה לכל נושא יש מנגנון ההגנה הקיים, הדרך הנכונה לכתוב, ומה לא לעשות. הדוגמאות נכתבו מול הקוד של 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->limit | SQL גולמי | רשימה לבנה, או (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 וגם מחליפה ' ב-', כך שהיא בטוחה גם בתוך תכונה עם גרשיים בודדים.
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).
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/או נקודת קצה ציבורית בודק אימות בעצמו.