في مقال OWASP Top 10 للمبتدئين رأينا خريطة المخاطر، أي ما الذي يحدث عندما يخطئ المطور، وفي مقال ثغرات الويب بالتجربة نكسر تطبيقاً ثم نصلحه ثغرة ثغرة. أما هذا المقال فيجيب عن السؤال الذي يأتي بعدهما، وهو السؤال الذي يسأله المطور وهو يكتب الكود في يوم عمل عادي: كيف أكتب هذا السطر بحيث لا يحتاج إصلاحاً لاحقاً؟ فالمطور لا يفكر وهو يكتب في «الفئة A05»، وإنما يفكر في «هذا الحقل جاء من المستخدم، وأنا سوف أضعه في استعلام، ثم في صفحة، ثم في اسم ملف»، وكل واحد من هذه الأماكن له قاعدته الخاصة. والبرمجة الآمنة Secure Coding هي مجموعة هذه القواعد الصغيرة، مكتوبة بلغة الكود الذي تكتبه كل يوم، وليست قائمة مخاطر تحفظها.
ولأن أغلب من يقرأ لنا يكتب بلغة C# أو Java، ومعهم من يكتب بلغة Python أو JavaScript، فكل مبدأ في هذا المقال يأتي بمثال ضعيف Vulnerable ومثال مصلح Fixed في لغتين على الأقل من الأربع، مع تبديل اللغات بين الأقسام، وأهم الأنماط شغلناها فعلاً في تجربة عملية Lab ووضعنا مخرجاتها الحقيقية.
وسوف نناقش في هذا المقال ما يلي:
- حدود الثقة Trust Boundaries، أي أين تبدأ البيانات غير الموثوقة وأين تنتهي.
- التحقق من المدخلات Input Validation بقائمة مسموحة Allowlist ومكتبات المخططات Schema Validation في كل لغة، والتوحيد Canonicalization قبل التحقق.
- ترميز المخرجات Output Encoding حسب السياق، والترميز التلقائي في Razor وThymeleaf وJinja2 وReact، والاستعلامات ذات المعاملات ومنافذ SQL الخام Raw SQL.
- تشغيل أوامر النظام دون Shell، وتجاوز المسار Path Traversal، ورفع الملفات، وفك التسلسل Deserialization، ومحللات XML.
- معالجة الأخطاء Error Handling والفشل المغلق Fail Closed، والإعدادات الآمنة افتراضياً، والتزامن Concurrency وثغرات TOCTOU، والاعتماديات.
- قائمة تحقق Checklist جاهزة للنسخ لكل لغة من اللغات الأربع.
ولاحظ أن هذا المقال يملك الأنماط فقط، أما الموضوعات التي لها مقالات مستقلة فنكتفي بالإشارة إليها: التشفير وكلمات المرور والعشوائية في مقال التشفير للمطورين، والجلسات والتوكنات في دليل الجلسات وJWT، والصلاحيات في دليل الصلاحيات المركزية Authorization، والسجلات في دليل السجلات Logging، والمتطلبات ومراجعة الكود في مقال OWASP ASVS.
حدود الثقة Trust Boundaries: من أين تأتي البيانات غير الموثوقة؟
حد الثقة Trust Boundary هو كل مكان تنتقل فيه البيانات من جهة لا تتحكم فيها إلى جهة تتحكم فيها، أو العكس. والجهة الأولى ليست المتصفح فقط كما يظن أغلبنا، وإنما كل ما يصل إلى الكود من الخارج: جسم الطلب Body، ومعاملات الرابط Query String، والترويسات Headers، وملفات تعريف الارتباط Cookies، واسم الملف المرفوع ومحتواه، والحقول داخل توكن JWT قبل التحقق من توقيعه، والرسائل القادمة من خدمة أخرى عبر Webhook أو طابور رسائل Message Queue. وحد الثقة الثاني الذي ينساه الكثير هو حد الخروج، أي اللحظة التي يرسل فيها كودك نصاً إلى مفسر Interpreter آخر: قاعدة البيانات، والمتصفح، وال Shell، ونظام الملفات، وخادم LDAP، ومحلل XML.
والصورة التالية تبين الحدين حول تطبيق واحد، وما الذي يحدث على كل حد:

