אבטחת העלאות

מה באמת מגן על העלאת קבצים ב-WIZZO CMS: רשימת הסיומות החסומות של STORAGE, בדיקת ה-WAF שכבויה, נקודות הכניסה להעלאה, SSRF בהורדה מכתובת, SVG, והדוגמה הנכונה לרשימת היתרים.

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

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

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 בפועל לסיומת) היא קוד שלא רץ. אל תסתמכו עליה.

בדיקות ה-WAF האחרות
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 מוגש בבידוד או לא מתקבל בכלל.
  • שם הקובץ של הלקוח הוא נתון לתצוגה בלבד, אף פעם לא חלק מנתיב.

ראו גם#

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