העלאת קובץ היא אחד המקומות המסוכנים ביותר באתר: קובץ אחד עם סיומת ריצה בתיקייה שהשרת מריץ הוא שליטה מלאה בשרת. בעמוד הזה מתוארות ההגנות שהליבה נותנת בפועל (ופחות ממה שאפשר לצפות), נקודות הכניסה להעלאה, והדפוס שכדאי לבנות מעליהן כשכותבים העלאה משלכם.
system/ בעמוד הזה הוא תיקיית הליבה הפרוסה (api/core בריפו), ו-{admin} הוא CONFIG::$admin_url.
מה מגן בפועל#
רשימת חסימה ב-STORAGE#
STORAGE::add() בודקת את הסיומת של הקובץ אחרי שהיא חולצה משם הקובץ שהגיע מהלקוח. סיומת מהרשימה הבאה מסיימת את הבקשה ב-die("STORAGE SECURITY ERROR!"):
php, pl, py, cgi, asp, perl, php3 עד php7, pht, phtml, shtml, asa, cer, asax, xap, exe, html, js, css, htm.
כל סיומת שלא ברשימה עוברת: svg, phar, php8, inc, htaccess ועוד. אין ב-STORAGE בדיקת גודל ואין בדיקת MIME. שרת שמוגדר להריץ סיומת שהרשימה לא מכירה (למשל php8) הופך את STORAGE::add() לדרך להעלות קוד. הפתרון הוא הגדרת השרת (ראו "הרצת קוד בתיקיית ההעלאות" למטה) ורשימת היתרים משלכם לפני הקריאה ל-add().
השם הפיזי של הקובץ הוא <uuid|id>.<סיומת>, והשם המקורי נשמר רק בעמודה name. כלומר מתקפת שם קובץ מהסוג ../../x.php לא משפיעה על הנתיב הפיזי, אבל הסיומת כן נלקחת מהשם שהלקוח שלח.
בדיקת init_security על $_FILES: כבויה#
MISC::init_security() רצה בתחילת כל בקשה. עבור $_FILES היא קוראת ל-MISC::is_file_secure($file), אבל הפונקציה הזו מתחילה ב-return true; // Disable check. כל הלוגיקה שאחריה (רשימת MIME מותרים, בדיקת סיומת כפולה, בדיקת null byte, השוואת MIME בפועל לסיומת) היא קוד שלא רץ. אל תסתמכו עליה.
detect_sql_injection רצה על ערכי $_GET ועל מפתחות $_GET ו-$_POST, אבל לא על ערכי $_POST. פירוט והגבלות בעמוד מודל האבטחה.נקודות הכניסה להעלאה בליבה#
הטבלה מסכמת את המקומות שבהם הליבה מקבלת קובץ. כולם משתמשים ב-STORAGE::add() (ובכך בבדיקת הסיומות), מלבד אלה שמסומנים.
| נקודה | מי רשאי | הערות |
|---|---|---|
Tools::CKEditor_upload | מנהל (ADMIN::is_admin()) | העלאת תמונה מעורך הטקסט, מומרת ל-jpg |
Tools::CKEditor_upload_new | מנהל | לוגיקת הסוגים המותרים בה לא ברורה, אל תסתמכו עליה כמסנן |
Tools::CKEditor_upload2 | מנהל | |
Tools::save_url_to_storage | מנהל | מוריד קובץ מכתובת (ראו SSRF) |
Tools::gallery_upload, gallery_replace, GalleryActions | מנהל | גלריות, עם STORAGE::$gallery_* |
ADMINMODULE_storage ({admin}/storage) | מנהל | העלאה מפאנל המדיה |
FormInput_File | לפי הטופס | לא עובר דרך STORAGE, ראו למטה |
FormInput_drop | לפי הטופס | משתמש בתיקיית STORAGE::$temp_folder |
פאנלי newpage_media, poolse_media, target_media | מנהל | רשימת היתרים לפי MIME, ראו למטה |
FormInput_File ו-FormInput_drop#
FormInput_File (ב-libraries/forms/FormInputs.php) מעתיק את הקובץ ישירות ליעד, הסיומת נלקחת משם הקובץ שהלקוח שלח, ואין בו רשימת חסימה. אם שדה העלאה מוגדר בטופס שנגיש לא-מנהלים, בדקו בעצמכם סיומת וגודל. שדה FormInput_drop בונה נתיב מתיקיית הקבצים הזמנית (STORAGE::$temp_folder) ומשם קובץ שמגיע ב-$_POST, ולכן אם אתם מפעילים אותו בטופס נגיש לציבור, בדקו שהשם הוא שם בסיס בלבד. פרטים על שדות הטפסים בשדות טופס.
CropManager#
הקונטרולר CropManager מחבר את הפרמטרים imgName ו-pic מה-$_GET לנתיב קובץ, ואין בו בדיקת ADMIN::is_admin(); הוא נשען על טוקנים. אל תעתיקו את הדפוס הזה: בקוד משלכם נקו כל שם קובץ שמגיע מבקשה עם basename() לפני שאתם משתמשים בו בנתיב.
התמודדות עם סיכונים ספציפיים#
SSRF: הורדה מכתובת#
STORAGE::add() שמקבלת מחרוזת שמתחילה ב-http מורידה אותה עם cURL, כולל מעקב אחרי הפניות, ובלי הגבלת מארח. אם תעבירו לה כתובת שהמשתמש הקליד, השרת שלכם יפנה לכל כתובת שיבחר המשתמש, כולל כתובות פנימיות (127.0.0.1, כתובות רשת פרטית, מטא-דאטה של ענן). הליבה עצמה קוראת לה רק ממנהלים מאומתים. בקוד שלכם:
// אל תעשו: $url מגיע מטופס ציבורי
// STORAGE::add($_POST["url"]);
function safe_remote_host($url)
{
$p = parse_url($url);
if (!$p || !in_array($p["scheme"] ?? "", ["http", "https"], true)) return false;
$host = $p["host"] ?? "";
$ip = filter_var($host, FILTER_VALIDATE_IP) ? $host : gethostbyname($host);
// חוסם כתובות פרטיות ושמורות (127.x, 10.x, 192.168.x, 169.254.x ...)
return filter_var(
$ip,
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
) !== false;
}
if (safe_remote_host($url)) {
$id = STORAGE::add($url);
}
הבדיקה למעלה בודקת את הכתובת הראשונה בלבד, ו-cURL ב-STORAGE עוקב אחרי הפניות. לכן בהעלאות שמקבלות כתובת ממשתמשים לא מהימנים, הורידו את הקובץ בעצמכם (עם CURLOPT_FOLLOWLOCATION כבוי או עם בדיקה בכל קפיצה) ורק אחר כך העבירו את הנתיב המקומי ל-STORAGE::add().
SVG#
svg אינו חסום. קובץ SVG הוא XML שיכול להכיל <script>, ודפדפן שיפתח אותו ישירות מהדומיין שלכם יריץ אותו בהקשר של האתר (XSS). אם אתם מאפשרים העלאת SVG, הגישו אותו מדומיין נפרד, עם Content-Security-Policy: sandbox, או אל תאפשרו אותו. כשאתם משלבים תמונה בדף באמצעות תג <img>, סקריפט בתוך SVG לא רץ, אבל פתיחה ישירה של הקישור כן מריצה אותו.
הרצת קוד בתיקיית ההעלאות#
הגנה מלאה לא נשענת על סינון סיומות. היא נשענת על כך שהשרת לא מריץ קוד בתיקיית המדיה. ב-Apache:
# media/.htaccess
<FilesMatch "\.(?i:php\d?|phtml|pht|phar|inc|cgi|pl|py)$">
Require all denied
</FilesMatch>
RemoveHandler .php .phtml .phar
php_flag engine off
Options -ExecCGI -Indexes
הנתיב המדויק של תיקיית ההעלאות נקבע ב-STORAGE וב-CONFIG (ראו אחסון קבצים); ההגדרות המלאות לשרת מופיעות בהקשחה. שימו לב שהמחלקה FILES::make_dir יוצרת תיקיות בהרשאה 0777, ולכן חשוב שגם תהליכי השרת האחרים לא יכתבו שם קוד.
דפוס מומלץ: רשימת היתרים מעל STORAGE#
הדפוס שהליבה עצמה משתמשת בו בפאנלים newpage_media, poolse_media ו-target_media: בדיקת MIME לפי תוכן הקובץ (לא לפי השם), תקרת גודל, שם קובץ שמחושב מ-hash, והרשאה 0755. אפשר לשחזר אותו לפני קריאה ל-STORAGE::add():
class ADMINMODULE_my_upload extends bgl_controller
{
// MIME לפי תוכן => [סיומת, גודל מקסימלי בבתים]
private $allowed = [
"image/jpeg" => ["jpg", 2097152],
"image/png" => ["png", 2097152],
"image/webp" => ["webp", 2097152],
"application/pdf" => ["pdf", 5242880],
];
function upload()
{
if (!ADMIN::is_admin()) die("Access denied");
$_GET["pmode"] = "empg";
$f = $_FILES["file"] ?? null;
if (!$f || $f["error"] !== UPLOAD_ERR_OK || !is_uploaded_file($f["tmp_name"])) {
echo json_encode(["ok" => false, "error" => "no_file"]);
return;
}
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($f["tmp_name"]);
if (!isset($this->allowed[$mime]) || $f["size"] > $this->allowed[$mime][1]) {
echo json_encode(["ok" => false, "error" => "type_or_size"]);
return;
}
// הסיומת נקבעת מה-MIME שזוהה, לא משם הקובץ של הלקוח
$f["name"] = "upload." . $this->allowed[$mime][0];
$id = STORAGE::add($f, ["name" => pathinfo($_FILES["file"]["name"], PATHINFO_FILENAME)]);
echo json_encode(["ok" => (bool)$id, "id" => (int)$id]);
}
}
הכללים שבדוגמה:
- בדיקת
is_uploaded_fileמוודאת שהקובץ אכן הגיע בהעלאה ולא נתיב שהוזרק. - ה-MIME מזוהה מתוכן הקובץ בעזרת
finfo; הערך$_FILES["file"]["type"]מגיע מהלקוח ואין לסמוך עליו. - הסיומת הסופית נגזרת מה-MIME, כך ש-
shell.phpעם תוכן JPEG יישמר כ-jpg. - הגבלת גודל נעשית גם ב-
upload_max_filesizeוב-post_max_sizeשל PHP (לא בליבה).
האפשרות img_convert של STORAGE::add() עוברת על הקובץ עם getimagesize ואם הוא אינו תמונה מחזירה false, ובשאר המקרים יוצרת קובץ חדש בפורמט שביקשתם. זה לא מחליף בדיקת MIME, אבל זה מסנן נוסף להעלאות תמונה. האפשרויות מתוארות באחסון קבצים ובכלי תמונה.
רשימת בדיקה#
- שרת שלא מריץ קוד בתיקיית המדיה: זו ההגנה העיקרית.
- רשימת היתרים של MIME וגודל לכל נקודת העלאה שלכם, כי
STORAGEלא מספקת. - העלאות מאחורי
ADMIN::is_admin()או אימות משתמש; לא להשאיר נקודת העלאה אנונימית. - כתובות שמגיעות ממשתמשים לא עוברות ישירות ל-
STORAGE::add(). - SVG מוגש בבידוד או לא מתקבל בכלל.
- שם הקובץ של הלקוח הוא נתון לתצוגה בלבד, אף פעם לא חלק מנתיב.