وقد يتساءل البعض: البيانات التي أقرؤها من قاعدة بياناتي موثوقة، أليس كذلك؟ والإجابة أن ذلك يعتمد على من كتبها، فاسم العميل الذي أدخله العميل بنفسه قبل سنة وحفظ في قاعدة البيانات ما زال نصاً كتبه مستخدم، ولو عرضته في صفحة دون ترميز فهذه هي البرمجة عبر المواقع المخزنة Stored XSS، وتوثيق Microsoft عن XSS يذكر صراحة أن البيانات غير الموثوقة تشمل «even data sourced from a database». لذلك فالقاعدة العملية أن الثقة تتبع مصدر الكتابة، وليس مكان التخزين.
والخطوة الثانية في الصورة، أي القيم المكتوبة بأنواعها Typed Values، هي أهم عادة في هذا المقال كله، والسبب أن النص الخام String الذي يمر في كل طبقات التطبيق يصل إلى كل مفسر بالشكل نفسه، أما إذا حولته عند الحد إلى int أو enum أو كلاس EmailAddress أو Path متحقق منه، فإن كل طبقة بعده تعمل بقيمة لها معنى ولا يمكن أن تكون شيئاً آخر.
تجهيز التجربة Lab: أربع لغات على جهاز واحد
الأمثلة في هذا المقال قصيرة حتى تبقى الفكرة واضحة، ولكن الأنماط التي تحتاج دليلاً شغلناها فعلاً، وفي كل قسم سوف تجد الكود الذي شغلناه ثم المخرج الحقيقي. وقد استخدمنا ما يلي:
- Python: Docker Image الرسمية
python:3.12-slim(Python 3.12.15)، ومعها PyYAML 6.0.3 وpydantic 2.13.5 وJinja2 3.1.6 مثبتة داخل ال Container. - Java: Docker Image الرسمية
eclipse-temurin:25-jdk(OpenJDK 25.0.4.1)، وتشغيل الملف مباشرة بالأمرjava Lab.javaدون بناء مشروع. - C#: مشروع Console على .NET 10 (الإصدار 10.0.11 من ال Runtime) على جهاز Windows نفسه.
- JavaScript: Node.js v22.14.0 على جهاز Windows نفسه، ومعه zod 4.6.5 وjs-yaml 5.4.3.
ولتجارب الملفات والأوامر في Python وJava جهزنا داخل كل Container مجلداً للملفات المرفوعة وملف إعداد سري بجانبه، وهو ملف تجريبي لا يحتوي على أي سر حقيقي، وذلك بالملف setup.sh التالي:
mkdir -p /data/uploads /data/config
echo "Q3 sales report" > /data/uploads/report.txt
echo "db_password=lab-only-not-real" > /data/config/app.secretثم شغلنا كل تجربة في Container مؤقت يحذف نفسه بعد الانتهاء، والمجلد الذي فيه الكود مربوط للقراءة فقط، كما يلي:
docker run --rm --name lab-securecode-py -v "$PWD/py:/lab:ro" python:3.12-slim \
sh -c "sh /lab/setup.sh && pip install -q pyyaml pydantic jinja2 && python /lab/lab2.py"
docker run --rm --name lab-securecode-java -v "$PWD/java:/lab:ro" eclipse-temurin:25-jdk \
sh -c "sh /lab/setup.sh && java /lab/Lab.java"التحقق من المدخلات Input Validation
لنفرض أن لديك Endpoint ينشئ فاتورة، ويستقبل رقم العميل والعملة والمبلغ. الطريقة الأولى التي يكتبها أغلبنا هي ألا نتحقق من شيء، فنقرأ الحقول ونمررها إلى قاعدة البيانات، ونترك الأخطاء تظهر حيث تظهر. والطريقة الثانية، وهي التي تبدو «أمنية»، أن نبحث عن الحروف الخطرة ونحذفها، مثل حذف <script> وعلامة الاقتباس من كل نص.
لماذا لا تنفع قائمة الممنوعات Denylist؟
القائمة الممنوعة Denylist تحاول أن تعد كل ما هو خطر، والمهاجم يحتاج فقط إلى شيء واحد لم تعده: <img onerror=…> بدل <script>، أو حروف كبيرة، أو ترميز مضاعف، أو حرف Unicode يشبه الحرف الممنوع. ثم إن الحذف الصامت يكسر البيانات الصحيحة، فالسيد O'Brien يفقد علامة اقتباس اسمه، والمبرمج الذي يكتب عن <script> في منتدى تقني يجد كلامه ناقصاً. ولذلك يقول OWASP Input Validation Cheat Sheet إن الاستراتيجية الصحيحة أن تحدد ما يقبله التطبيق وترفض كل ما سواه، أي القائمة المسموحة Allowlist، وإن التحقق يكون على مستويين: نحوي Syntactic، أي النوع والشكل والطول، ودلالي Semantic، أي أن القيمة منطقية في سياقها، مثل أن يكون تاريخ النهاية بعد تاريخ البداية.
ويجدر الإشارة هنا إلى أمرين يذكرهما المرجع نفسه: الأول أن التحقق في المتصفح لتجربة المستخدم فقط، أما التحقق الذي يحمي فهو على السيرفر دائماً، والثاني أن التحقق من المدخلات لا يمنع وحده حقن SQL ولا XSS، فهو طبقة تقلل ما يصل إلى الكود، أما المنع فهو بالاستعلامات ذات المعاملات وترميز المخرجات كما سيأتي. والسبب أن حقل الاسم الحر يجب أن يقبل علامة الاقتباس، والحقل الذي يقبلها لا يستطيع التحقق أن يحميه من الحقن.
التحقق بالمخطط Schema Validation في كل لغة
بدل أن تكتب شروطاً يدوية متفرقة في كل Endpoint، صف شكل الطلب مرة واحدة في كلاس أو مخطط Schema، ودع المكتبة ترفض ما لا يطابقه قبل أن يصل إلى منطق العمل. والجدول التالي يبين المكتبة الشائعة في كل لغة، وقد تحققنا من تراخيصها من مستودعاتها الرسمية:
| اللغة | المكتبة | الترخيص License | ملاحظة |
|---|---|---|---|
| C# | DataAnnotations المدمجة، وFluentValidation | جزء من .NET، وFluentValidation بترخيص Apache-2.0 | الخاصية [ApiController] ترد تلقائياً بخطأ 400 عند فشل التحقق |
| Java | Jakarta Bean Validation وتنفيذها المرجعي Hibernate Validator | Apache-2.0 للمواصفة والتنفيذ | تعمل مع @Valid في Spring وJakarta EE |
| Python | Pydantic | MIT | أساس التحقق في FastAPI |
| JavaScript وTypeScript | zod | MIT | المخطط نفسه يعطيك النوع Type في TypeScript |
والمثال التالي بلغة Python مع Pydantic هو ما شغلناه في التجربة، ونرسل فيه طلباً فيه ثلاث مشكلات: عملة غير مسموحة، ومبلغ سالب، وحقل إضافي is_paid لم نطلبه:
from typing import Literal
from pydantic import BaseModel, Field, ValidationError
class NewInvoice(BaseModel):
model_config = {"extra": "forbid"} # unknown fields are an error, not ignored
customer_id: int = Field(gt=0)
currency: Literal["SAR", "USD"] # allowlist
amount: float = Field(gt=0, le=1_000_000)
try:
NewInvoice.model_validate({"customer_id": "12", "currency": "EUR", "amount": -5, "is_paid": True})
except ValidationError as e:
for err in e.errors():
print(f" {err['loc'][0]:12} {err['msg']}")
print(" ok:", NewInvoice.model_validate({"customer_id": "12", "currency": "SAR", "amount": 420}))والمخرج سوف يكون كما يلي:
currency Input should be 'SAR' or 'USD'
amount Input should be greater than 0
is_paid Extra inputs are not permitted
ok: customer_id=12 currency='SAR' amount=420.0والمخطط نفسه بلغة JavaScript مع zod، وقد شغلناه بالطلب نفسه:
import { z } from "zod";
const NewInvoice = z.strictObject({ // unknown keys are rejected
customerId: z.number().int().positive(),
currency: z.enum(["SAR", "USD"]),
amount: z.number().positive().max(1_000_000),
});
const r = NewInvoice.safeParse({ customerId: "12", currency: "EUR", amount: -5, isPaid: true });
for (const i of r.error.issues) console.log(` ${(i.path.join(".") || "(object)").padEnd(12)} ${i.message}`);والمخرج سوف يكون كما يلي:
customerId Invalid input: expected number, received string
currency Invalid option: expected one of "SAR"|"USD"
amount Too small: expected number to be >0
(object) Unrecognized key: "isPaid"في المخرجين أعلاه لاحظ التالي:
- المكتبتان أعادتا كل الأخطاء مرة واحدة، فيستطيع ال API أن يرد بخطأ 422 أو 400 فيه قائمة كاملة بدل أن يكتشف المستخدم الأخطاء واحداً واحداً.
- الحقل الإضافي رفض في الحالتين، وهذا ما يحميك من التعيين الجماعي Mass Assignment، أي أن يرسل المستخدم حقلاً مثل
is_paidأوroleفيصل إلى الكائن الذي يحفظ في قاعدة البيانات، وقد شرحنا هذا الخطر في قسم تفويض مستوى خصائص الكائن BOPLA. - الفرق بين المكتبتين في رقم العميل: Pydantic حولت النص
"12"إلى الرقم 12 لأنها تعمل افتراضياً في الوضع المرن Lax Mode، وzod رفضته لأنها لا تحول الأنواع إلا إذا طلبت ذلك. وكلاهما مقبول، والمهم أن تعرف سلوك مكتبتك، فإذا أردت الصرامة في Pydantic ففيها الوضع الصارم Strict Mode.
وفي C# يكفي في أغلب الحالات DataAnnotations على كلاس الطلب، مع [ApiController] الذي يرد بخطأ 400 تلقائياً كما في توثيق ASP.NET Core Web API، وعندما تكبر القواعد أو تحتاج قاعدة تعتمد على أكثر من حقل فمكتبة FluentValidation أوضح:
// DTO: only the fields the client may send, nothing else can be bound
public record NewInvoice(
[property: Range(1, int.MaxValue)] int CustomerId,
[property: Required, RegularExpression("^(SAR|USD)$")] string Currency,
[property: Range(0.01, 1_000_000)] decimal Amount);
// The same rules with FluentValidation, plus a rule across two fields
public class NewInvoiceValidator : AbstractValidator<NewInvoice>
{
public NewInvoiceValidator()
{
RuleFor(x => x.CustomerId).GreaterThan(0);
RuleFor(x => x.Currency).Must(c => c is "SAR" or "USD");
RuleFor(x => x.Amount).GreaterThan(0).LessThanOrEqualTo(1_000_000);
RuleFor(x => x.Amount).LessThanOrEqualTo(10_000)
.When(x => x.Currency == "USD").WithMessage("USD invoices above 10,000 need approval");
}
}وفي Java مع Spring تكتب القواعد على Record الطلب بخصائص Jakarta Bean Validation، ثم تطلب التحقق بالخاصية @Valid:
public record NewInvoice(
@Positive long customerId,
@NotNull @Pattern(regexp = "SAR|USD") String currency,
@NotNull @Positive @DecimalMax("1000000") BigDecimal amount) {}
@PostMapping("/invoices")
ResponseEntity<?> create(@Valid @RequestBody NewInvoice req) { // invalid body: 400 before this line runs
return ResponseEntity.ok(invoices.create(req));
}ولاحظ في الأمثلة كلها أن كلاس الطلب DTO منفصل عن كلاس قاعدة البيانات Entity، فلا تربط الطلب مباشرة بكلاس فيه حقول مثل IsPaid أو OwnerId، والسبب أن أي حقل في الكلاس المربوط يستطيع المستخدم أن يرسله.
وإذا استخدمت التعبيرات النمطية Regex في التحقق، كما في حقل العملة أعلاه، فاجعلها مثبتة من البداية إلى النهاية، وانتبه إلى أن بعض الأنماط تستهلك وقتاً هائلاً مع مدخلات معينة، وهي ثغرة حجب الخدمة بالتعبيرات النمطية ReDoS، وقد شرحناها مع حلولها في مقال التعبيرات النمطية Regex للمبتدئين.
التوحيد قبل التحقق Canonicalization
للنص الواحد أكثر من شكل، فالمسار ../ يمكن أن يصل مرمزاً %2e%2e%2f أو مرمزاً مرتين %252e%252e%252f، وحرف Unicode قد يكتب بنقطة رمز Code Point واحدة أو بحرف وعلامة مركبة، وبعض الحروف العريضة Fullwidth تتحول إلى حروف عادية عند التطبيع. والخطأ الشائع أن يتحقق الكود من الشكل الأول، ثم تفك طبقة أخرى الترميز أو تطبع النص بعد التحقق، فيصل إلى المفسر شيء لم يره التحقق أبداً. ولذلك فالقاعدة هي التوحيد Canonicalization أولاً، أي أن تحول النص إلى شكله النهائي الوحيد، ثم تتحقق منه، ثم تستخدم الشكل نفسه الذي تحققت منه، وهذا ما يطلبه OWASP Input Validation Cheat Sheet حين يقول إن سياسة التطبيع Normalization يجب أن تطبق قبل التحقق والتخزين والمقارنة بالطريقة نفسها.
والمثال التالي بلغة Python ثم C# يبين الفكرة على اسم مستخدم:
import unicodedata
from urllib.parse import unquote
def clean_username(raw: str) -> str:
value = unicodedata.normalize("NFKC", raw).strip() # one canonical form, decoded once by the framework
if not re.fullmatch(r"[a-z0-9_.]{3,32}", value):
raise ValueError("invalid username")
return value # use THIS value from now on, not raw
# Vulnerable: decoding again after the check gives a second chance to "../"
name = request.args["file"] # the framework already decoded it once
if ".." not in name:
path = unquote(name) # %2e%2e%2f becomes ../ here, after the check// Normalize, then validate, then keep only the normalized value
string Clean(string raw)
{
var value = raw.Normalize(NormalizationForm.FormKC).Trim();
if (!Regex.IsMatch(value, "^[a-z0-9_.]{3,32}$"))
throw new ValidationException("invalid username");
return value;
}ولاحظ أن التوحيد في المسارات له شكل خاص، فهو تحويل المسار إلى مساره الحقيقي الكامل Canonical Path بعد حل .. والروابط الرمزية Symbolic Links، وسوف نراه في التجربة بعد قليل في قسم المسارات.
ترميز المخرجات حسب السياق Output Encoding
التحقق يحدث مرة واحدة عند الدخول، أما الترميز فيحدث في كل مرة تخرج فيها القيمة إلى مفسر، والسبب أن كل مفسر له حروف خاصة مختلفة: علامة < خطرة في HTML، وعلامة الاقتباس خطرة داخل نص JavaScript، و& خطرة في الرابط، و* خطرة في فلتر LDAP. ولذلك لا يوجد ترميز «آمن» عام، وإنما ترميز صحيح لكل سياق Context، وهذا ما يقوله OWASP XSS Prevention Cheat Sheet. والصورة التالية تبين القيمة نفسها وهي تخرج إلى سبعة سياقات:

