הדף הזה הוא החוזה בין הליבה למי שכותב עליה קוד. הליבה נותנת כלים לכל שכבת הגנה, אבל מי שמפעיל אותם בכל מתודה הוא כותב הפאנל או הקונטרולר. כל כלל כאן מנוסח במשפט אחד, מוסבר במנגנון שעומד מאחוריו, ומלווה בקוד נכון ובקוד שגוי. בסוף הדף יש רשימת בדיקה להעתקה.
system/ בדף הזה הוא תיקיית הליבה הפרוסה באתר, ו-{admin} הוא CONFIG::$admin_url. הדוגמאות כתובות לפי המוסכמות של הליבה: פאנל הוא ADMINMODULE_<שם> שיורש מ-bgl_controller, קונטרולר של אתר הוא מחלקה שיורשת מ-wz_controller, שמות טבלאות ב-builder הם בלי CRM_, וב-SQL גולמי חייבים CRM_ ו-DB::escape.
1. כתיבה רק ב-POST#
הכלל: מתודה ששומרת, מוחקת או משנה מצב מסרבת לכל בקשה שאינה POST.
למה: בקשת GET נשלחת מכל תג <img>, מקישור או מתצוגה מקדימה בדפדפן. כתובת שמוחקת רשומה ב-GET מאפשרת לכל דף זר למחוק אותה בשם המנהל המחובר. כל המנגנונים שמגנים מפני זיוף בקשה (סעיף 2) מניחים שהכתיבה היא POST.
<?php
// application/admin/orders.php
class ADMINMODULE_orders extends bgl_controller
{
public function cancel()
{
if (($_SERVER["REQUEST_METHOD"] ?? "") !== "POST")
{
http_response_code(405);
return json_encode(["success" => false, "error" => "post_only"]);
}
// ... אימות, הרשאה, כתיבה
}
}
<?php
// שגוי: GET /{admin}/orders/cancel?id=7 מבטל הזמנה
public function cancel()
{
DB::update("orders")->set("status", "cancelled")->update((int)$_GET["id"]);
}
פעולות של הליבה עצמה (Form, AJAXForm, מחיקה ב-panel_table) נשלחות ב-POST ונשענות על כך שעוגיית admin_session נשלחת עם SameSite=Lax. מתודה מותאמת שאתם כותבים מוסיפה לזה את הכלל הבא.
2. כתיבה רק עם אסימון CSRF של הליבה#
הכלל: כל POST שמשנה מצב נושא אסימון, והמתודה מאמתת אותו לפני כל פעולה.
למה: SameSite=Lax חוסם את רוב המקרים אבל לא את כולם (למשל דפדפנים ישנים, ותתי-דומיינים של אותו אתר). אסימון הוא ערך סודי שדף זר לא יכול לדעת. בפאנל הניהול הליבה מספקת דפוס double-submit: ADMIN::generate_csrf_cookie() יוצרת אסימון (או מחזירה את הקיים מעוגיית admin_csrf), ו-ADMIN::verify_csrf_token($token) משווה אותו לעוגייה ב-hash_equals.
מגרסה 5.0.162 יש לפאנל הניהול גם שכבה כללית: בקשת POST, PUT, PATCH או DELETE שה-Origin שלה (או ה-Referer) אינו האתר עצמו לא מקבלת סשן מנהל. ממשק ניהול שיושב בכתובת נוספת נכנס לרשימה static $admin_trusted_origins ב-config.php. השכבה הזו לא מחליפה את האסימון: היא לא חלה על קונטרולרים של האתר, ובקשה בלי שתי הכותרות עוברת אותה.
<?php
public function index()
{
return $this->view("admin/orders", [
"csrf" => ADMIN::generate_csrf_cookie(),
]);
}
public function cancel()
{
if (($_SERVER["REQUEST_METHOD"] ?? "") !== "POST") { http_response_code(405); return ""; }
$data = json_decode((string)file_get_contents("php://input"), true);
if (!is_array($data)) $data = $_POST;
$token = $data["csrf"] ?? $_SERVER["HTTP_X_WZ_CSRF"] ?? "";
if (!ADMIN::verify_csrf_token((string)$token))
{
http_response_code(403);
return json_encode(["success" => false, "error" => "csrf"]);
}
// ...
}
בקריאת AJAX שולחים את האסימון בכותרת X-WZ-CSRF או בגוף הבקשה:
fetch("/" + adminUrl + "/orders/cancel", {
method: "POST",
headers: { "Content-Type": "application/json", "X-WZ-CSRF": csrf },
body: JSON.stringify({ id: orderId })
});
העוגייה תקפה שעה. אימות שנכשל מחייב לבקש אסימון חדש ולשלוח שוב.
באתר עצמו (לקוחות האתר, לא מנהלים) מייצרים אסימון בסשן משלכם: SESSION::set("csrf", bin2hex(random_bytes(32))) בהצגת הטופס, והשוואה עם hash_equals בשמירה. הדוגמה המלאה בCSRF, XSS ו-SQLi.
<?php
// שגוי: POST בלי אסימון, והאימות היחיד הוא ה-Referer
if (strpos($_SERVER["HTTP_REFERER"] ?? "", "example.co.il") === false) return "";
3. ולידציה בצד השרת לכל קלט#
הכלל: כל ערך שמגיע מ-$_GET, מ-$_POST, מגוף JSON, מעוגייה או מכותרת נבדק בצד השרת: טיפוס, טווח, ורשימת ערכים מותרים.
למה: בדיקה בדפדפן היא נוחות למשתמש ולא הגנה, כי מי ששולח בקשה ישירות עוקף אותה. גם Must() בשדה טופס בודק בצד השרת רק שהשדה לא ריק ($_POST[name] != ""), בלי שום בדיקת טיפוס או פורמט.
<?php
$email = trim((string)($_POST["email"] ?? ""));
$qty = filter_var($_POST["qty"] ?? null, FILTER_VALIDATE_INT, ["options" => ["min_range" => 1, "max_range" => 100]]);
$status = in_array($_POST["status"] ?? "", ["new", "paid", "cancelled"], true) ? $_POST["status"] : null;
if (!filter_var($email, FILTER_VALIDATE_EMAIL) || $qty === false || $qty === null || $status === null)
{
http_response_code(422);
return json_encode(["success" => false, "error" => "invalid"]);
}
<?php
// שגוי: הערכים נכנסים כמו שהם, והבדיקה היחידה היא "לא ריק"
$name = new FormInput_Text("qty");
$name->Must();
מתודה שמקבלת ערך מרשימה סגורה (סטטוס, סוג, כיוון מיון) משווה אותו לרשימה עם in_array(..., true). מתודה שמקבלת מספר ממירה אותו בבדיקה, לא בשליפה.
4. כל ערך ל-SQL עובר builder, (int) או DB::escape#
הכלל: קלט חיצוני לא מגיע ל-SQL בשום דרך אחרת: ערך ב-builder, מספר עם (int), או מחרוזת עם DB::escape בתוך מרכאות. שמות עמודות וכיווני מיון נבחרים מרשימה לבנה.
למה: DB::escape מנטרל מרכאות ולכן תקף רק לערך שנמצא בתוך מרכאות. מחוץ למרכאות הוא לא עוצר כלום. מפתח של מערך תנאים, order_by, filter ו-fields נכנסים ל-SQL כמו שהם. ומחרוזת לא מספרית ב-DB::get_val($table, $x) נחשבת WHERE גולמי.
<?php
$row = DB::query("orders", ["id" => (int)$id, "user_id" => (int)$uid])->get_row();
$allowed = ["created" => "created", "total" => "total"];
$col = $allowed[$_GET["sort"] ?? ""] ?? "created";
$dir = (($_GET["dir"] ?? "") === "asc") ? "ASC" : "DESC";
$rows = DB::query("orders", ["user_id" => (int)$uid], $col . " " . $dir)->get_all();
$rows = DB::get_all("SELECT id FROM CRM_orders WHERE ref = '" . DB::escape($ref) . "'");
<?php
// שגוי: שלוש דרכים שונות לפתוח הזרקה
$row = DB::get_val("orders", $_GET["id"]);
$rows = DB::get_all("SELECT * FROM CRM_orders WHERE id = " . DB::escape($_GET["id"]));
$rows = DB::query("orders", [], $_GET["sort"])->get_all();
הכללים המלאים, כולל LIKE, בCSRF, XSS ו-SQLi.
5. בדיקת הרשאה בכל פעולה, גם ב-AJAX וב-JSON#
הכלל: כל מתודה ציבורית של פאנל או קונטרולר בודקת בעצמה מי הקורא ומה מותר לו.
למה: בכניסה לפאנל AdminModule::GetPageContent בודק רק שלקבוצה של המנהל יש גישה לפאנל. מכאן שכל מתודה ציבורית בפאנל פתוחה לכל מנהל שרשאי להיכנס אליו. מגרסה 5.0.162 שמירה דרך Form, שינוי סדר ועריכה מהירה ב-panel_table נבדקים בעצמם מול הרשאת edit של הפאנל (ADMIN::may_save()), אבל מתודה שכתבתם בעצמכם ועונה JSON או מבצעת פעולה לא עוברת את הבדיקה הזו. לכן בודקים במפורש, עם שם הפאנל ועם "index", שם נשמרים הדגלים edit, delete ו-export. בקונטרולר של אתר אין שער כלל, וכל מתודה פתוחה לכל גולש.
<?php
public function cancel()
{
if (!ADMIN::has_perms("edit", "orders", "index"))
{
http_response_code(403);
return json_encode(["success" => false, "error" => "forbidden"]);
}
// ...
}
<?php
// קונטרולר של אתר: רק משתמש מחובר
class account extends wz_controller
{
function save()
{
if (!LOGIN::is_logged()) { http_response_code(401); return ""; }
// ...
}
}
בגרסאות קודמות ADMIN::has_perms("edit") בתוך דף שאין לו שורה משלו ב-panels_options (למשל insert) החזיר true לכל מנהל. מגרסה 5.0.162 הוא מחזיר את ההרשאה של הפאנל. אתר שזקוק זמנית להתנהגות הישנה מגדיר static $admin_legacy_perms = true ב-config.php. הקריאה המפורשת עם "index" נכונה בכל הגרסאות.
מתודת עזר שלא אמורה להיות כתובת מוגדרת private או protected. הנושא כולו בהרשאות וקבוצות.
6. לא סומכים על מזהים שהלקוח שולח כשיוך#
הכלל: מי הבעלים של רשומה נקבע בשרת: מהמשתמש המחובר ומהרשומה השמורה, לא משדה בבקשה.
למה: שדה user_id או owner בטופס הוא ערך שהקורא בוחר. מי שמחליף את id=7 ב-id=8 מקבל את ההזמנה של מישהו אחר, אלא אם השרת בודק מי הבעלים.
<?php
if (!LOGIN::is_logged()) { http_response_code(401); return ""; }
$uid = (int)LOGIN::get_id();
$order = DB::query("orders", ["id" => (int)($_POST["id"] ?? 0), "user_id" => $uid])->get_row();
if (!$order) { http_response_code(404); return ""; }
DB::update("orders")->set("note", $note)->update((int)$order["id"]);
<?php
// שגוי: הבעלים נלקח מהבקשה, והעדכון חל על כל id
$uid = (int)$_POST["user_id"];
DB::update("orders")->set("note", $_POST["note"])->update((int)$_POST["id"]);
בפאנל הניהול הכלל זהה: כל הגבלה על מה שמנהל רשאי לראות או לשנות נבדקת מול הרשומה השמורה ומול ADMIN::get_id(), לא מול פרמטר מהבקשה.
7. אסימונים חתומים, לא base64 ולא serialize#
הכלל: ערך שחוזר מהלקוח ושהשרת סומך עליו (קישור אישור, איפוס, התחזות, מזהה בעוגייה) נוצר עם MISC::sign() ונקרא עם MISC::decode_token().
למה: מגרסה 5.0.147 MISC::sign($value) מצפינת ומאמתת (AES-256-GCM) במפתח שנגזר מפרטי ה-DB של האתר ומהמפתחות של MISC, כך שרק האתר עצמו יכול ליצור טוקן, וטוקן ששונה או שהגיע מאתר אחר נדחה. base64_encode הוא קידוד ולא הגנה, ו-MISC::encode / MISC::decode הן הצפנה בלי אימות, שמשתמשת במפתח ברירת מחדל עד שמגדירים אחר.
<?php
$token = MISC::sign([
"purpose" => "unsubscribe",
"uid" => (int)$uid,
"exp" => time() + 7 * 86400,
]);
$link = "/newsletter/unsubscribe?t=" . rawurlencode($token);
// בצד הקבלה
$data = MISC::decode_token((string)($_GET["t"] ?? ""));
if (!is_array($data) || ($data["purpose"] ?? "") !== "unsubscribe" || (int)($data["exp"] ?? 0) < time())
{
http_response_code(400);
return "";
}
הערכים החשובים:
- הטוקן לא נושא תפוגה ולא ייעוד. כל מה שחשוב (מטרה, מזהה, זמן תפוגה) נכנס לערך ונבדק אחרי הפענוח, כמו בדוגמה.
decode_tokenמחזירהfalseלטוקן מזויף, ששונה או של אתר אחר.- שמים בערך מספרים, מחרוזות ומערכים. אובייקטים לא נשמרים.
- החלפת מפתחות ההצפנה או פרטי ה-DB מבטלת את כל הטוקנים שהונפקו. טוקן בפורמט הישן מתקבל בחלון מעבר של 30 יום (
CONFIG_USER::$legacy_tokensמשנה זאת).
<?php
// שגוי: כל מי שמכיר את הפורמט מייצר קישור תקף
$link = "/reset?u=" . base64_encode(serialize(["uid" => $uid]));
8. אין unserialize על קלט#
הכלל: קלט חיצוני נקרא ב-json_decode, לא ב-unserialize.
למה: unserialize על מחרוזת שהמשתמש שולט בה יוצר אובייקטים של מחלקות שהוא בחר, ושיטות הקסם שלהן רצות. json_decode מחזיר רק מערכים וערכים פשוטים.
<?php
$payload = json_decode((string)($_POST["data"] ?? ""), true);
if (!is_array($payload)) { http_response_code(400); return ""; }
// אם חייבים לפענח ערך ש-serialize יצר בשרת
$value = @unserialize($stored, ["allowed_classes" => false]);
<?php
// שגוי
$value = unserialize(base64_decode($_COOKIE["prefs"]));
ערכי SESSION::set נשמרים בצורה מסודרת (serialize) ונקראים חזרה, לכן שומרים בסשן ערך שאימתם (מספר, ערך מרשימה) ולא מחרוזת גולמית מהמשתמש.
9. העלאות קבצים רק דרך STORAGE ובכללי ההעלאה#
הכלל: קובץ שהמשתמש שולח עובר STORAGE::add() אחרי רשימת היתרים משלכם לסיומת, למימד ולגודל. אין move_uploaded_file ישיר לתיקייה שהשרת מגיש.
למה: STORAGE::add() נותנת לקובץ שם פנימי (uuid או מזהה), שומרת את השם המקורי בעמודה בלבד, ורושמת אותו בטבלה. חסימת הסיומות שבתוכה היא רשימת חסימה של סיומות הרצה מוכרות, ולא רשימת היתרים, ואין בה בדיקת גודל או MIME. לכן הבדיקה של האתר שלכם היא החלק החיוני. אל תסתמכו על MISC::is_file_secure ועל MISC::init_security כשכבת הגנה: הם סינון בסיסי בלבד. הפרטים בכללי העלאת קבצים.
<?php
$file = $_FILES["image"] ?? null;
$ext = strtolower(pathinfo((string)($file["name"] ?? ""), PATHINFO_EXTENSION));
if (!$file || $file["error"] !== UPLOAD_ERR_OK
|| !in_array($ext, ["jpg", "jpeg", "png", "webp"], true)
|| $file["size"] > 5 * 1024 * 1024
|| @getimagesize($file["tmp_name"]) === false)
{
http_response_code(422);
return json_encode(["success" => false, "error" => "bad_file"]);
}
$id = STORAGE::add($file, ["object_type" => "order", "object_id" => (int)$order["id"]]);
<?php
// שגוי: הסיומת והנתיב מגיעים מהלקוח, והקובץ נשמר בתיקיית האתר
move_uploaded_file($_FILES["f"]["tmp_name"], "media/" . $_FILES["f"]["name"]);
שני כללי עזר: לא מעבירים ל-STORAGE::add() כתובת שהמשתמש הקליד (היא מורידה כל כתובת, כולל פנימית), ושם קובץ זמני שהגיע מהדפדפן נבדק עם STORAGE::temp_file($name), שמחזירה null לכל מה שאינו שם בסיס פשוט.
10. כל פלט עובר escape#
הכלל: משתנה שמקורו במשתמש או בבסיס הנתונים מודפס עם |escape בתבנית, עם MISC::special_chars() ב-PHP, או עם json_encode בדגלי JSON_HEX_* בתוך <script>.
למה: Smarty בליבה לא עושה escape אוטומטי. ערך עם <script> שמודפס כמו שהוא רץ בדפדפן של מי שצופה בו, כולל מנהל, ושם יש לו את הסשן שלו.
<h1>{$title|escape}</h1>
<a href="/search?q={$q|escape:'url'}">{$q|escape}</a>
<script>var cfg = {$cfg_json};</script>
<?php
echo '<span title="' . MISC::special_chars($name) . '">' . MISC::special_chars($name) . '</span>';
$cfg_json = json_encode($cfg, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
{* שגוי: ערך של משתמש כמו שהוא *}
<h1>{$title}</h1>
HTML מכוון (תוכן מעורך הטקסט) מודפס בלי escape רק כשהכותב הוא מנהל מהימן. עוגיות האתר (COOKIES::set) נשלחות בלי HttpOnly, ולכן escape הוא גם ההגנה על הסשן של הגולש.
11. אין סודות ב-git#
הכלל: סיסמאות, מפתחות API ומפתחות הצפנה לא נכנסים לקוד, ל-config.php שנשמר בגרסאות או להיסטוריה. הם נטענים מקובץ מחוץ לשורש האתר, או מהסביבה.
למה: מה שנכנס להיסטוריית גרסאות נשאר בה גם אחרי שמוחקים אותו. מפתחות ההצפנה של MISC ופרטי ה-DB הם גם הבסיס של האסימונים החתומים (סעיף 7), כך שדליפה שלהם היא דליפה של היכולת לזייף טוקן.
<?php
// application/includes/init.php
$keys = require CONFIG::$base_path . "/../secrets/wizzo_keys.php"; // מחוץ ל-docroot
MISC::set_encrypt_secret_keys($keys["key"], $keys["iv"]);
CRYPT::set_secret_key($keys["crypt"]);
printf 'config.php\nsecrets/\n.env\nsqllog.txt\n' >> .gitignore
git ls-files | grep -E 'config\.php|\.env|secrets/' # חייב להחזיר ריק
<?php
// שגוי: סוד בקוד
$apiKey = "<המפתח עצמו כתוב כאן>";
סוד שנחשף מוחלף, לא רק נמחק: מנפיקים חדש, מעדכנים את האתר, ומבטלים את הישן. ההנחיות המלאות בצ'קליסט הקשחה.
12. שגיאות לא מוצגות לגולשים#
הכלל: בייצור display_errors כבוי, שגיאות נכתבות ליומן, והגולש מקבל הודעה כללית בלבד.
למה: הודעת שגיאה חושפת שמות טבלאות, נתיבים ולפעמים חלקי שאילתה. היא כלי עבודה של מי שמחפש פרצה. מיומן השגיאות (פאנל error_log) אפשר לאבחן הכול.
<?php
try
{
// ... עבודה
}
catch (\Throwable $e)
{
error_log("orders/cancel: " . $e->getMessage());
http_response_code(500);
return json_encode(["success" => false, "error" => "server"]);
}
<?php
// שגוי
ini_set("display_errors", 1);
return json_encode(["success" => false, "error" => $e->getMessage()]);
13. רושמים כל קונטרולר וכל פאנל חדש#
הכלל: קובץ חדש שלא נרשם לא קיים עבור המערכת: קונטרולר צריך שורה ב-CRM_modules, ופאנל צריך שורה ב-CRM_adminPanel_panels.
למה: MODULE::get_html טוען קונטרולר רק אם יש שורה פעילה, ו-GetPageContent מסרב לפאנל בלי שורה. הרישום הוא גם ההחלטה מי רואה את הפאנל: פאנל רגיש נרשם עם perm_developer = 1, והרשאה לקבוצות אחרות ניתנת במפורש.
INSERT INTO CRM_modules (moduleName, active) VALUES ('news', 1);
<?php
cache_engine::remove("modules"); // אחרי הוספה ידנית
<?php
$p = DB::update("adminPanel_panels");
$p->set("panel_name", "orders");
$p->set("text", "הזמנות");
$p->set("category", 1);
$p->set("perm_developer", 0);
$p->insert();
אין DB::insert: הכנסה היא DB::update("table")->set("col", $v)->insert(). פירוט הרישום בבניית פאנל ובקונטרולרים של אתר.
14. כתובות של קונטרולרי ליבה מתחילות ב-system/#
הכלל: קונטרולר של הליבה נקרא בכתובת /system/<שם>, ו-system/ הוא קידומת שמורה: מודול של האתר בשם system לא נגיש. קוד שלכם לא נכתב בתוך system/core, כי תיקיית הליבה מוחלפת בכל עדכון.
למה: קונטרולר מערכת נטען כל עוד הקובץ קיים, וכל מתודה ציבורית שלו היא נקודת קצה בלי שער הרשאות מרכזי. מה שרלוונטי לכם הוא הכלל ההפוך: נקודת קצה שאתם חושפים כתובה ב-application/controllers, ובודקת בעצמה מי קורא, באחת משתי דרכים. נקודת קצה לגולשים מחוברים או למנהלים בודקת זהות. נקודת קצה של cron או של שירות חיצוני בודקת טוקן בהשוואה בטוחה.
<?php
// application/controllers/nightly.php -> /nightly/run
class nightly extends wz_controller
{
function run()
{
$_GET["pmode"] = "empg";
$sent = (string)($_GET["tk"] ?? "");
$real = (string)PARAMS::get("nightly_token");
if ($real === "" || !hash_equals($real, $sent))
{
http_response_code(403);
return "";
}
// ...
}
}
<?php
// שגוי: השוואה רגילה, ובלי מקרה שבו הטוקן המוגדר ריק
if ($_GET["tk"] == PARAMS::get("nightly_token")) { /* ... */ }
פרטים על קונטרולרי הליבה בקונטרולרי מערכת.
רשימת בדיקה לפני כל פריסה#
מעתיקים לתיאור ה-PR או למסמך הפריסה, ועוברים סעיף סעיף.
[ ] כל מתודה שכותבת או מוחקת מסרבת לבקשה שאינה POST
[ ] כל POST כזה מאמת אסימון CSRF (ADMIN::verify_csrf_token, או אסימון סשן באתר)
[ ] כל קלט נבדק בשרת: טיפוס, טווח, רשימת ערכים מותרים (לא רק Must())
[ ] אין ערך חיצוני ב-SQL שלא עבר builder, (int) או DB::escape בתוך מרכאות
[ ] order_by, שמות עמודות וכיווני מיון נבחרים מרשימה לבנה
[ ] אין DB::get_val עם מחרוזת מהמשתמש
[ ] כל מתודה ציבורית בודקת הרשאה בעצמה: ADMIN::has_perms("edit", "<panel>", "index"), LOGIN::is_logged() או טוקן
[ ] מתודות עזר הן private או protected
[ ] בעלות על רשומה נקבעת בשרת, לא לפי user_id או owner שנשלחו בבקשה
[ ] אסימונים וקישורים אישיים נוצרים עם MISC::sign ונקראים עם MISC::decode_token, וערך הטוקן נושא ייעוד ותפוגה
[ ] אין unserialize על קלט (json_decode, או allowed_classes => false)
[ ] העלאות: רשימת היתרים לסיומת, גודל ותמונה אמיתית, ואז STORAGE::add; אין כתובת מהמשתמש
[ ] כל משתנה בתבנית עובר |escape, ופלט ב-<script> עובר json_encode עם JSON_HEX_*
[ ] אין סוד, מפתח או סיסמה בקוד או ב-git, ו-git ls-files נקי מ-config.php ומ-secrets
[ ] display_errors כבוי, והגולש לא רואה הודעת שגיאה של PHP או של ה-DB
[ ] קונטרולר חדש רשום ב-CRM_modules ו-cache_engine::remove("modules") רץ; פאנל חדש רשום ב-CRM_adminPanel_panels
[ ] נקודת קצה של cron או שירות בודקת טוקן עם hash_equals, וטוקן ריק נדחה
[ ] הקוד לא נכתב בתוך system/core, ואין DB::insert בשום מקום