ولاحظ الخطوة الثالثة في الصورة: احفظ القيمة كما هي، ولا تحفظها مرمزة لصفحة HTML، والسبب كما يقول توثيق Microsoft أن الترميز يكون عند المخرج، وأن القيمة المرمزة في قاعدة البيانات تصبح خاطئة عندما تخرج إلى سياق آخر، مثل ملف CSV أو رسالة بريد أو رابط.
المرمزات Encoders الجاهزة في كل لغة
لا تكتب دالة الترميز بنفسك، فكل لغة فيها مرمزات مجربة. وفي .NET توجد في المساحة System.Text.Encodings.Web، وقد شغلنا المثال التالي:
using System.Text.Encodings.Web;
var name = "<img src=x onerror=alert(1)>";
Console.WriteLine("HtmlEncoder : " + HtmlEncoder.Default.Encode(name));
Console.WriteLine("JavaScriptEncoder: " + JavaScriptEncoder.Default.Encode("'; alert(1); //"));
Console.WriteLine("UrlEncoder : " + UrlEncoder.Default.Encode("a b&c=d/../"));والمخرج سوف يكون كما يلي:
HtmlEncoder : <img src=x onerror=alert(1)>
JavaScriptEncoder: '; alert(1); //
UrlEncoder : a%20b%26c%3Dd%2F..%2Fفي المخرج أعلاه لاحظ أن كل مرمز غير حروفاً مختلفة: مرمز HTML غير علامتي الزاوية، ومرمز JavaScript غير علامة الاقتباس التي كانت سوف تغلق النص، ومرمز الرابط غير المسافة و& و= و/ حتى تبقى القيمة قيمة واحدة في الرابط. وفي Java لا يوجد مرمز HTML في المكتبة القياسية، والمكتبة المعروفة هي OWASP Java Encoder بترخيص BSD-3-Clause، وفيها دالة لكل سياق:
import org.owasp.encoder.Encode;
String html = "<p>Hello " + Encode.forHtml(name) + "</p>";
String attr = "<input value=\"" + Encode.forHtmlAttribute(name) + "\">";
String js = "var n = '" + Encode.forJavaScript(name) + "';";
String url = "/search?q=" + Encode.forUriComponent(name);وفي Python تكفي الدالة html.escape للنص داخل HTML، وurllib.parse.quote لأجزاء الرابط، وفي JavaScript داخل المتصفح الخاصية textContent بدل innerHTML، والدالة encodeURIComponent لقيم الرابط.
الترميز التلقائي Autoescaping في القوالب، ومتى يتوقف
في أغلب الحالات لن تستدعي المرمز بنفسك، لأن محرك القوالب Template Engine يرمز كل قيمة تلقائياً، والخطر الحقيقي في المكان الذي تطلب فيه من المحرك أن يتوقف عن الترميز. والجدول التالي يجمع السلوك الافتراضي ومنفذ الهروب Escape Hatch في كل محرك:
| المحرك | يرمز تلقائياً | منفذ الهروب الذي يجب أن يراجع |
|---|---|---|
| Razor في ASP.NET Core | كل قيمة بعد @، بقواعد ترميز ال Attribute التي تشمل HTML | Html.Raw والكلاس HtmlString |
| Thymeleaf في Spring | th:text | th:utext، أي النص غير المرمز Unescaped Text |
| Jinja2 في Python | فقط إذا فعلته، وFlask يفعله لقوالب .html | الفلتر |safe وMarkup، وEnvironment() دون autoescape |
| React | كل قيمة داخل {} في JSX | dangerouslySetInnerHTML |
والسطر الثالث يحتاج انتباهاً خاصاً، فبحسب توثيق Jinja2 فإن القيمة الافتراضية للمعامل autoescape في Environment هي False، أي أن من يستخدم Jinja2 مباشرة خارج Flask، مثلاً لتوليد رسائل بريد HTML أو تقارير، يحصل على قوالب لا ترمز شيئاً. وقد شغلنا المثال التالي:
import jinja2
name = '<img src=x onerror=alert(1)>'
raw = jinja2.Environment() # autoescape defaults to False
safe = jinja2.Environment(autoescape=jinja2.select_autoescape()) # on for .html/.xml and for strings
print("Environment() :", raw.from_string("<p>Hello {{ name }}</p>").render(name=name))
print("autoescape on .html :", safe.from_string("<p>Hello {{ name }}</p>").render(name=name))والمخرج سوف يكون كما يلي:
Environment() : <p>Hello <img src=x onerror=alert(1)></p>
autoescape on .html : <p>Hello <img src=x onerror=alert(1)></p>والسطر الأول هو بالضبط ما يحدث في رسالة بريد ترحيب تعرض اسم المستخدم كما كتبه. ولذلك فالقاعدة: فعل الترميز التلقائي صراحة في كل مكان تنشئ فيه محرك القوالب، ولا تعتمد على القيمة الافتراضية، وهذا ما ينصح به توثيق Jinja2 نفسه.
وفي React يكفي أن تبحث في المستودع عن dangerouslySetInnerHTML، فكل استخدام له يجب أن يكون بمحتوى موثوق أو منظف بمكتبة تنظيف HTML Sanitizer، كما يقول توثيق React. ونفس الكلام عن Html.Raw في Razor وth:utext في Thymeleaf، وهذه الكلمات هي أول ما تبحث عنه أداة الفحص الثابت SAST والمراجع البشري.
ويجدر الإشارة إلى سياق JavaScript داخل الصفحة تحديداً، فتوثيق Microsoft ينصح بألا تضع القيمة داخل كتلة <script> أصلاً، وإنما في خاصية data- على عنصر HTML، ترمزها Razor تلقائياً، ثم يقرؤها السكربت بالخاصية dataset. وهذا يجعل سياق JavaScript سياق HTML Attribute، وهو السياق الذي يحميه المحرك. ومعه سياسة CSP كطبقة ثانية، كما شرحنا في دليل ترويسات الأمان HSTS وCSP.
سياقات أخرى: LDAP وال Shell والسجلات
- فلتر LDAP: بحسب RFC 4515 يجب ترميز الحروف
*و(و)و\والحرف NUL داخل قيمة الفلتر بالشكل\XX، والطريقة الأفضل أن تستخدم واجهة مكتبتك التي تبني الفلتر من قيم منفصلة بدل دمج النصوص، والتفاصيل في OWASP LDAP Injection Prevention Cheat Sheet. - ال Shell: الترميز هنا مثل
shlex.quoteفي Python يعمل مع Shell من نوع POSIX فقط، والحل الأفضل بكثير ألا يوجد Shell أصلاً، كما سنرى في قسم الأوامر. - السجلات: القيمة التي تكتب في سطر سجل نصي تحتاج ترميزاً أيضاً، أو الأفضل سجل منظم JSON، وقد شرحنا ذلك في قسم حقن السجلات Log Injection.
الاستعلامات ذات المعاملات Parameterized Queries، ومنافذ SQL الخام Raw SQL
شرحنا في قسم الحقن A05 في مقال OWASP Top 10 لماذا يفصل الاستعلام ذو المعاملات البيانات عن الكود، ورأينا مثال JDBC مع PreparedStatement، فلن نكرره هنا. والسؤال الذي يبقى للمطور هو: أنا أستخدم ORM، فهل أنا محمي؟ والإجابة نعم ما دمت في واجهة ال ORM العادية، ولكن كل ORM فيه باب لكتابة SQL بنفسك، وفي هذا الباب يعود الخطر كما كان. ولنرى الفرق أولاً بشكل عملي، شغلنا المثال التالي بلغة Python مع قاعدة SQLite في الذاكرة:
import sqlite3
db = sqlite3.connect(":memory:")
db.executescript("CREATE TABLE customers(id INT, name TEXT); INSERT INTO customers VALUES (1,'Acme'),(2,'O''Brien Ltd');")
q = "x' OR '1'='1"
print("concat :", db.execute(f"SELECT id, name FROM customers WHERE name = '{q}'").fetchall())
print("param :", db.execute("SELECT id, name FROM customers WHERE name = ?", (q,)).fetchall())
print("param :", db.execute("SELECT id, name FROM customers WHERE name = ?", ("O'Brien Ltd",)).fetchall())والمخرج سوف يكون كما يلي:
concat : [(1, 'Acme'), (2, "O'Brien Ltd")]
param : []
param : [(2, "O'Brien Ltd")]الاستعلام المبني بالدمج أعاد كل العملاء لأن الشرط أصبح صحيحاً دائماً، والاستعلام ذو المعاملات بحث عن عميل اسمه حرفياً x' OR '1'='1 فلم يجده، ثم وجد O'Brien Ltd بعلامة الاقتباس في اسمه دون أي مشكلة، أي أن المعاملات لا تمنع الهجوم فقط، وإنما تجعل البيانات الصحيحة تعمل أيضاً.
منافذ الهروب في ال ORM
في Entity Framework Core يوجد شكلان متشابهان جداً في الكتابة ومختلفان تماماً في الأمان، ويقول توثيق EF Core إن الدالتين FromSql وFromSqlInterpolated آمنتان من حقن SQL لأنهما تحولان كل قيمة داخل النص المستكمل Interpolated String إلى DbParameter، أما FromSqlRaw فقد تكون عرضة للحقن إذا استخدمت بشكل خاطئ:
// Vulnerable: FromSqlRaw with an interpolated string = string concatenation
var rows = await db.Customers
.FromSqlRaw($"SELECT * FROM Customers WHERE Name = '{name}'")
.ToListAsync();
// Fixed: FromSql (EF Core 7+) turns {name} into a real DbParameter
var rows = await db.Customers
.FromSql($"SELECT * FROM Customers WHERE Name = {name}")
.ToListAsync();ولاحظ أن الفرق كلمة Raw فقط، والشكل متطابق تقريباً، ولذلك فهذه أول كلمة تبحث عنها في المراجعة، ومثلها ExecuteSqlRaw وSqlQueryRaw. وفي JavaScript مع مكتبة node-postgres يمرر النص والقيم منفصلين، كما في توثيقها:
// Vulnerable: template literal builds the SQL text
await pool.query(`SELECT id, name FROM customers WHERE name = '${name}'`);
// Fixed: $1 placeholder, values travel separately
await pool.query("SELECT id, name FROM customers WHERE name = $1", [name]);والقاعدة نفسها في JPA مع Hibernate، فاستعلام JPQL أو HQL المبني بالدمج في createQuery ضعيف مثل SQL تماماً، والصحيح المعامل المسمى :name مع setParameter، وفي Spring Data الخاصية @Query مع معامل مسمى. وفي Python مع SQLAlchemy الدالة text() مع معاملات مسماة :name، وليس f-string.
أسماء الأعمدة والترتيب: قائمة مسموحة في الكود
المعاملات تحمل القيم فقط، ولا يمكن أن تحمل اسم جدول أو عمود أو اتجاه الترتيب، وتوثيق EF Core نفسه يقول ذلك، وتوثيق node-postgres كذلك. فإذا كان المستخدم يختار عمود الترتيب، فالحل ليس تنظيف النص، وإنما قاموس في الكود يحول اختيار المستخدم إلى اسم عمود تكتبه أنت:
// Fixed: the user picks a key, the code owns the SQL identifier
private static final Map<String, String> SORT = Map.of(
"name", "c.name",
"created", "c.created_at",
"total", "c.total_amount");
String column = SORT.getOrDefault(req.sort(), "c.created_at");
String dir = "desc".equalsIgnoreCase(req.dir()) ? "DESC" : "ASC";
String sql = "SELECT c.id, c.name FROM customers c ORDER BY " + column + " " + dir + " LIMIT ?";const SORT = { name: "c.name", created: "c.created_at", total: "c.total_amount" };
const column = SORT[req.query.sort] ?? "c.created_at"; // unknown key: default, never the raw text
const dir = req.query.dir === "desc" ? "DESC" : "ASC";
await pool.query(`SELECT c.id, c.name FROM customers c ORDER BY ${column} ${dir} LIMIT $1`, [limit]);ولاحظ أن الدمج هنا آمن لأن كل ما يدخل النص مكتوب في الكود نفسه، وما جاء من المستخدم مفتاح يبحث به في القاموس فقط. ومثله شروط البحث بالكلمة LIKE، فالقيمة تمرر كمعامل، ولكن الحرفين % و_ داخلها لهما معنى في LIKE، فإذا لم تردهما كأحرف بحث فرمزهما مع ESCAPE.
تشغيل أوامر النظام بأمان OS Commands
الطريقة البسيطة لتشغيل برنامج خارجي هي بناء سطر أوامر كامل ثم تمريره إلى ال Shell، مثل sh -c أو shell=True في Python أو exec في Node، والمشكلة أن ال Shell مفسر كامل له حروف خاصة: ; و| و& و$() وعلامات الاقتباس، فكل حرف منها في مدخل المستخدم يصبح أمراً. والحل الذي يأتي على البال بعد ذلك هو تنظيف المدخل من هذه الحروف، وهو قائمة ممنوعات أخرى سوف تنسى فيها حرفاً. والحل الصحيح بحسب OWASP OS Command Injection Defense Cheat Sheet له مستويان: الأول ألا تشغل برنامجاً خارجياً أصلاً إذا كانت هناك دالة في مكتبة اللغة تقوم بالعمل، والثاني إذا احتجت البرنامج فشغله مباشرة بمصفوفة معاملات Argument Array دون Shell، فيصل كل معامل إلى البرنامج كما هو، ولا يوجد مفسر يقرأ حروفه الخاصة.
التجربة: نص Shell مقابل مصفوفة معاملات
لنأخذ ميزة تعرض معلومات ملف مرفوع، والمستخدم يرسل اسم الملف، والمهاجم يرسل الاسم report.txt; echo INJECTED. والمثال التالي بلغة Python هو ما شغلناه:
import subprocess
name = "report.txt; echo INJECTED"
r = subprocess.run("ls /data/uploads/" + name, shell=True, capture_output=True, text=True)
print("shell=True :", (r.stdout + r.stderr).strip().replace("\n", " | "))
r = subprocess.run(["ls", "--", "/data/uploads/" + name], capture_output=True, text=True)
print("list args :", (r.stdout + r.stderr).strip())والمخرج سوف يكون كما يلي:
shell=True : /data/uploads/report.txt | INJECTED
list args : ls: cannot access '/data/uploads/report.txt; echo INJECTED': No such file or directoryوالمثال نفسه بلغة Java مع ProcessBuilder، والفرق بين السطرين أن الأول يمرر النص إلى sh -c، والثاني يشغل ls مباشرة:
String name = "report.txt; echo INJECTED";
show("shell string ", () -> run(List.of("sh", "-c", "ls /data/uploads/" + name)));
show("argument list", () -> run(List.of("ls", "--", "/data/uploads/" + name)));
static String run(List<String> cmd) throws Exception {
Process p = new ProcessBuilder(cmd).redirectErrorStream(true).start();
String out = new String(p.getInputStream().readAllBytes()).strip();
p.waitFor();
return out;
}والمخرج سوف يكون كما يلي:
shell string -> /data/uploads/report.txt
INJECTED
argument list -> ls: cannot access '/data/uploads/report.txt; echo INJECTED': No such file or directoryفي المخرجين أعلاه لاحظ التالي:
- في نسخة ال Shell نفذ أمران، والثاني من المستخدم، وهنا كان
echoغير ضار، وفي الواقع قد يكون أي أمر يملك المستخدم الذي يعمل به التطبيق صلاحية تنفيذه. - في نسخة المصفوفة وصل النص كله إلى
lsكاسم ملف واحد، فبحث عن ملف بهذا الاسم الغريب ولم يجده، وهذا هو السلوك الصحيح، فالمدخل بقي بيانات. - المعامل
--يخبر أغلب برامج Linux أن ما بعده أسماء وليس خيارات Options، والسبب أن المصفوفة تمنع حقن أمر جديد، ولكنها لا تمنع أن يبدأ المدخل بشرطة فيقرأه البرنامج كخيار، مثل اسم ملف--helpأو خيار يكتب في ملف، ويذكر هذا المعامل OWASP في المرجع نفسه. - ويقول توثيق subprocess في قسم الاعتبارات الأمنية إن المكتبة لا تستدعي Shell ضمنياً، وإن كل الحروف تمرر بأمان، فإذا طلبت
shell=Trueصراحة فمسؤولية الترميز عليك.
وفي C# وNode الفكرة نفسها: في .NET الخاصية ArgumentList بدل نص Arguments واحد، وفي Node الدالة execFile أو spawn بمصفوفة بدل exec، وتوثيق child_process يقول عن exec نصاً: «Never pass unsanitized user input to this function».
// Vulnerable: one command line string through the shell
Process.Start("/bin/sh", $"-c \"git log --format=%s -- {userPath}\"");
// Fixed: the program and each argument separately, no shell
var psi = new ProcessStartInfo("git") { UseShellExecute = false, RedirectStandardOutput = true };
foreach (var a in new[] { "log", "--format=%s", "--", userPath }) psi.ArgumentList.Add(a);
using var p = Process.Start(psi)!;import { exec, execFile } from "node:child_process";
// Vulnerable: exec runs the string in a shell
exec(`git log --format=%s -- ${userPath}`, cb);
// Fixed: execFile runs git directly, userPath is one argument
execFile("git", ["log", "--format=%s", "--", userPath], cb);استثناء Windows: ملفات bat وcmd
المصفوفة تحميك لأن البرنامج يستقبل المعاملات مباشرة، ولكن في Windows لا يمكن تشغيل ملف .bat أو .cmd إلا عبر cmd.exe، أي أن ال Shell يعود من الباب الخلفي حتى لو استخدمت المصفوفة. ولهذا السبب أصدر Node.js في أبريل 2024 تحديثاً أمنياً للثغرة CVE-2024-27980، وبحسب إعلان التحديث أصبح Node يرمي الخطأ EINVAL إذا مررت ملف .bat أو .cmd إلى spawn دون الخيار shell. وقد جربنا ذلك على جهاز Windows:
import { spawnSync } from "node:child_process";
const arg = "x & echo INJECTED";
const s = spawnSync("report.bat", [arg]);
console.log(" spawn report.bat:", s.error ? `${s.error.code}` : s.stdout.toString().trim());والمخرج سوف يكون كما يلي:
spawn report.bat: EINVALوتوثيق subprocess في Python يذكر المشكلة نفسها، فيقول إن ملفات batch في Windows قد تشغل عبر Shell بغض النظر عن المعاملات التي تمررها. والخلاصة العملية: لا تشغل ملف batch بمدخل من المستخدم، وإذا احتجت المنطق الذي فيه فانقله إلى كود في تطبيقك أو إلى برنامج تنفيذي حقيقي.
تجاوز المسار Path Traversal والتعامل الآمن مع الملفات
تجاوز المسار Path Traversal هو أن يرسل المستخدم اسم ملف فيه ../ أو مساراً كاملاً، فيقرأ الكود أو يكتب ملفاً خارج المجلد الذي قصدته، كما في صفحة OWASP عن الهجوم. والطريقة البسيطة دمج اسم المجلد مع الاسم القادم، والطريقة التي تليها حذف ../ من النص، وهي قائمة ممنوعات أخرى يكسرها ....// الذي يصبح ../ بعد الحذف، أو الترميز المضاعف، أو المسار الكامل. والطريقة الصحيحة هي التوحيد ثم الفحص: حول المسار الناتج إلى مساره الحقيقي الكامل، ثم تأكد أنه ما زال داخل المجلد المسموح.
التجربة: الدمج المباشر مقابل فحص المسار الحقيقي
الملف files.py كاملاً كما شغلناه:
import os
from pathlib import Path
BASE = Path("/data/uploads")
def read_vulnerable(name: str) -> str:
# Vulnerable: the name is glued to the folder and opened as is
return open(os.path.join(BASE, name)).read().strip()
def read_fixed(name: str) -> str:
# Fixed: canonicalise first (resolve '..' and symlinks), then check the result
target = (BASE / name).resolve()
if not target.is_relative_to(BASE.resolve()):
raise PermissionError(f"outside the upload folder: {target}")
return target.read_text().strip()
for name in ["report.txt", "../config/app.secret", "/data/config/app.secret"]:
for fn in (read_vulnerable, read_fixed):
try:
print(f"{fn.__name__:15} {name!r:28} -> {fn(name)}")
except Exception as e:
print(f"{fn.__name__:15} {name!r:28} -> {type(e).__name__}: {e}")والمخرج سوف يكون كما يلي:
read_vulnerable 'report.txt' -> Q3 sales report
read_fixed 'report.txt' -> Q3 sales report
read_vulnerable '../config/app.secret' -> db_password=lab-only-not-real
read_fixed '../config/app.secret' -> PermissionError: outside the upload folder: /data/config/app.secret
read_vulnerable '/data/config/app.secret' -> db_password=lab-only-not-real
read_fixed '/data/config/app.secret' -> PermissionError: outside the upload folder: /data/config/app.secretفي المخرج أعلاه لاحظ التالي:
- الحالة الثالثة مفاجئة لكثير من المطورين، فالاسم لا يحتوي على
..أصلاً، ولكنos.path.joinيرمي كل ما قبله إذا كان الجزء التالي مساراً كاملاً، كما يقول توثيق os.path، ولذلك فالبحث عن..في النص لا يكفي. - الدالة
resolve()تحل..والروابط الرمزية معاً، ثمis_relative_to(منذ Python 3.9) تقارن أجزاء المسار وليس النص، فلا يمر مجلد اسمه/data/uploads-oldلأنه يبدأ بالنص نفسه. - رسالة الخطأ هنا للتجربة فقط، أما في التطبيق فالمستخدم يرى 404 والمسار الكامل يذهب إلى السجل.
وقد شغلنا الفحص نفسه بلغة Java، والدالة toRealPath تحل الروابط الرمزية أيضاً، وstartsWith على كائن Path يقارن الأجزاء وليس الحروف:
static final Path BASE = Path.of("/data/uploads");
static String readFixed(String name) throws IOException {
Path base = BASE.toRealPath();
Path target = base.resolve(name).normalize();
if (!target.startsWith(base)) throw new SecurityException("outside the upload folder: " + target);
return Files.readString(target.toRealPath()).strip(); // toRealPath also follows symlinks
}والمخرج سوف يكون كما يلي:
fixed report.txt -> Q3 sales report
fixed ../config/app.secret -> SecurityException: outside the upload folder: /data/config/app.secretوفي C# وNode الفكرة نفسها، مع ملاحظة مهمة في .NET: الدالة Path.Combine مثل os.path.join تبدأ من جديد إذا كان أحد الأجزاء مساراً كاملاً، وتوثيقها ينصح باستخدام Path.Join عندما تكون الأجزاء التالية نصوصاً من المستخدم. وفي المقارنة أضف فاصل المجلد في نهاية المسار الأساسي حتى لا يمر المجلد المجاور الذي يبدأ بالاسم نفسه:
static readonly string Base = Path.GetFullPath("/srv/files/uploads") + Path.DirectorySeparatorChar;
static string SafePath(string name)
{
var full = Path.GetFullPath(Path.Join(Base, name)); // resolves "..", Join does not reset on "/x"
if (!full.StartsWith(Base, StringComparison.Ordinal))
throw new UnauthorizedAccessException("outside the upload folder");
return full;
}import path from "node:path";
import { realpath } from "node:fs/promises";
const BASE = await realpath("/srv/files/uploads");
async function safePath(name) {
const full = await realpath(path.resolve(BASE, name)); // resolves "..", absolute names and symlinks (file must exist)
if (full !== BASE && !full.startsWith(BASE + path.sep)) throw new Error("outside the upload folder");
return full;
}وأفضل من كل ذلك ألا تستخدم الاسم القادم من المستخدم كاسم ملف أصلاً، وإنما معرفاً في قاعدة البيانات يقابله اسم عشوائي على القرص، كما سيأتي في قسم الرفع، فعندها لا يوجد مسار يمكن تجاوزه.
الملفات المضغوطة Archives: نفس الثغرة من داخل الملف
عندما يفك تطبيقك ملف zip أو tar رفعه مستخدم، فأسماء الملفات داخله مدخلات من المستخدم أيضاً، وقد تحتوي على ../ أو مسار كامل أو رابط رمزي يشير إلى خارج المجلد. فطبق فحص المسار نفسه على كل اسم قبل كتابته، أو استخدم ما توفره اللغة، ففي Python أضيفت مرشحات الاستخراج Extraction Filters إلى الوحدة tarfile في الإصدار 3.12، والمرشح data يرفض الروابط ذات المسار الكامل أو التي تشير إلى خارج المجلد، وأصبح هو الافتراضي في Python 3.14 بحسب توثيق tarfile:
import tarfile
with tarfile.open(upload_path) as tar:
tar.extractall(dest_dir, filter="data") # explicit on 3.12/3.13, the default from 3.14رفع الملفات File Upload بأمان
رفع الملف يجمع أكثر من مشكلة في طلب واحد: الحجم، والنوع، والاسم، والمكان، والمحتوى، ثم طريقة عرضه لاحقاً. والصورة التالية تبين المسار الذي نوصي به، وهو مبني على OWASP File Upload Cheat Sheet:

وفي الصورة أعلاه لاحظ التالي:
- الحدود: في كل طبقة من ال Reverse Proxy إلى إطار العمل، والملف يقرأ كتيار Stream ولا يحمل كاملاً في الذاكرة، وهذا موضوع دليل حدود حجم رفع الملفات بقيمه لكل أداة وإطار.
- الامتداد Extension: قائمة مسموحة بالامتداد الأخير بحروف صغيرة، فالاسم
invoice.pdf.phpامتداده.php. - ترويسة Content-Type: يرسلها المتصفح أو المهاجم، ويقول OWASP إنها لا يعتمد عليها في الأمان، وإنما تفحص محتوى الملف بالبايتات الأولى المعروفة لكل نوع File Signature.
- الاسم: اسم جديد عشوائي يولده السيرفر مثل UUID، والاسم الأصلي يحفظ في قاعدة البيانات للعرض فقط، فلا يدخل في أي مسار.
- المكان: خارج مجلد الويب Web Root، أو في تخزين كائنات Object Storage، حتى لا يمكن طلب الملف كصفحة ينفذها السيرفر.
- العرض: عبر Endpoint يفحص الصلاحية أولاً، ويرسل الملف مع
Content-Disposition: attachmentوالترويسةX-Content-Type-Options: nosniffالتي شرحناها في قسم لا تخمن نوع الملف، حتى لا يعرض المتصفح ملف HTML مرفوعاً كصفحة من نطاقك.
والمثال التالي بلغة C# يجمع الخطوات من الثانية إلى الخامسة في Endpoint واحد:
static readonly Dictionary<string, byte[]> Signatures = new()
{
[".pdf"] = "%PDF-"u8.ToArray(),
[".png"] = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A],
[".jpg"] = [0xFF, 0xD8, 0xFF],
};
app.MapPost("/files", async (IFormFile file, ClaimsPrincipal user, AppDb db) =>
{
var ext = Path.GetExtension(file.FileName).ToLowerInvariant(); // last extension only
if (!Signatures.TryGetValue(ext, out var magic)) return Results.BadRequest("file type not allowed");
var head = new byte[magic.Length];
await using (var s = file.OpenReadStream())
if (await s.ReadAtLeastAsync(head, head.Length, throwOnEndOfStream: false) < head.Length
|| !head.AsSpan().SequenceEqual(magic))
return Results.BadRequest("content does not match the extension");
var stored = $"{Guid.NewGuid():N}{ext}"; // our name, not theirs
var path = Path.Join("/srv/files/uploads", stored); // outside wwwroot
await using (var target = File.Create(path))
await file.CopyToAsync(target);
db.Files.Add(new StoredFile(stored, OriginalName: file.FileName, OwnerId: user.GetId()));
await db.SaveChangesAsync();
return Results.Ok(new { id = stored });
}).RequireAuthorization().DisableAntiforgery(); // API with token auth; keep antiforgery on for cookie-based formsولاحظ أن فحص البايتات الأولى يمنع أن يرفع ملف نصي أو سكربت باسم صورة، ولكنه لا يثبت أن الملف سليم، فملف PDF حقيقي قد يحتوي على محتوى خبيث، ولذلك تبقى خطوة الفحص بمضاد الفيروسات أو إعادة البناء Content Disarm and Reconstruct واختصاراً CDR للملفات التي سوف يفتحها موظفون، كما يقترح OWASP.
فك التسلسل Deserialization: البيانات لا تختار الكلاس
في مقال OWASP Top 10 رأينا مثال pickle الذي يعيد بناء أي كائن يرسله العميل، والفكرة العامة أن المسلسل الخطر هو الذي يقرأ اسم الكلاس من البيانات نفسها ثم ينشئ كائناً منه، فيختار المهاجم الكلاس الذي يريد من كل ما في ال Classpath أو المكتبات. والقاعدة بسيطة: استخدم صيغة بيانات فقط Data-only Format مثل JSON مع كلاس محدد تعرفه أنت، ولا تفعل الأنواع المتعددة Polymorphic Deserialization بأسماء أنواع تأتي من الطلب. وفيما يلي ما يعنيه ذلك في كل لغة، مع ما شغلناه.
.NET: لم يعد BinaryFormatter موجوداً
تقول Microsoft في دليلها الأمني إن BinaryFormatter غير آمن ولا يمكن جعله آمناً، وإنه بدءاً من .NET 9 يرمي التنفيذ المدمج استثناء عند أي استخدام، حتى مع الإعدادات التي كانت تسمح به سابقاً، وقد شغلنا سطراً واحداً على .NET 10:
new BinaryFormatter().Serialize(new MemoryStream(), "x");والمخرج سوف يكون كما يلي:
Serialize -> PlatformNotSupportedException: BinaryFormatter serialization and deserialization have been removed. See https://aka.ms/binaryformatter for more information.أي أن الكود القديم الذي ينتقل إلى .NET 9 أو 10 سوف يتوقف هنا، وهذا جيد، والبديل الذي يقترحه الدليل نفسه System.Text.Json أو DataContractSerializer أو XmlSerializer. وانتبه إلى أن الخطر نفسه يعود من باب آخر في Newtonsoft.Json، فالخاصية TypeNameHandling بأي قيمة غير None تجعل الطلب يحدد نوع الكائن، وتوثيقها يطلب الحذر عند قراءة JSON من مصدر خارجي، والتحقق من الأنواع بكلاس SerializationBinder مخصص.
Java: مرشحات فك التسلسل ObjectInputFilter
أفضل حل في Java ألا تستخدم ObjectInputStream على بيانات من الخارج أصلاً، فإذا كان لا بد منه، مثل نظام قديم يتواصل بهذه الصيغة، فالمنصة نفسها توفر مرشح فك التسلسل ObjectInputFilter، وهو قائمة مسموحة بأسماء الكلاسات وحدود للعمق والحجم، تفحص قبل إنشاء أي كائن. وبحسب صيغة المرشحات فالنمط المسبوق بعلامة ! مرفوض، و!* في النهاية ترفض كل ما لم يذكر قبلها. وقد شغلنا المثال التالي:
static class Gadget implements Serializable { String note = "any class on the classpath"; }
static Object read(byte[] data) throws Exception {
try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(data))) {
in.setObjectInputFilter(ObjectInputFilter.Config.createFilter(
"java.util.ArrayList;java.lang.String;java.lang.Object;maxdepth=5;maxbytes=10000;!*"));
return in.readObject();
}
}
show("ArrayList<String>", () -> read(write(new ArrayList<>(List.of("a", "b")))));
show("Gadget ", () -> read(write(new Gadget())));والمخرج سوف يكون كما يلي:
ArrayList<String> -> [a, b]
Gadget -> InvalidClassException: filter status: REJECTEDفي الكود أعلاه لاحظ التالي:
- الكلاس
Gadgetقابل للتسلسل وموجود في البرنامج، ومع ذلك رفض لأنه ليس في القائمة، وهذه هي الفكرة كلها، فلا يهم ما في ال Classpath، المهم ما سمحت به. - في المحاولة الأولى كتبنا القائمة دون
java.lang.ObjectفرفضتArrayListنفسها، والسبب أنArrayListتحفظ عناصرها في مصفوفةObject[]، والمرشح يفحص المصفوفة بنوع عناصرها، وهذا مثال جيد على أن القائمة المسموحة تحتاج تجربة على بياناتك الحقيقية قبل النشر. - يمكن تطبيق مرشح على مستوى البرنامج كله دون تعديل الكود بالخاصية
jdk.serialFilterفي سطر التشغيل أو في ملف الإعدادات.
YAML: التحميل الآمن هو الافتراضي في المكتبات الحديثة
YAML ليست صيغة بيانات فقط في كل المكتبات، فبعضها يفهم وسوماً Tags تنشئ كائنات من كلاسات اللغة، ويقول توثيق PyYAML إن yaml.load بقوة pickle.load ويمكن أن يستدعي أي دالة Python، وينصح بالدالة yaml.safe_load. وقد شغلنا وثيقة فيها وسم ينشئ كائن تاريخ من Python، وهو كلاس غير ضار، ولكنه يكفي لنرى من يقبل الوسوم ومن يرفضها:
import yaml
doc = "invoice: 1001\ncreated: !!python/object/new:datetime.date [2026, 10, 7]\n"
try:
print("safe_load :", yaml.safe_load(doc))
except yaml.YAMLError as e:
print("safe_load :", type(e).__name__, str(e).splitlines()[0])
try:
yaml.load(doc)
except TypeError as e:
print("load() : TypeError:", e)
print("unsafe_load:", yaml.unsafe_load(doc))والمخرج سوف يكون كما يلي:
safe_load : ConstructorError could not determine a constructor for the tag 'tag:yaml.org,2002:python/object/new:datetime.date'
load() : TypeError: load() missing 1 required positional argument: 'Loader'
unsafe_load: {'invoice': 1001, 'created': datetime.date(2026, 10, 7)}ولاحظ أن yaml.load دون معامل Loader لم تعد تعمل في PyYAML 6، فالمكتبة تجبرك على أن تختار صراحة، والدالة unsafe_load اسمها يكفي في المراجعة. وفي JavaScript شغلنا وثيقة فيها الوسم !!js/function مع js-yaml:
import * as yaml from "js-yaml";
try { console.log(yaml.load("invoice: 1001\nrun: !!js/function 'function () {}'\n")); }
catch (e) { console.log(" load:", e.name, e.message.split("\n")[0]); }والمخرج سوف يكون كما يلي:
load: YAMLException unknown scalar tag !<tag:yaml.org,2002:js/function> (2:6)فالدالة load في js-yaml الحديثة تفهم أنواع YAML العادية فقط، والأنواع الخاصة بلغة JavaScript نقلت إلى حزمة منفصلة، فلا تضفها لملفات تأتي من الخارج.
محللات XML وحقن الكيانات الخارجية XXE
حقن الكيانات الخارجية XML External Entity واختصاراً XXE هو أن يحتوي ملف XML على تعريف DOCTYPE فيه كيان Entity يشير إلى ملف على السيرفر أو رابط داخلي، فيقرأ المحلل الملف ويضعه داخل الوثيقة. والمشكلة ليست في الكود الذي تكتبه، وإنما في الإعدادات الافتراضية للمحلل، وهي تختلف بين اللغات، فقد يكون الإعداد الآمن هو الافتراضي في لغة، ويحتاج سطوراً صريحة في أخرى. ولذلك شغلنا الوثيقة نفسها في ثلاث لغات، وهي تحاول قراءة ملف الإعداد التجريبي:
<?xml version="1.0"?>
<!DOCTYPE r [<!ENTITY x SYSTEM "file:///data/config/app.secret">]>
<r>&x;</r>Java: الإعداد الافتراضي يقرأ الملف
في Java شغلنا DocumentBuilderFactory مرتين، مرة بالإعداد الافتراضي ومرة بالإعداد المقوى الذي يوصي به OWASP XXE Prevention Cheat Sheet:
static String parse(String xml, boolean hardened) throws Exception {
DocumentBuilderFactory f = DocumentBuilderFactory.newInstance();
if (hardened) {
f.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
f.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
f.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
f.setExpandEntityReferences(false);
}
Document d = f.newDocumentBuilder().parse(new ByteArrayInputStream(xml.getBytes()));
return d.getDocumentElement().getTextContent().strip();
}والمخرج على OpenJDK 25 سوف يكون كما يلي:
default factory -> db_password=lab-only-not-real
[Fatal Error] :1:31: DOCTYPE is disallowed when the feature "http://apache.org/xml/features/disallow-doctype-decl" set to true.
hardened factory -> SAXParseException: DOCTYPE is disallowed when the feature "http://apache.org/xml/features/disallow-doctype-decl" set to true.نعم، في أحدث إصدار LTS من Java ما زال المحلل الافتراضي يقرأ الملف، ودليل أمان JAXP من Oracle يقول ذلك صراحة: المعالجة الآمنة FEATURE_SECURE_PROCESSING مفعلة افتراضياً، ولكن الإعداد الافتراضي لا يقيد تحليل الكيانات الخارجية. ويذكر الدليل نفسه ملف إعداد أشد يأتي مع ال JDK باسم jaxp-strict.properties.template، وقد جربناه دون تغيير سطر في الكود:
java -Djava.xml.config.file=$JAVA_HOME/conf/jaxp-strict.properties.template /lab/Lab.javadefault factory -> SAXException: javax.xml.catalog.CatalogException: JAXP09040001: No match found for publicId 'null' and systemId 'file:///data/config/app.secret'.فالمحلل الافتراضي نفسه رفض الملف هذه المرة، لأن الإعداد الأشد يجعل ال Catalog المدمج في ال JDK في الوضع strict. ويقدم الدليل هذا الملف كطريقة لاختبار جاهزية تطبيقك لإعدادات أشد، فهو مفيد في بيئة الاختبار، أما الحماية التي لا تعتمد على طريقة التشغيل فهي الأسطر الصريحة في الكود.
.NET وPython: الآمن هو الافتراضي، حتى تغيره
في .NET شغلنا الوثيقة نفسها بثلاث طرق:
Show("XmlReader defaults ", () =>
{
using var r = XmlReader.Create(new StringReader(xml));
var d = new XmlDocument(); d.Load(r); return d.DocumentElement!.InnerText;
});
Show("XmlDocument defaults ", () =>
{
var d = new XmlDocument(); d.LoadXml(xml); return d.DocumentElement!.InnerText;
});
Show("DtdProcessing.Parse+resolver", () =>
{
var s = new XmlReaderSettings { DtdProcessing = DtdProcessing.Parse, XmlResolver = new XmlUrlResolver() };
using var r = XmlReader.Create(new StringReader(xml), s);
var d = new XmlDocument(); d.Load(r); return d.DocumentElement!.InnerText;
});والمخرج سوف يكون كما يلي:
XmlReader defaults -> XmlException: For security reasons DTD is prohibited in this XML document. To enable DTD processing set the DtdProcessing property on XmlReaderSettings to Parse and pass the settings into XmlReader.Create method.
XmlDocument defaults ->
DtdProcessing.Parse+resolver -> db_password=lab-only-not-realفي المخرج أعلاه لاحظ التالي:
XmlReaderيرفض ال DTD افتراضياً، وXmlDocumentيقبل الوثيقة ولكنه لا يحل الكيان الخارجي، فيعيد نصاً فارغاً، لأن الXmlResolverفيه فارغ افتراضياً، وهذا ما يذكره OWASP لإصدارات .NET Framework 4.5.2 فما فوق.- السطر الثالث هو ما يحدث عندما ينسخ أحدهم إعداداً من إجابة قديمة على الإنترنت لأن ملفاً ما «لا يقرأ»، فسطران من الإعداد أعادا الثغرة كاملة. ولذلك ابحث في المراجعة عن
DtdProcessing.ParseوXmlUrlResolver.
وفي Python شغلنا الوثيقة مع ElementTree من المكتبة القياسية:
import xml.etree.ElementTree as ET
try:
print("ElementTree:", repr(ET.fromstring(xml).text))
except ET.ParseError as e:
print("ElementTree: ParseError:", e)ElementTree: ParseError: undefined entity &x;: line 1, column 90فالمحلل Expat الذي تستخدمه المكتبة القياسية لا يصل إلى الملفات ولا إلى الشبكة من نفسه، كما يقول توثيق وحدة xml، والتوثيق نفسه يحذر من أن إصدارات Expat الأقدم من 2.7.2 عرضة لهجمات توسيع الكيانات مثل Billion Laughs، أي أن تحديث Python نفسه جزء من الحماية، ولذلك ينصح OWASP بمكتبة defusedxml للبيانات غير الموثوقة. أما Node.js فلا يوجد فيه محلل XML في المكتبات المدمجة، والأمان يعتمد على المكتبة التي تختارها، فاقرأ خيار الكيانات فيها واتركه مغلقاً.
معالجة الأخطاء Error Handling: لا تفاصيل للمستخدم، والفشل المغلق Fail Closed
للخطأ جمهوران مختلفان: المستخدم الذي يحتاج أن يعرف أن العملية لم تنجح وماذا يفعل بعدها، والفريق الذي يحتاج كل التفاصيل ليعرف السبب. والخطأ الشائع أن تذهب التفاصيل إلى الجمهور الأول، فيرى الزائر Stack Trace فيه أسماء الكلاسات والمسارات وإصدارات المكتبات واستعلام SQL الذي فشل، وهذه بالضبط المعلومات التي يحتاجها من يبحث عن ثغرة منشورة تناسب تطبيقك. والصورة التالية تبين المسار الصحيح، خاصة عندما يحدث الخطأ داخل قرار أمني:

وقد رأينا مثال الفشل المفتوح Fail Open بلغة C# في قسم A10 في مقال OWASP Top 10، ورأينا هناك أيضاً UseExceptionHandler في ASP.NET Core، فنكمل هنا بالمعالج العام في Java وPython. وفي Spring يعيد المعالج العام كائن ProblemDetail بصيغة RFC 9457 المدعومة في Spring Framework:
// Vulnerable: the exception message (SQL, paths, class names) goes to the client
@ExceptionHandler(Exception.class)
ResponseEntity<String> handle(Exception ex) {
return ResponseEntity.status(500).body(ex.toString());
}
// Fixed: generic answer with a reference id, details only in the log
@RestControllerAdvice
class GlobalErrors {
private static final Logger log = LoggerFactory.getLogger(GlobalErrors.class);
@ExceptionHandler(Exception.class)
ProblemDetail handle(Exception ex) {
String errorId = UUID.randomUUID().toString();
log.error("unhandled_error errorId={}", errorId, ex); // stack trace, once, in the log
ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR);
pd.setTitle("Something went wrong");
pd.setProperty("errorId", errorId);
return pd;
}
}import logging, uuid
from flask import Flask, jsonify
from werkzeug.exceptions import HTTPException
app = Flask(__name__)
log = logging.getLogger("app")
@app.errorhandler(Exception)
def handle(e):
if isinstance(e, HTTPException): # 404, 405 ... keep their normal answer
return e
error_id = uuid.uuid4().hex
log.exception("unhandled_error error_id=%s", error_id)
return jsonify(title="Something went wrong", errorId=error_id), 500
# and never app.run(debug=True) outside your laptop: the debugger runs code in the browserفي الكود أعلاه لاحظ التالي:
- الخطأ يسجل مرة واحدة فقط في المعالج العام، والأجزاء الأخرى من الكود لا تسجله ثم ترميه من جديد، والسبب أن الخطأ المكرر في السجل يربك من يحقق فيه، كما شرحنا في قسم تسجيل الاستثناءات مرة واحدة.
- المعرف في الرد يربط شكوى المستخدم بالسطر في السجل دون أن يكشف شيئاً، ومثله في ASP.NET Core معرف التتبع
traceIdالذي يضيفه ProblemDetails. - رسالة الاستثناء نفسها قد تحتوي على بيانات حساسة، مثل قيمة حقل أو جزء من استعلام، فراجع ما يدخل السجل أيضاً، كما في قسم ما لا يدخل السجل أبداً.
الاستثناءات Exceptions أم القيم المرجعة Result Types؟
هذا سؤال تصميم أكثر منه سؤال أمان، ولكن له أثر أمني مباشر. فالقاعدة العملية في اللغات الأربع: الاستثناء للحالة غير المتوقعة، مثل انقطاع قاعدة البيانات، والقيمة المرجعة للفشل المتوقع الذي يجب أن يتعامل معه من استدعى الدالة، مثل «كلمة المرور خاطئة» أو «المدخل غير صحيح». والسبب الأمني أن الفشل المتوقع إذا كان استثناء فمن السهل أن يبتلعه catch عام في مكان بعيد ويكمل التنفيذ، أما إذا كان قيمة مرجعة فالمترجم أو المراجع يرى أن أحداً لم يفحصها. وفي C# مثلاً نمط TryParse الذي يعيد bool، وفي Java النوع Optional أو Sealed Interface للنتيجة، وفي Python يبقى الاستثناء هو الأسلوب المعتاد، فاجعل أنواع الاستثناءات محددة، ولا تكتب except Exception: pass أبداً.
// Vulnerable: a broad catch turns "invalid token" into "carry on"
try { principal = ValidateToken(token); }
catch { /* ignore, maybe the token is optional */ }
return Ok(LoadDocuments(principal?.Identity?.Name ?? "guest"));
// Fixed: the expected failure is a value the caller must handle
if (!TryValidateToken(token, out var principal))
return Unauthorized();
return Ok(LoadDocuments(principal.Identity!.Name!));الإعدادات الآمنة افتراضياً Secure Defaults
الإعداد الآمن افتراضياً Secure by Default يعني أن من ينسى خطوة يحصل على السلوك الآمن، ومن يريد السلوك الأقل أماناً يكتبه صراحة فيراه المراجع. وفي الكود الذي تكتبه أنت، هذا يعني ما يلي:
- الرفض هو الأصل: كل Endpoint يحتاج مصادقة إلا ما استثني بالاسم، مثل
FallbackPolicyفي ASP.NET Core التي تطلب مستخدماً مسجلاً لكل شيء، وanyRequest().authenticated()في آخر قواعد Spring Security، والتفاصيل في دليل الصلاحيات المركزية. - بيئة الإنتاج Production هي الافتراضية: وضع التطوير Debug يفعل بمتغير صريح في جهاز المطور، وليس العكس، فإذا نسي أحد المتغير على السيرفر حصل على الإنتاج.
- مهلات وحدود Timeouts and Limits لكل شيء: كل طلب HTTP خارجي له مهلة، وكل استعلام له حد لعدد الصفوف، وكل طابور له حد، لأن القيمة الافتراضية في كثير من المكتبات هي «انتظر إلى الأبد».
- الأسرار خارج الكود: من متغيرات البيئة أو خزنة أسرار، مع فحص آلي للمستودع، كما في مقال اكتشاف الأسرار المسربة.
- Cookies بخصائصها الآمنة:
SecureوHttpOnlyوSameSiteفي إعداد واحد مركزي، كما في دليل الجلسات وJWT. - أقل الصلاحيات Least Privilege: مستخدم قاعدة البيانات الذي يعمل به التطبيق لا يملك صلاحية تعديل الجداول DDL، والعملية تعمل بمستخدم عادي وليس root، فإذا فاتك خطأ في الكود كان أثره محدوداً.
والصورة التالية تبين كيف تجتمع هذه الطبقات مع ما سبق حول قيمة واحدة، وهي كلمة بحث يكتبها المستخدم ثم تعرض له مرة أخرى:

التزامن Concurrency وثغرات TOCTOU
ثغرة وقت الفحص ووقت الاستخدام Time-of-check Time-of-use واختصاراً TOCTOU هي أن يفحص الكود شرطاً، ثم يتصرف بناء عليه، وبين اللحظتين يتغير الشيء الذي فحصه، كما يعرفها CWE-367. والمثال الكلاسيكي في الملفات: «إذا لم يكن الملف موجوداً فأنشئه»، فبين الفحص والإنشاء قد ينشئ طلب آخر الملف، أو رابطاً رمزياً بالاسم نفسه. والحل ألا تفصل الفحص عن الفعل، وإنما تطلب من النظام أن يفعلهما في عملية واحدة ذرية Atomic:
# Vulnerable: check, then act, two separate steps
if not os.path.exists(path):
with open(path, "w") as f:
f.write(data)
# Fixed: "x" mode = create only if it does not exist, in one system call
try:
with open(path, "x") as f:
f.write(data)
except FileExistsError:
abort(409)والوضع "x" بحسب توثيق open هو الإنشاء الحصري Exclusive Creation، ومثله في .NET FileMode.CreateNew وفي Java StandardOpenOption.CREATE_NEW.
وأشهر صورة لهذه الثغرة في تطبيقات الويب ليست الملفات، وإنما الرصيد والكوبونات والحجوزات: الكود يقرأ الرصيد، ويتأكد أنه كاف، ثم يخصم، وطلبان متزامنان يقرآن الرصيد نفسه قبل أن يخصم أي منهما، فيصرف الرصيد مرتين. والحل أن يكون الشرط جزءاً من عملية التعديل نفسها في قاعدة البيانات:
// Vulnerable: read, check in C#, then write; two requests can both pass the check
var account = await db.Accounts.SingleAsync(a => a.Id == id);
if (account.Balance < amount) return BadRequest("insufficient balance");
account.Balance -= amount;
await db.SaveChangesAsync();
// Fixed: the check and the update are one statement; FromSql-style interpolation = parameters
var rows = await db.Database.ExecuteSqlAsync(
$"UPDATE Accounts SET Balance = Balance - {amount} WHERE Id = {id} AND Balance >= {amount}");
if (rows == 0) return BadRequest("insufficient balance");ولاحظ أن ExecuteSqlAsync بالنص المستكمل آمن من الحقن مثل FromSql بحسب توثيق EF Core نفسه، والبديل الآخر قيد فريد Unique Constraint في قاعدة البيانات للعمليات التي يجب ألا تتكرر، مثل استخدام الكوبون مرة واحدة، أو التزامن المتفائل Optimistic Concurrency بعمود إصدار.
استخدم ما توفره المنصة، ولا تكتبه بنفسك
أغلب الأخطاء الأمنية الكبيرة في الكود ليست في الأماكن الصعبة، وإنما في أماكن كتب فيها المطور بنفسه شيئاً موجوداً في إطار العمل، والسبب أن الكود الذي يكتبه الإطار جربه ملايين المستخدمين وراجعه فريق أمني، أما الكود الذي كتبته أنت في يومين فلم يره أحد غيرك. والجدول التالي يجمع أهم ما يجب أن تأخذه من المنصة:
| الحاجة | لا تكتبها بنفسك، استخدم | التفاصيل |
|---|---|---|
| تخزين كلمات المرور والتشفير والأرقام العشوائية | Argon2id أو PBKDF2 من مكتبة موثوقة، وAES-GCM، وSecureRandom وsecrets وRandomNumberGenerator | التشفير للمطورين |
| تسجيل الدخول والجلسات | ASP.NET Core Identity، وSpring Security، ومزود هوية OIDC | الجلسات وJWT |
| الحماية من CSRF | Antiforgery في ASP.NET Core، وCSRF في Spring Security، وSameSite في Cookies | الجلسات وJWT |
| الصلاحيات | Policies في ASP.NET Core، و@PreAuthorize في Spring، أو محرك سياسات مركزي | الصلاحيات المركزية |
| ترويسات الأمان | الإعداد المركزي في ال Reverse Proxy أو Middleware الإطار | ترويسات الأمان |
| ترميز HTML والروابط | محرك القوالب والمرمزات المدمجة | قسم ترميز المخرجات أعلاه |
ويجدر الإشارة هنا إلى أن اللغات الأربع في هذا المقال تدير الذاكرة عنك، فلا تحتاج أن تقلق من تجاوز حدود المصفوفة Buffer Overflow في كودك العادي، ولكن المكتبات الأصلية Native Libraries التي تستدعيها، مثل محللات الصور والضغط، مكتوبة غالباً بلغة C أو C++، وفيها تبقى هذه المشكلة، وقد شرحنا الفكرة في مقال الذاكرة للمبرمجين Stack وHeap والمؤشرات.
نظافة الاعتماديات Dependency Hygiene
كل ما سبق في كودك، وأغلب الكود الذي يعمل في تطبيقك لم يكتبه فريقك، لذلك فالعادات اليومية للمطور هنا قصيرة وواضحة: ملف القفل Lock File محفوظ في Git، والتثبيت في خط CI من ملف القفل فقط، ولا تضف مكتبة لدالة من خمسة أسطر، وقبل إضافة مكتبة جديدة انظر إلى نشاطها ومن يصونها وترخيصها، وحدث الاعتماديات بانتظام بدل قفزة كبيرة كل سنة. والفحص الآلي للثغرات المعروفة في الاعتماديات شرحناه في مقال فحص الاعتماديات SCA مع OSV-Scanner وDependency-Track، والصورة الكاملة للتهديدات في دليل أمن سلسلة التوريد البرمجية.
إزالة التجربة Cleanup
ال Containers كانت مؤقتة بالخيار --rm فحذفت نفسها، ولم ننشئ أي Volume أو شبكة. وحذفنا ال Image التي سحبناها لهذه التجربة فقط بالاسم، أما python:3.12-slim فكانت موجودة على الجهاز قبل التجربة فتركناها:
docker ps -a --filter name=^lab-securecode- # nothing left
docker rmi eclipse-temurin:25-jdkقائمة تحقق Checklist لكل لغة، جاهزة للنسخ
القوائم التالية مكتوبة لتلصق في وصف طلب الدمج Pull Request أو في ملف قالب المراجعة، وكل بند فيها سؤال تجيب عنه بنعم أو لا، وقد شرحنا طريقة استخدام مثلها في المراجعة في قسم قائمة المراجعة الأمنية لكل طلب دمج.
C# و.NET
[ ] Request DTOs separate from EF entities; DataAnnotations or FluentValidation on every DTO; [ApiController] on API controllers
[ ] No Html.Raw / HtmlString with user data; JS values passed through data- attributes
[ ] No FromSqlRaw / ExecuteSqlRaw / SqlQueryRaw with interpolated user input; FromSql / ExecuteSql instead
[ ] ORDER BY / column names from a dictionary in code, never from the request
[ ] Process: UseShellExecute = false + ArgumentList; no /bin/sh -c or cmd /c with user data; no .bat/.cmd with user data
[ ] Paths: Path.Join + Path.GetFullPath + StartsWith(base + separator); no Path.Combine with user strings
[ ] Uploads: extension allowlist + magic bytes + Guid file name + storage outside wwwroot + size limits
[ ] No BinaryFormatter / NetDataContractSerializer / LosFormatter; Newtonsoft TypeNameHandling = None
[ ] XML: no DtdProcessing.Parse, no XmlUrlResolver on untrusted input
[ ] UseExceptionHandler + ProblemDetails outside Development; no exception text in responses
[ ] Security checks fail closed; no empty catch around token, policy or signature checks
[ ] FallbackPolicy requires an authenticated user; antiforgery on cookie-based forms
[ ] Atomic updates for balances, coupons, bookings (WHERE ... AND condition, unique constraints)Java وSpring
[ ] Request records with Jakarta Bean Validation (@Positive, @Pattern, @Size) and @Valid on every @RequestBody
[ ] DTOs separate from JPA entities (no mass assignment of owner, role, status fields)
[ ] Thymeleaf th:text everywhere; every th:utext reviewed; OWASP Java Encoder for manual HTML/JS/URL output
[ ] PreparedStatement / named parameters in JPQL, HQL and @Query; no string-built queries
[ ] ProcessBuilder with a List of arguments and "--"; no "sh -c" with user data
[ ] Paths: toRealPath() + startsWith(base) on Path objects, not on strings
[ ] No ObjectInputStream on external data; if unavoidable, ObjectInputFilter allowlist ending with !*
[ ] DocumentBuilderFactory / SAXParserFactory / XMLInputFactory hardened: disallow-doctype-decl, ACCESS_EXTERNAL_DTD = ""
[ ] YAML: SnakeYAML with safe constructors only, no global tags from untrusted documents
[ ] @RestControllerAdvice returns ProblemDetail with an error id; stack traces only in logs
[ ] Spring Security: anyRequest().authenticated() as the last rule; CSRF on for browser sessions
[ ] Files created with CREATE_NEW where existence matters; atomic UPDATE ... WHERE for balancesPython
[ ] Pydantic models (extra = "forbid") or framework forms for every request body
[ ] Jinja2 Environment(autoescape=select_autoescape()); every |safe and Markup() reviewed
[ ] Parameters (?, %s, :name) in every query; no f-strings or % in SQL; SQLAlchemy text() with bind params
[ ] subprocess with a list and no shell=True; "--" before user-controlled arguments
[ ] Paths: Path(...).resolve() + is_relative_to(base); never os.path.join with user absolute paths
[ ] tarfile extractall(filter="data"); zip member names checked like any user path
[ ] No pickle / marshal / shelve on external data; yaml.safe_load only
[ ] XML from outside: defusedxml, or at least stdlib ElementTree on a current Python/Expat
[ ] debug=False in production; one error handler that logs with an id and returns a generic body
[ ] No bare "except: pass"; specific exception types around security checks
[ ] open(path, "x") for create-if-absent; atomic UPDATE ... WHERE in the database
[ ] secrets module for tokens; hashing and keys as in the crypto articleJavaScript وNode.js
[ ] zod (strictObject) or another schema validator on every body, query and header you read
[ ] textContent / createElement in the DOM; no innerHTML, document.write or dangerouslySetInnerHTML with user data
[ ] encodeURIComponent for URL values; JSON data passed to pages in data- attributes or JSON script blocks
[ ] Parameterised queries ($1 / ?) or the ORM's safe API; no template literals inside SQL text
[ ] execFile / spawn with an argument array; no exec or shell: true with user data; no .bat/.cmd with user data
[ ] Paths: path.resolve + fs.realpath + startsWith(base + path.sep)
[ ] Uploads: size limits in the parser, allowlist + magic bytes, random names, storage outside the static folder
[ ] js-yaml load() without the js-types extension; no eval / new Function / vm on input
[ ] XML library with external entities disabled
[ ] Central error middleware: generic message + id to the client, stack only in the log; NODE_ENV=production
[ ] Atomic database updates for balances and one-time actions
[ ] package-lock.json committed; npm ci in CIأخطاء شائعة Common Mistakes
- التنظيف بدل الفصل: حذف علامات الاقتباس أو
<script>من المدخل، والصحيح قائمة مسموحة عند الدخول، ثم معاملات وترميز عند الخروج. - التحقق في المتصفح فقط: الحد في نموذج HTML أو في JavaScript، والطلب يرسل بأداة curl دون الواجهة.
- الترميز مرة واحدة عند الحفظ: قيمة مرمزة لصفحة HTML في قاعدة البيانات، ثم تخرج خاطئة في ملف CSV أو رابط أو رسالة.
- الوثوق بالقيمة من قاعدة البيانات: وهي في الأصل نص كتبه مستخدم، وهذا أصل XSS المخزن.
- كلمة Raw في ال ORM:
FromSqlRawأو$queryRawUnsafeأوtext()مع f-string، وكلها دمج نصوص بلباس ORM. - Shell لأنه أسهل:
shell=Trueأوexecأوsh -cلتشغيل برنامج واحد بمعامل واحد. - البحث عن
..في اسم الملف: والمسار الكامل يتجاوزه دون أي نقطتين، والمقارنة الصحيحة على المسار الحقيقي بعد التوحيد. - الاعتماد على Content-Type في الرفع: وهو نص يكتبه من يرسل الملف.
- نسخ إعداد XML من إجابة قديمة:
DtdProcessing.Parseأو محلل Java بإعداده الافتراضي لملفات من الخارج. - Stack Trace في الرد: أو رسالة الاستثناء كما هي، وفيها أسماء الجداول والمسارات.
- catch يكمل التنفيذ في فحص أمني: الفشل المفتوح باسم «لا نريد إيقاف المستخدمين».
- الفحص ثم الفعل: قراءة الرصيد ثم الخصم في خطوتين، أو فحص وجود الملف ثم إنشاؤه.
ماذا تقرأ بعد هذا المقال؟
هذا المقال جزء من مرحلة أمن التطبيقات AppSec، والمقالات التالية هي الأقرب إليه: الخريطة العامة للمخاطر، والتطبيق الذي نكسره ونصلحه، ثم الموضوعات التي أشرنا إليها دون أن نكررها:
دليلOWASP Top 10 للمبتدئين: أخطر عشر مشكلات في تطبيقات الويبأخطر عشر مشكلات أمنية في تطبيقات الويب، كل واحدة بمثال ضعيف ثم مصلح، وكيف تمنعها في الكود وأين تكتشفها.
دليلثغرات الويب بالتجربة: تطبيق نكسره ثم نصلحهنرى أثر أشهر ثغرات الويب على متجر صغير على جهازك، ثم السطر المسؤول عن كل ثغرة، ثم الإصلاح والاختبار الذي يثبته.
دليلالتشفير للمطورين: ماذا تستخدم ولماذا لا تخترع خوارزميتكأي هاش تستخدم لكلمات المرور، ومتى تحتاج التشفير المتماثل أو غير المتماثل، وأين تحفظ المفاتيح بأمان في تطبيقك.
دليلOWASP ASVS ومراجعة الكود الأمنية لكل Pull Requestكيف تكتب متطلبات أمان يمكن اختبارها مع OWASP ASVS، وتراجع كل طلب دمج أمنياً في عشرين دقيقة بقائمة جاهزة.
دليلSAST: كيف يجد تحليل الكود الثابت الثغرات قبل التشغيلكيف تقرأ أداة SAST الكود بحثاً عن الثغرات، وما الذي لا تراه أبداً، وكيف تمنع الإنذارات الخاطئة من أن تفقد الفريق ثقته بها.
دليلأمن ال API بالأمثلة مع OWASP API Security Top 10أشهر عشرة مخاطر في واجهات API كما ترتبها OWASP، ولكل خطر مثال لكود ضعيف وكيف تصلحه وتختبره.الخلاصة
وصلنا لنهاية الموضوع، وأهم ما فيه:
- حدود الثقة مكانان: الدخول حيث تتحقق، والخروج إلى كل مفسر حيث ترمز أو تفصل البيانات بواجهة آمنة، والثقة تتبع من كتب البيانات وليس أين خزنت.
- التحقق بقائمة مسموحة ومخطط Schema على السيرفر، مع Pydantic وzod وBean Validation وFluentValidation، وبكلاس طلب منفصل يرفض الحقول الإضافية، وبعد التوحيد Canonicalization وليس قبله.
- الترميز حسب السياق عند كل مخرج، والقوالب ترمزه عنك حتى تستخدم منافذ الهروب:
Html.Rawوth:utextو|safeوdangerouslySetInnerHTML، وJinja2 خارج Flask لا يرمز إلا إذا طلبت. - في التجربة رأينا الأمر نفسه ينفذ أمرين عبر ال Shell ويبقى بيانات في مصفوفة المعاملات، والمسار الكامل يتجاوز الدمج دون
..ويتوقف عند فحص المسار الحقيقي، وNode يرفض ملفات bat دون Shell بخطأ EINVAL. - فك التسلسل: BinaryFormatter يرمي استثناء في .NET 9 فما فوق، وObjectInputFilter بقائمة تنتهي بـ
!*في Java، وyaml.safe_loadفي Python، وjs-yaml الحديثة آمنة افتراضياً. - XML: محلل Java الافتراضي في JDK 25 قرأ الملف المحلي حتى أضفنا
disallow-doctype-decl، بينما رفضه XmlReader في .NET وElementTree في Python افتراضياً، وسطران من الإعداد أعادا الثغرة في .NET. - الأخطاء: رد عام بمعرف للمستخدم وتفاصيل في السجل مرة واحدة، والفشل مغلق في كل قرار أمني، والعمليات الحساسة للتزامن تكون ذرية في قاعدة البيانات.
وفي المقالات القادمة بإذن الله سوف نكمل المرحلة بمتطلبات ASVS ومراجعة الكود، حيث تتحول هذه الأنماط إلى أسئلة يطرحها المراجع على كل طلب دمج.
سجل التحديثات
- أكتوبر 2026: كتابة المقال، والتحقق من كل سلوك افتراضي من التوثيق الرسمي ومراجع OWASP Cheat Sheet Series، وتشغيل التجارب على python:3.12-slim (Python 3.12.15، PyYAML 6.0.3، pydantic 2.13.5، Jinja2 3.1.6) وeclipse-temurin:25-jdk (OpenJDK 25.0.4.1) داخل Docker Engine 29.8.0، و.NET 10.0.11 وNode.js v22.14.0 (zod 4.6.5، js-yaml 5.4.3) على جهاز Windows.