في OWASP Top 10 للمبتدئين مررنا على أخطر عشر مشكلات في تطبيقات الويب فئة فئة، ورأينا لكل فئة مثالاً قصيراً ضعيفاً وآخر مصلحاً، لكن قراءة السطر الضعيف شيء، ورؤيته يكسر تطبيقاً يعمل أمامك شيء آخر تماماً. لذلك سوف نأخذ في هذا المقال تطبيق متجر صغيراً مكتوباً ب Python وFlask، وهو نفس التطبيق التجريبي الذي فحصناه في تحليل الكود الثابت SAST وفحص التطبيق أثناء التشغيل DAST، ونشغله على جهازنا، ثم نكسر فيه سبع ثغرات Vulnerabilities واحدة واحدة، ونرى الأثر الحقيقي لكل منها، ثم نصل إلى السطر الذي سببها، ثم نصلحه ونكتب اختباراً Test يثبت أن الإصلاح يعمل ويبقى يعمل.
والهدف هنا دفاعي وتعليمي بالكامل، فلن تجد في المقال قوائم حمولات Payloads ولا طرقاً لتجاوز الحماية، وكل مدخل سوف تراه بسيط ولا يؤذي أحداً: علامة اقتباس في اسم كتاب، ووسم <b> يجعل النص عريضاً، ونموذج من صفحة على منفذ آخر في جهازك نفسه، والسبب أن ما يحتاجه المطور ليفهم الثغرة ويمنعها هو أن يرى أن المدخل تحول إلى كود، وهذا يظهر بأبسط مدخل ممكن.
وسوف نناقش في هذا المقال ما يلي:
- قواعد التجربة: تطبيقنا وحده، على العنوان 127.0.0.1 وحده.
- تجهيز نسخة من تطبيق السلسلة فيها سبع ثغرات، وملف اختبارات Tests يفشل عليها قبل أي إصلاح.
- حقن SQL Injection، وXSS المنعكس Reflected والمخزن Stored، وتزوير الطلب عبر المواقع CSRF، وIDOR، وإعادة التوجيه المفتوحة Open Redirect، وSSRF باختصار: لكل منها ماذا يحدث فعلاً، والسطر المسؤول، والإصلاح، والاختبار.
- تجربة CSRF في متصفح Chrome حقيقي، وما الذي يفعله المتصفح من تلقاء نفسه وما الذي لا يفعله.
- أي أداة تجد أي ثغرة: نتائج Semgrep على الكود نفسه قبل الإصلاح وبعده، ونتائج ZAP من مقال الفحص الديناميكي، وما لا تراه أي منهما.
- الأخطاء الشائعة، وقائمة تحقق Checklist لكل نموذج ومسار جديد.
قواعد التجربة Lab Rules: تطبيقنا، وجهازنا، ومدخلات لا تؤذي أحداً
قبل أي أمر، هذه القواعد التي تحكم المقال كله، وهي نفسها ما شرحناه في على أي هدف يحق لك أن تشغل الفحص:
- التطبيق تطبيقنا: كل الثغرات في كود كتبناه للتجربة، ولا يوجد في المقال أي طلب إلى موقع أو خدمة لا نملكها، والسبب أن تجربة ثغرة على نظام غيرك دون إذن مكتوب مخالفة للقانون في أغلب الدول، حتى لو كان المدخل نفسه بريئاً.
- الشبكة جهازنا: كل ال Containers تنشر منافذها على 127.0.0.1 فقط، والخدمة الداخلية لا تنشر أي منفذ أصلاً.
- المدخلات أبسط ما يثبت الفكرة: علامة اقتباس، ووسم <b>، ونموذج يغير بريد مستخدم تجريبي، ورابط لموقع وهمي ينتهي ب example.
- كل ثغرة تنتهي باختبار: لا يكفي أن ترى الإصلاح يعمل مرة، وإنما يجب أن يفشل البناء إذا عادت الثغرة بعد سنة على يد مطور لا يعرف القصة.
تجهيز التجربة Lab: نسخة من تطبيق السلسلة بسبع ثغرات
الصورة التالية تبين التجربة كاملة: المتجر يعمل بنسختين، الضعيفة على المنفذ 21080 والمصلحة على 21082، وصفحة «موقع آخر» على 21081 نستخدمها في تجربة CSRF، وخدمة داخلية لا يصل إليها إلا ال Containers على الشبكة نفسها، وتحتها الثغرات السبع والمكان الذي يقع فيه الضرر لكل منها:

ملفات التجربة Lab Files
نسخنا مجلد التطبيق التجريبي للسلسلة كما هو، وفيه أصلاً ثلاث ثغرات تخص هذا المقال: حقن SQL في البحث (VULN-02)، وXSS المنعكس في صفحة النتائج (VULN-03)، وإعادة التوجيه المفتوحة (VULN-05)، ثم أضفنا في نسختنا فقط أربع ثغرات بالترقيم الذي يطلبه ملف التطبيق، أي من VULN-10: XSS مخزن في التقييمات Reviews، وCSRF على تغيير البريد، وIDOR على الفواتير، وSSRF في معاينة الروابط. وبجانب الملف الضعيف app.py كتبنا app_fixed.py بنفس المسارات ونفس قاعدة البيانات، وكل إصلاح فيه معلم بتعليق FIX-xx برقم الثغرة التي يغلقها، وملف اختبارات واحد يعمل على النسختين. ولاحظ أننا حدثنا في هذه النسخة ال Base Image إلى python:3.12-slim والمكتبات إلى Flask 3.1.3، وجعلنا التطبيق يعمل بالمستخدم nobody، والسبب أن المكتبات القديمة (VULN-06) وال Base Image القديمة (VULN-07) موضوع مقالات أخرى في السلسلة، ولا نريد أن تختلط نتائجها هنا.
lab/
├── Dockerfile python:3.12-slim, Flask 3.1.3, pytest 9.1.1, USER nobody
├── app.py the vulnerable shop: VULN-02, 03, 05 + VULN-10 to 13
├── app_fixed.py the same routes, every flaw fixed (FIX-xx comments)
├── templates/ Jinja2 templates used by app_fixed.py only
├── test_web_vulns.py 12 security tests, run against either file
├── csrf-browser.mjs drives headless Chrome through the CSRF demo
├── other-site/ csrf-vuln.html and csrf-fixed.html, served on localhost:21081
└── internal/ index.html of the internal service (no host port)تشغيل ال Containers
الأوامر التالية تبني ال Docker Image وتشغل الخدمات الأربع من داخل مجلد التجربة، ولاحظ أن مفتاح الجلسة للنسخة المصلحة يأتي من متغير بيئة يولد عند التشغيل، وليس من الكود:
docker build -t lab-webvuln-app:1 .
docker network create lab-webvuln-net
# the internal service: only reachable from the lab network, no -p at all
docker run -d --name lab-webvuln-internal --network lab-webvuln-net --network-alias internal \
-v "$PWD/internal:/srv:ro" -w /srv python:3.12-slim python -m http.server 8080
# the vulnerable shop and the fixed shop
docker run -d --name lab-webvuln-vuln --network lab-webvuln-net \
-e LAB_PASSWORD=Lab-Only-Pass-27 -p 127.0.0.1:21080:8000 lab-webvuln-app:1
docker run -d --name lab-webvuln-fixed --network lab-webvuln-net \
-e LAB_PASSWORD=Lab-Only-Pass-27 -e SECRET_KEY="$(openssl rand -base64 32)" \
-p 127.0.0.1:21082:8000 lab-webvuln-app:1 python app_fixed.py
# "another site": a static page on a second origin
docker run -d --name lab-webvuln-other --user nobody -v "$PWD/other-site:/srv:ro" -w /srv \
-p 127.0.0.1:21081:8000 python:3.12-slim python -m http.server 8000
docker ps --filter name=lab-webvuln --format '{{.Names}} {{.Ports}}' والمخرج سوف يكون كما يلي:
lab-webvuln-fixed 127.0.0.1:21082->8000/tcp
lab-webvuln-vuln 127.0.0.1:21080->8000/tcp
lab-webvuln-other 127.0.0.1:21081->8000/tcp
lab-webvuln-internalفي المتجر ثلاثة مستخدمين تجريبيين، admin وalice وbob، ولكل من alice وbob فاتورة واحدة، 1001 ل alice و1002 ل bob، وفي المنتجات كتاب اسمه «O'Reilly Flask Book» وضعناه عن قصد، لأن اسمه فيه علامة اقتباس كما في أسماء كثيرة حقيقية.
الاختبارات Tests أولاً: إحدى عشرة نتيجة حمراء
قبل أن نلمس أي ثغرة، كتبنا ملف الاختبارات الذي يصف السلوك الذي نريده، وليس السلوك الموجود، فكل اختبار يقول ما يجب أن يحدث: علامة الاقتباس لا تكسر البحث، والنص يعود نصاً، والطلب الذي يغير البيانات دون توكن يرفض، وفاتورة غيرك غير موجودة، وهكذا. ويستخدم الملف عميل الاختبار Test Client في Flask، فلا يحتاج سيرفراً يعمل ولا شبكة، ويختار النسخة التي يختبرها من متغير البيئة APP_MODULE، وهذا النوع هو اختبار التكامل Integration Test الذي شرحناه في أنواع الاختبارات. وهذه بدايته، وسوف نعرض كل اختبار في قسم ثغرته:
os.environ.setdefault("DB_PATH", "/tmp/test-shop.db")
os.environ.setdefault("SECRET_KEY", "test-only-key")
os.environ.setdefault("LAB_PASSWORD", "test-only-pass")
mod = importlib.import_module(os.environ.get("APP_MODULE", "app"))
init_db = getattr(mod, "init_db", None) or mod.base.init_db
@pytest.fixture
def client():
init_db()
return mod.app.test_client()نشغله الآن على النسخة الضعيفة:
docker run --rm -e APP_MODULE=app lab-webvuln-app:1 python -m pytest -q --tb=no -rf -p no:cacheproviderوالمخرج سوف يكون كما يلي:
FFFFFFFFFF.F [100%]
=========================== short test summary info ============================
FAILED test_web_vulns.py::test_search_with_a_quote_works - assert 500 == 200
FAILED test_web_vulns.py::test_search_term_is_escaped - AssertionError: asser...
FAILED test_web_vulns.py::test_review_is_escaped - AssertionError: assert '<b...
FAILED test_web_vulns.py::test_csp_header - assert "default-src 'self'" in ''
FAILED test_web_vulns.py::test_email_change_without_csrf_token_is_refused - a...
FAILED test_web_vulns.py::test_session_cookie_is_samesite_lax - AssertionErro...
FAILED test_web_vulns.py::test_invoice_of_another_user_is_not_found - Asserti...
FAILED test_web_vulns.py::test_redirect_stays_on_this_site[https://other.example/]
FAILED test_web_vulns.py::test_redirect_stays_on_this_site[//other.example/]
FAILED test_web_vulns.py::test_redirect_stays_on_this_site[/\\other.example/]
FAILED test_web_vulns.py::test_preview_refuses_internal_host - assert 500 == 400
11 failed, 1 passed in 21.64sفي المخرج أعلاه لاحظ التالي:
- إحدى عشرة نتيجة من اثنتي عشرة حمراء، وهذا ما نريده تماماً في هذه المرحلة، فاختبار الأمان الذي ينجح على الكود الضعيف لا يختبر شيئاً.
- الاختبار الوحيد الناجح هو أن إعادة التوجيه إلى مسار داخل الموقع مثل
/settingsما زالت تعمل، وهو اختبار للوظيفة نفسها، حتى لا يأتي الإصلاح فيكسر الميزة بدل أن يصلحها. - الخيار
-p no:cacheproviderلأن التطبيق يعمل بالمستخدم nobody الذي لا يستطيع الكتابة في مجلد التطبيق، وهذا مقصود.
حقن SQL Injection: علامة اقتباس واحدة تكسر البحث
ماذا يحدث فعلاً؟ الأثر Impact كما يراه المستخدم
نبدأ ببحث عادي عن «Mouse»، ثم ببحث عن اسم الكتاب الذي فيه علامة اقتباس، كما يفعل أي مستخدم:
curl -s -w "\nHTTP %{http_code}\n" "http://127.0.0.1:21080/search?q=Mouse"
curl -s -o /dev/null -w "HTTP %{http_code}\n" "http://127.0.0.1:21080/search?q=O'Reilly"
docker logs lab-webvuln-vuln 2>&1 | grep -B3 OperationalError | tail -4والمخرج سوف يكون كما يلي:
<!doctype html><html><head><meta charset='utf-8'><title>Demo Shop</title></head><body><h1>Results for Mouse</h1><ul><li>Mouse: 45.5</li></ul></body></html>
HTTP 200
HTTP 500
File "/app/app.py", line 103, in search
rows = db().execute(f"SELECT name, price FROM products WHERE name LIKE '%{q}%'").fetchall()
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
sqlite3.OperationalError: near "Reilly": syntax errorمستخدم يبحث عن كتاب حقيقي حصل على خطأ 500، وسجل السيرفر يقول إن قاعدة البيانات وجدت خطأ نحوياً Syntax Error قرب كلمة Reilly. والسبب أن الاستعلام الذي وصل إلى SQLite أصبح ... LIKE '%O'Reilly%'، فعلامة الاقتباس التي كتبها المستخدم أغلقت النص الذي فتحه الكود، وبقيت كلمة Reilly خارج النص فصارت جزءاً من أمر SQL نفسه. وقد يتساءل البعض: هل المشكلة مجرد خطأ مزعج في البحث؟ والإجابة لا، فإذا كان المدخل يستطيع أن يكسر الاستعلام فهو يستطيع أن يغير معناه أيضاً، والطلب التالي يثبت ذلك دون أن يلمس أي بيانات حساسة، فهو يضيف شرطاً على السعر من خانة البحث:
curl -s -G -w "\nHTTP %{http_code}\n" "http://127.0.0.1:21080/search" \
--data-urlencode "q=%' AND price > 500 AND name LIKE '%"والمخرج سوف يكون كما يلي:
<!doctype html><html><head><meta charset='utf-8'><title>Demo Shop</title></head><body><h1>Results for %' AND price > 500 AND name LIKE '%</h1><ul><li>Monitor: 899.0</li></ul></body></html>
HTTP 200لاحظ أن الصفحة أعادت الشاشة وحدها، أي المنتج الوحيد الذي سعره أكبر من 500، مع أن الكود لا يحتوي على أي شرط للسعر، فالشرط جاء من المستخدم ونفذته قاعدة البيانات كأنه من كتابة المطور، وهذا هو تعريف الحقن Injection: البيانات أصبحت كوداً. ومن يستطيع أن يضيف شرطاً يستطيع أن يضيف غيره، ولا نحتاج أن نذهب أبعد من ذلك لنفهم الخطر.
السطر المسؤول Root Cause
السطر 103 في app.py يبني الاستعلام بدمج النص String Concatenation عبر f-string، والمفارقة أن دالة الدخول login() في الملف نفسه تستخدم الطريقة الصحيحة قبل عشرة أسطر فقط:
# login(): safe, the value is a parameter
row = db().execute("SELECT * FROM users WHERE username = ?", (username,)).fetchone()
# search(): VULN-02, the value is pasted into the SQL text
rows = db().execute(f"SELECT name, price FROM products WHERE name LIKE '%{q}%'").fetchall()وقد شرحنا في A05 الحقن بالصورة لماذا لا تعرف قاعدة البيانات أين انتهى كودك وأين بدأت بيانات المستخدم عندما يصلان في نص واحد، لذلك لن نعيده هنا، ونكتفي بالإصلاح.
الإصلاح Fix: استعلام بمعاملات Parameterized Query
الإصلاح سطر واحد: نص الاستعلام ثابت وفيه علامة ?، والقيمة ترسل منفصلة في Tuple، وهذا ما توثقه مكتبة sqlite3 نفسها تحت اسم العناصر النائبة Placeholders، ويوصي به OWASP Query Parameterization Cheat Sheet:
- rows = db().execute(f"SELECT name, price FROM products WHERE name LIKE '%{q}%'").fetchall()
+ rows = db().execute("SELECT name, price FROM products WHERE name LIKE ?", (f"%{q}%",)).fetchall()ونرسل الطلبين نفسيهما إلى النسخة المصلحة، ونطبع العنوان والنتائج فقط:
curl -s "http://127.0.0.1:21082/search?q=O'Reilly" | grep -o "<h1>.*</h1>\|<li>.*</li>"
curl -s -G "http://127.0.0.1:21082/search" --data-urlencode "q=%' AND price > 500 AND name LIKE '%" \
| grep -o "<h1>.*</h1>\|<ul>.*</ul>"والمخرج سوف يكون كما يلي:
<h1>Results for O'Reilly</h1>
<li>O'Reilly Flask Book: 150.0</li>
<h1>Results for %' AND price > 500 AND name LIKE '%</h1>
<ul></ul>في المخرج أعلاه لاحظ التالي:
- الكتاب ظهر في النتائج، فعلامة الاقتباس أصبحت حرفاً عادياً في قيمة البحث، والمستخدم الحقيقي حصل على ما يريد.
- الطلب الثاني أعاد قائمة فارغة، لأن قاعدة البيانات بحثت عن منتج اسمه يحتوي هذا النص الطويل كما هو، ولم تنفذ منه أي شرط.
- ظهرت علامة الاقتباس في الصفحة بالشكل
'، وهذا إصلاح آخر سوف نصل إليه في XSS، لأن النسخة المصلحة تكتب كل HTML من قوالب Jinja2.
الاختبار Test الذي يثبت الإصلاح
# VULN-02: a quote in normal input must not break or change the query
def test_search_with_a_quote_works(client):
r = client.get("/search?q=O'Reilly")
assert r.status_code == 200
assert "Reilly Flask Book" in r.get_data(as_text=True)لاحظ أن الاختبار لا يحتوي على أي مدخل هجومي، وإنما على اسم كتاب حقيقي، والسبب أن الاختبار يصف سلوك الميزة الصحيح، فإذا عاد أحد إلى دمج النص في المستقبل فسوف يفشل هذا الاختبار في خط CI قبل أن يصل الكود إلى المستخدمين.
من يكتشفه؟ أدوات الفحص Scanners
هذه أسهل ثغرة للأدوات، فقد وجدها Semgrep في تجربتنا على السطر 103 بالقاعدة tainted-sql-string التي تتبع القيمة من request.args إلى execute()، كما شرحنا في تتبع البيانات الملوثة Taint Analysis، ووجدها ZAP من الخارج في الفحص الكامل بالقاعدة 40018 SQL Injection على المعامل q، بدرجة ثقة منخفضة لأنه استنتجها من خطأ 500 كالذي رأيناه أعلاه.
البرمجة عبر المواقع XSS: عندما يصبح النص HTML
XSS هي حقن أيضاً، لكن المفسر Interpreter هنا هو المتصفح وليس قاعدة البيانات، فالكود يكتب نصاً من المستخدم داخل صفحة HTML دون تهريب Escaping، فيقرؤه المتصفح كوسوم Tags وليس كحروف. وقد شرحنا في ما الذي تمنعه CSP ما يستطيع السكربت المحقون أن يفعله بصلاحيات المستخدم، لذلك نركز هنا على الطريقين اللذين يصل منهما النص إلى الصفحة، وهما في الصورة التالية:

XSS المنعكس Reflected XSS في صفحة البحث
نبحث عن نص فيه وسم <b>، ونطلب الصفحة نفسها من النسختين:
curl -s -G "http://127.0.0.1:21080/search" --data-urlencode "q=<b>hi</b>"; echo
curl -s -G "http://127.0.0.1:21082/search" --data-urlencode "q=<b>hi</b>" | grep -i "results"والمخرج سوف يكون كما يلي:
<!doctype html><html><head><meta charset='utf-8'><title>Demo Shop</title></head><body><h1>Results for <b>hi</b></h1><ul></ul></body></html>
<h1>Results for <b>hi</b></h1>في النسخة الضعيفة عاد الوسم كما هو داخل العنوان، فيعرض المتصفح كلمة hi بخط عريض، وهذا يكفي ليثبت أن المتصفح فسر ما كتبه المستخدم ك HTML، وأي وسم آخر سوف يفسر بالطريقة نفسها، ومنه وسم السكربت. والسطر المسؤول هو 107، حيث تكتب القيمة q داخل f-string ثم تعاد كما هي:
return PAGE.format(f"<h1>Results for {q}</h1><ul>{items}</ul>")وفي النسخة المصلحة تحولت علامات < و> إلى ما يسمى الكيانات Entities، فيعرض المتصفح الحروف نفسها كنص، ولا يرى أي وسم.
XSS المخزن Stored XSS في التقييمات
في صفحة المنتج تستطيع أن تكتب تقييماً Review، وهنا يختلف المسار: alice تكتب التقييم مرة، ويحفظ في قاعدة البيانات، ثم يظهر لكل من يفتح الصفحة لاحقاً. نسجل الدخول ك alice، ونكتب تقييماً فيه وسم <b>، ثم نفتح الصفحة كزائر دون تسجيل:
V=http://127.0.0.1:21080
curl -s -c alice-v.txt -o /dev/null -d "username=alice&password=Lab-Only-Pass-27" $V/login
curl -s -b alice-v.txt -o /dev/null -w "review: HTTP %{http_code}\n" \
--data-urlencode "body=<b>great</b> keyboard" $V/product/1
curl -s $V/product/1 | grep -o "<ul>.*</ul>"والمخرج سوف يكون كما يلي:
review: HTTP 302
<ul><li><b>alice</b>: <b>great</b> keyboard</li></ul>الوسم الأول حول اسم alice من تصميم الصفحة، والثاني حول great كتبته alice، والمتصفح لا يفرق بينهما. والسطر المسؤول هو 127:
reviews = db().execute("SELECT author, body FROM reviews WHERE product_id = ?", (pid,)).fetchall()
items = "".join(f"<li><b>{r['author']}</b>: {r['body']}</li>" for r in reviews)لاحظ أن الاستعلام هنا آمن، فالحفظ والقراءة كلاهما بمعاملات، وهذه نقطة تستحق التوقف: الإصلاح في طبقة لا يحمي طبقة أخرى، فالمعاملات Parameters تحمي قاعدة البيانات من النص، والتهريب Escaping يحمي الصفحة من النص، ولكل مفسر حمايته. وهذا النوع أخطر من المنعكس، لأن المنعكس يحتاج أن يفتح الضحية رابطاً معيناً، أما المخزن فيعمل على كل من يزور الصفحة، دون رابط ودون أن يفعل شيئاً غير القراءة.
الإصلاح Fix: قوالب Jinja2 تهرب كل شيء، وCSP كطبقة ثانية
الحل الأول الذي يخطر على البال هو تنظيف المدخلات عند الحفظ بحذف علامات < و>، وهذا الحل غير مناسب، والسبب أن البيانات نفسها قد تحتاج هذه الحروف (معادلة رياضية، أو مقطع كود في تقييم)، وأن التنظيف يعتمد على أن تعرف كل الطرق الخطرة مسبقاً، وأن البيانات قد تصل إلى قاعدة البيانات من طريق آخر لا يمر بالتنظيف. والحل الأنسب هو التهريب عند الإخراج Output Encoding، أي في المكان الذي يصبح فيه النص HTML، كما يوصي OWASP XSS Prevention Cheat Sheet. وفي Flask لا تحتاج أن تفعل ذلك بيدك، فتوثيق Templates يقول إن التهريب التلقائي Autoescaping يعمل في كل القوالب التي تنتهي ب .html عند استخدام render_template()، لذلك نقلنا كل HTML في النسخة المصلحة إلى ملفات قوالب، وأصبحت الدالة كما يلي:
@app.route("/product/<int:pid>", methods=["GET", "POST"])
def product(pid):
if request.method == "POST":
db().execute("INSERT INTO reviews (product_id, author, body) VALUES (?, ?, ?)",
(pid, session.get("user", "guest"), request.form.get("body", "")))
db().commit()
return redirect(f"/product/{pid}")
reviews = db().execute("SELECT author, body FROM reviews WHERE product_id = ?", (pid,)).fetchall()
return render_template("product.html", reviews=reviews)والقالب templates/product.html كما يلي:
{% extends "layout.html" %}
{% block body %}
<h1>Reviews</h1>
<ul>{% for r in reviews %}<li><b>{{ r["author"] }}</b>: {{ r["body"] }}</li>{% endfor %}</ul>
<form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<textarea name="body"></textarea><button>Add review</button>
</form>
{% endblock %}في الإعداد أعلاه لاحظ التالي:
{{ r["body"] }}تهرب تلقائياً، فلا يوجد في الكود أي استدعاء للتهريب يمكن أن ينساه أحد، وهذا هو الفرق بين الإصلاح الصحيح والإصلاح الذي يعتمد على الذاكرة.- كل قيمة داخل خاصية Attribute محاطة بعلامتي اقتباس، مثل
value="{{ csrf_token() }}"، والسبب أن توثيق Security Considerations في Flask ينبه إلى أن التهريب لا يحميك في خاصية دون اقتباس. - الطرق التي تتجاوز التهريب هي ما يجب أن تبحث عنه في مراجعة الكود: الفلتر
|safe، وMarkup()على بيانات من المستخدم، وبناء HTML في Python كما في النسخة الضعيفة.
ثم أضفنا طبقة ثانية في كل استجابة، وهي ترويسة CSP مع X-Content-Type-Options، والتفاصيل الكاملة لكل توجيه Directive في دليل ترويسات الأمان:
@app.after_request
def security_headers(resp):
resp.headers["Content-Security-Policy"] = (
"default-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'"
)
resp.headers["X-Content-Type-Options"] = "nosniff"
return respونعيد التقييم نفسه على النسخة المصلحة، ولاحظ أن كل طلب POST يحتاج الآن توكن CSRF الذي سوف نشرحه في القسم التالي، لذلك نقرؤه من النموذج أولاً:
F=http://127.0.0.1:21082
J="-b alice-f.txt -c alice-f.txt"
token() { grep -o 'name="csrf_token" value="[^"]*"' | cut -d'"' -f4; }
T=$(curl -s $J $F/login | token)
curl -s $J -o /dev/null -w "login: HTTP %{http_code}\n" -d "username=alice&password=Lab-Only-Pass-27&csrf_token=$T" $F/login
T=$(curl -s $J $F/product/1 | token)
curl -s $J -o /dev/null -w "review: HTTP %{http_code}\n" --data-urlencode "body=<b>great</b> keyboard" -d "csrf_token=$T" $F/product/1
curl -s $F/product/1 | grep -o "<li>.*</li>"
curl -sI "$F/search?q=x" | grep -iE "^(content-security|x-content)"والمخرج سوف يكون كما يلي:
login: HTTP 302
review: HTTP 302
<li><b>alice</b>: <b>great</b> keyboard</li>
Content-Security-Policy: default-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'
X-Content-Type-Options: nosniffالتقييم حفظ كما كتبته alice تماماً، ولم نحذف منه حرفاً، لكنه يعرض كنص، والترويسة موجودة في كل استجابة، بينما لا توجد في النسخة الضعيفة أي منهما. وقد يتساءل البعض: إذا كانت CSP تمنع السكربت المحقون فلماذا نحتاج التهريب؟ والإجابة أن CSP تقلل أثر الثغرة ولا تزيلها، فالوسوم التي لا تحتوي سكربتاً، مثل نموذج مزيف أو رابط مزيف، تبقى تعمل، وقد رأينا ذلك بأرقام حقيقية في الترويسات لا تصلح الكود.
الاختبارات Tests التي تثبت الإصلاح
# VULN-03: what the user typed comes back as text, not as HTML
def test_search_term_is_escaped(client):
body = client.get("/search?q=<b>hi</b>").get_data(as_text=True)
assert "<b>hi</b>" not in body
assert "<b>hi</b>" in body
# VULN-10: a stored review is shown to every visitor as text
def test_review_is_escaped(client):
login(client, "alice")
token = form_token(client.get("/product/1").get_data(as_text=True))
client.post("/product/1", data={"body": "<b>great</b>", **token})
body = client.get("/product/1").get_data(as_text=True)
assert "<b>great</b>" not in body
assert "<b>great</b>" in body
# VULN-04 / second layer: a CSP that blocks inline scripts
def test_csp_header(client):
csp = client.get("/").headers.get("Content-Security-Policy", "")
assert "default-src 'self'" in cspلاحظ أن كل اختبار يتأكد من أمرين: أن الوسم الخام غير موجود، وأن النسخة المهربة موجودة، والسبب أن الشرط الأول وحده ينجح أيضاً إذا حذف الكود التقييم كله بالخطأ، فيظن الفريق أن الإصلاح يعمل وهو في الحقيقة كسر الميزة.
من يكتشفه؟ أدوات الفحص Scanners
وجد Semgrep الانعكاس على السطرين 104 و107 بالقاعدتين raw-html-format وdirectly-returned-format-string، ووجد التخزين أيضاً على السطر 127 بالقاعدة raw-html-format، لأن القاعدة تبحث عن نص يبنى ك HTML بيد المطور. ومن الخارج وجد ZAP الانعكاس في الفحص الكامل بالقاعدة 40012 Cross Site Scripting (Reflected)، أما المخزن فله في ZAP قاعدة مستقلة هي 40014 Cross Site Scripting (Persistent)، ولم تكن في تطبيق السلسلة الأصلي صفحة تقييمات حتى نراها في تلك التجربة. وبعد الإصلاح اختفت كل هذه النتائج من Semgrep، كما سوف ترى في جدول الأدوات.
تزوير الطلب عبر المواقع CSRF: موقع آخر يغير بريدك
شرحنا في ما الذي لا تمنعه سياسة المصدر الواحد أن المتصفح يمنع الموقع الآخر من قراءة ردودك، لكنه لا يمنعه من إرسال نموذج Form إليك، ويضيف المتصفح ملف تعريف الارتباط Cookie الخاص بجلستك إلى هذا الطلب حسب الخاصية SameSite. وهذا كل ما يحتاجه CSRF: طلب يغير البيانات، وتطبيق يكتفي بملف الجلسة دليلاً على أن المستخدم هو من أرسله. والصورة التالية تبين الخطوات في تجربتنا، ثم الطبقتين اللتين توقفانه:

السطر المسؤول Root Cause
@app.route("/settings/email", methods=["POST"])
def change_email():
if "user" not in session:
return {"error": "login required"}, 401
db().execute("UPDATE users SET email = ? WHERE username = ?", (request.form.get("email", ""), session["user"]))
db().commit()
return redirect("/settings")الدالة تبدو سليمة، ففيها فحص للدخول واستعلام بمعاملات، لكن السؤال الذي لا تسأله هو: هل جاء هذا الطلب من نموذج في موقعنا؟ وملف الجلسة لا يجيب عن هذا السؤال، لأن المتصفح يرسله مع الطلب أياً كان مصدره. ويزيد الأمر سوءاً أن Flask لا يضع الخاصية SameSite على ملف الجلسة إلا إذا طلبت ذلك، فهذا هو رأس الاستجابة عند الدخول في النسختين:
Set-Cookie: session=…; HttpOnly; Path=/
Set-Cookie: session=…; HttpOnly; Path=/; SameSite=Laxالسطر الأول من النسخة الضعيفة، والثاني من المصلحة، وحذفنا قيمة الجلسة نفسها.
التجربة في متصفح Browser حقيقي هو Chrome
هذه الثغرة لا تظهر بأمر curl، لأن الذي يضيف ملف الجلسة هو المتصفح، لذلك كتبنا سكربتاً صغيراً csrf-browser.mjs يقود متصفح Chrome دون واجهة Headless: يسجل دخول alice في المتجر، ثم يفتح صفحة الموقع الآخر، ثم يعود ليقرأ البريد. والصفحة على المنفذ 21081 كما يلي، ولاحظ أنها لا تفعل أكثر من إرسال نموذج فيه بريد تجريبي:
<!doctype html>
<!-- A page on a second local origin (http://localhost:21081) that submits a form to the shop on 127.0.0.1:21080.
It only changes the signed-in user's email inside the lab, to show what a cross-site POST can do. -->
<html><head><meta charset="utf-8"><title>Another site</title></head>
<body>
<p>A harmless looking page on another site.</p>
<form id="f" method="post" action="http://127.0.0.1:21080/settings/email">
<input type="hidden" name="email" value="[email protected]">
</form>
<script>document.getElementById("f").submit();</script>
</body></html>وقد يتساءل البعض: لماذا تفتح الصفحة من localhost والمتجر من 127.0.0.1، وهما الجهاز نفسه؟ والإجابة أن SameSite تتكلم عن «الموقع» Site وليس عن «المصدر» Origin، وبحسب تعريف MDN فالمنفذ Port لا يدخل في تحديد الموقع، أي أن 127.0.0.1:21081 و127.0.0.1:21080 موقع واحد في نظر المتصفح، فلن تختبر شيئاً، أما localhost و127.0.0.1 فهما اسمان مختلفان، فيعاملهما المتصفح كموقعين مختلفين Cross-Site كما في الحياة الحقيقية. نشغل السكربت على النسخة الضعيفة من جذر المستودع:
node content/articles/web-vulns-lab/lab/csrf-browser.mjs 21080 csrf-vuln.html
docker logs --tail 3 lab-webvuln-vulnوالمخرج سوف يكون كما يلي:
signed in: Signed in as alice
Search
before: Email: [email protected]
Change email
cookie: session sameSite=(not set)
after the other site: http://127.0.0.1:21080/settings | Email: [email protected] Change email
settings now: Email: [email protected]
Change email
192.168.48.1 - - [07/Oct/2026 10:14:19] "POST /settings/email HTTP/1.1" 302 -
192.168.48.1 - - [07/Oct/2026 10:14:19] "GET /settings HTTP/1.1" 200 -
192.168.48.1 - - [07/Oct/2026 10:14:22] "GET /settings HTTP/1.1" 200 -في المخرج أعلاه لاحظ التالي:
- alice لم تضغط أي زر في الموقع الآخر، فقد كفى أن تفتح الصفحة، فأرسلت الصفحة النموذج وأضاف المتصفح ملف الجلسة، وسجل المتجر طلب POST ناجحاً، وتغير البريد.
- تغيير البريد تحديداً من أخطر ما يمكن أن يصيبه CSRF، والسبب أن الخطوة التالية في أغلب التطبيقات هي «نسيت كلمة المرور»، التي ترسل رابط الاستعادة إلى البريد الجديد.
- ملف الجلسة دون SameSite كما رأينا في رأس الاستجابة.
ماذا يفعل Chrome من تلقاء نفسه؟ ولماذا لا تعتمد عليه
قد تقرأ أن المتصفحات الحديثة تحمي من CSRF وحدها، وهذا صحيح جزئياً، فبحسب الأسئلة الشائعة عن SameSite في Chromium يعامل Chrome ملف تعريف الارتباط الذي لا يحدد SameSite كأنه Lax، لكن مع استثناء: ملف عمره دقيقتان أو أقل يرسل مع طلب POST من موقع آخر ينقل الصفحة كلها، وهذا الاستثناء وضع حتى لا تنكسر بعض أنظمة الدخول الموحد. أي أن تجربتنا نجحت لأن الصفحة فتحت بعد الدخول بثوان. ولنتأكد، أعدنا التجربة نفسها مع انتظار 130 ثانية بين الدخول وفتح الموقع الآخر:
node content/articles/web-vulns-lab/lab/csrf-browser.mjs 21080 csrf-vuln.html 130والمخرج سوف يكون كما يلي، مع سطر الطلب من سجل المتجر:
cookie: session sameSite=(not set)
waiting 130s…
after the other site: http://127.0.0.1:21080/settings/email | {"error":"login required"}
settings now: Email: [email protected]
Change email
192.168.48.1 - - [07/Oct/2026 06:06:56] "POST /settings/email HTTP/1.1" 401 -هذه المرة وصل الطلب إلى المتجر دون ملف الجلسة، فرد ب 401 ولم يتغير شيء. وهذه الحماية مفيدة، لكنها ليست حمايتك أنت، فهي سلوك متصفح واحد، فيه نافذة زمنية بعد كل دخول، ويطبق على ملفات لم تحدد لها SameSite فقط، وكل ذلك قد يتغير في إصدار قادم أو لا يوجد أصلاً في متصفح آخر، كما ذكرنا في خصائص ملفات تعريف الارتباط. لذلك لا تقم بترك الحماية من CSRF للمتصفح، وضع الطبقتين في تطبيقك.
الإصلاح Fix: توكن CSRF في كل نموذج، وSameSite=Lax
الطبقة الأولى هي توكن التزامن Synchronizer Token الذي يصفه OWASP CSRF Prevention Cheat Sheet: قيمة عشوائية لكل جلسة، توضع في حقل مخفي في كل نموذج، ويفحصها السيرفر في كل طلب POST. والموقع الآخر لا يستطيع أن يعرفها، لأنه لا يستطيع قراءة صفحاتنا بسبب سياسة المصدر الواحد. وفي النسخة المصلحة كتبناها بأسطر قليلة حتى ترى الفكرة كاملة:
# FIX-11: one random token per session, written into every form and checked on every POST.
@app.template_global()
def csrf_token():
if "csrf" not in session:
session["csrf"] = secrets.token_urlsafe(32)
return session["csrf"]
@app.before_request
def check_csrf():
if request.method == "POST":
sent = request.form.get("csrf_token", "")
if not sent or not hmac.compare_digest(sent, session.get("csrf", "")):
abort(403)في الكود أعلاه لاحظ التالي:
- التوكن من
secrets.token_urlsafeوليس منrandom، لأنه يجب ألا يمكن تخمينه، والفرق بين المولدين في العشوائية Randomness. - المقارنة ب
hmac.compare_digestالتي تأخذ الزمن نفسه أياً كان موضع الاختلاف، كما شرحنا في المقارنة في زمن ثابت. - الفحص في
before_requestلكل طلب POST، وليس داخل كل دالة، فالمسار الجديد الذي يضيفه أحد بعد سنة محمي دون أن يتذكر أحد ذلك. وفي تطبيق حقيقي لا تكتب هذا بنفسك، واستخدم CSRFProtect من Flask-WTF الذي يفعل الشيء نفسه مع حالات أكثر، فتوثيق Flask يقول صراحة إن إطار العمل لا يحمي من CSRF وحده. - عند الدخول نستدعي
session.clear()قبل وضع المستخدم، فتبدأ الجلسة الجديدة بتوكن جديد.
والطبقة الثانية سطر إعداد واحد، يجعل المتصفح لا يرسل ملف الجلسة مع طلب POST يبدأ من موقع آخر، بغض النظر عن نافذة Chrome أو سلوك أي متصفح افتراضي:
app.config.update(SESSION_COOKIE_SAMESITE="Lax", SESSION_COOKIE_HTTPONLY=True)ونعيد تجربة المتصفح على النسخة المصلحة، مع صفحة الموقع الآخر نفسها لكن موجهة إلى المنفذ 21082:
node content/articles/web-vulns-lab/lab/csrf-browser.mjs 21082 csrf-fixed.html
docker logs --tail 3 lab-webvuln-fixedوالمخرج سوف يكون كما يلي:
signed in: Signed in as alice
Search
before: Signed in as alice
Email: [email protected]
Change email
cookie: session sameSite=Lax
after the other site: http://127.0.0.1:21082/settings/email | Forbidden You don't have the permission to access the requested resource. It is
settings now: Signed in as alice
Email: [email protected]
Change email
192.168.48.1 - - [07/Oct/2026 10:14:58] "GET /settings HTTP/1.1" 200 -
192.168.48.1 - - [07/Oct/2026 10:14:59] "POST /settings/email HTTP/1.1" 403 -
192.168.48.1 - - [07/Oct/2026 10:15:02] "GET /settings HTTP/1.1" 200 -رفض الطلب ب 403 لأن النموذج لا يحمل التوكن، والبريد بقي كما هو، والجلسة ما زالت مسجلة. ولاحظ أن ملف الجلسة الآن SameSite=Lax، فلو نسي أحد التوكن في نموذج جديد يوماً فالمتصفح لن يرسل الجلسة مع الطلب أصلاً، وهذا معنى الطبقتين المستقلتين.
الاختبارات Tests التي تثبت الإصلاح
# VULN-11: a state-changing POST without the form token is refused, and nothing changes
def test_email_change_without_csrf_token_is_refused(client):
login(client, "alice")
r = client.post("/settings/email", data={"email": "[email protected]"})
assert r.status_code == 403
assert "[email protected]" in client.get("/settings").get_data(as_text=True)
def test_session_cookie_is_samesite_lax(client):
r = login(client, "alice")
assert "SameSite=Lax" in r.headers.get("Set-Cookie", "")الاختبار الأول يرسل ما يرسله الموقع الآخر بالضبط، أي جلسة صحيحة دون توكن، ويتأكد من الرد ومن أن البيانات لم تتغير، والثاني يحرس سطر الإعداد، لأن هذا النوع من الأسطر يحذف بسهولة عند «تنظيف» ملف الإعداد.
من يكتشفه؟ أدوات الفحص Scanners
هنا تبدأ حدود الأدوات بالظهور، فلم يجد Semgrep أي شيء عن CSRF في app.py، لأن النماذج مكتوبة كنصوص داخل Python ولا توجد قاعدة تعرف أن هذا المسار يغير البيانات. والطريف أن النتيجة الوحيدة عن CSRF ظهرت بعد الإصلاح، على قوالب النسخة المصلحة، من قاعدة django-no-csrf-token التي تبحث عن الوسم الخاص ب Django ولا تعرف دالتنا، أي أنها إنذار خاطئ False Positive من قواعد إطار عمل آخر، وهي نفس مشكلة الضجيج Noise التي رأيناها في تجربة الفحص الثابت. أما ZAP فيجد غياب التوكن بالفحص السلبي، بالقاعدة 10202 Absence of Anti-CSRF Tokens التي ظهرت في الفحص الأساسي على نموذج الدخول، وهو مؤشر مفيد، لكنه يخبرك أن التوكن غائب، ولا يخبرك أن الطلب نجح من موقع آخر، فهذا ما يثبته الاختبار.
IDOR: الرقم في الرابط ليس إذناً Permission
الإشارة المباشرة غير الآمنة إلى الكائن Insecure Direct Object Reference واختصاراً IDOR هي أشهر صور التحكم في الوصول المكسور Broken Access Control، وقد بنينا لها تجربة كاملة في A01 في OWASP Top 10، وشرحنا صورتها في ال APIs باسم BOLA في أمن واجهات API، لذلك نمر عليها هنا بسرعة في صفحة الفواتير، والصورة التالية تبين الفكرة:

نسجل الدخول ك alice في النسختين، ونطلب فاتورتها ثم فاتورة bob، ومن النسخة المصلحة نطلب أيضاً رقماً غير موجود:
for id in 1001 1002; do curl -s -b alice-v.txt -w " HTTP %{http_code}\n" http://127.0.0.1:21080/invoice/$id; done
for id in 1001 1002 1003; do curl -s -b alice-f.txt -w " HTTP %{http_code}\n" http://127.0.0.1:21082/invoice/$id; doneوالمخرج سوف يكون كما يلي:
{"amount":420.0,"id":1001,"owner":"alice"}
HTTP 200
{"amount":9800.0,"id":1002,"owner":"bob"}
HTTP 200
{"amount":420.0,"id":1001,"owner":"alice"}
HTTP 200
{"error":"not found"}
HTTP 404
{"error":"not found"}
HTTP 404النسخة الضعيفة أعطت alice فاتورة bob، لأن الكود يتأكد أنها مسجلة ثم يثق بالرقم القادم في الرابط. والإصلاح أن يكون المالك جزءاً من الاستعلام نفسه، فلا يمكن أن تخرج من قاعدة البيانات فاتورة لا يملكها صاحب الجلسة:
- row = db().execute("SELECT id, owner, amount FROM invoices WHERE id = ?", (invoice_id,)).fetchone()
+ row = db().execute("SELECT id, owner, amount FROM invoices WHERE id = ? AND owner = ?",
+ (invoice_id, session["user"])).fetchone()ولاحظ أن فاتورة bob وفاتورة 1003 غير الموجودة أعادتا الرد نفسه، والسبب أن الرد 403 لفاتورة bob يخبر المهاجم أن الرقم موجود، فيعرف أي الأرقام يستحق المحاولة، كما يوصي OWASP IDOR Prevention Cheat Sheet بفحص الصلاحية في كل طلب. وإذا كان تطبيقك يخدم عدة مؤسسات فهذا الشرط نفسه هو ما تفرضه قاعدة البيانات عليك بسياسات الصفوف Row-Level Security في بناء تطبيق SaaS متعدد المستأجرين، حتى لا يعتمد العزل على أن يتذكر كل مطور الشرط في كل استعلام، وإذا تعقدت القواعد (مدير يرى فواتير فريقه، ومحاسب يرى كل الفواتير) فمكانها الصلاحيات المركزية Authorization. والاختبار كما يلي:
# VULN-12: alice may read her invoice, and bob's looks like it does not exist
def test_invoice_of_another_user_is_not_found(client):
login(client, "alice")
assert client.get("/invoice/1001").status_code == 200
assert client.get("/invoice/1002").status_code == 404وهذا الاختبار هو الأداة الوحيدة في المقال التي ترى هذه الثغرة، فلم يجد Semgrep أي شيء، لأن الاستعلام بمعاملات وسليم من ناحية الحقن، والمشكلة في المنطق وليس في نمط الكود، وتقول وثائق ZAP كما ذكرنا في الفحص النشط إنه لا يجد الثغرات المنطقية مثل التحكم في الوصول المكسور، لأنه لا يعرف أن الفاتورة 1002 ليست ل alice. لذلك اجعل اختبار «مستخدم يطلب كائن غيره» جزءاً ثابتاً من كل مسار فيه رقم.
إعادة التوجيه المفتوحة Open Redirect: رابطنا، وموقع غيرنا
المسار /go?next= موجود في تطبيقات كثيرة بأسماء مختلفة، مثل العودة إلى الصفحة السابقة بعد الدخول، والمشكلة أنه يوجه إلى أي عنوان يصله. والصورة التالية تبين هذه الثغرة مع SSRF، لأن الاثنتين تبدآن من نفس الخطأ: الثقة برابط يرسله المستخدم:

نطلب المسار بثلاث قيم في النسختين، مسار داخلي، ورابط كامل لموقع آخر، ورابط يبدأ بشرطتين، ونطبع رأس Location فقط:
for u in http://127.0.0.1:21080 http://127.0.0.1:21082; do echo "== $u"
for n in "/settings" "https://other.example/" "//other.example/"; do
printf "next=%-24s " "$n"
curl -s -o /dev/null -D - -G "$u/go" --data-urlencode "next=$n" | grep -i '^location' | tr -d '\r'
done
doneوالمخرج سوف يكون كما يلي:
== http://127.0.0.1:21080
next=/settings Location: /settings
next=https://other.example/ Location: https://other.example/
next=//other.example/ Location: //other.example/
== http://127.0.0.1:21082
next=/settings Location: /settings
next=https://other.example/ Location: /
next=//other.example/ Location: /الرابط الذي يبدأ بشرطتين //other.example/ يستحق الانتباه، فهو يبدو مساراً لأنه يبدأ ب /، لكن المتصفح يقرؤه كرابط إلى مضيف آخر بنفس المخطط Scheme، لذلك يفشل الحل البسيط الذي يكتفي بفحص startswith("/"). وضرر هذه الثغرة غير مباشر، فهي لا تمس بياناتك، لكنها تجعل رابطاً يبدأ بنطاقك الموثوق ينتهي في صفحة تصيد Phishing، والمستخدم الذي تعلم أن يتأكد من النطاق قبل الضغط سوف يرى نطاقك. والإصلاح في النسخة المصلحة يقبل مساراً داخلياً فقط، ويرجع إلى الصفحة الرئيسية في أي حالة أخرى:
def safe_next(target):
# FIX-05: only a path on this site: starts with one "/", no scheme, no host, no backslash.
parts = urlsplit(target)
if parts.scheme or parts.netloc or not target.startswith("/") or target.startswith("//") or "\\" in target:
return "/"
return targetوإذا كان تطبيقك يحتاج فعلاً إلى التوجيه لمواقع خارجية فاستخدم قائمة سماح بالمضيفين Allowlist، أو معرفاً قصيراً يقابل رابطاً مخزناً عندك بدل الرابط نفسه، كما يوصي OWASP Unvalidated Redirects Cheat Sheet. والاختبار يجرب الأشكال الثلاثة، ويتأكد أن الميزة نفسها ما زالت تعمل:
# VULN-05: /go only redirects inside this site
@pytest.mark.parametrize("target", ["https://other.example/", "//other.example/", "/\\other.example/"])
def test_redirect_stays_on_this_site(client, target):
r = client.get("/go", query_string={"next": target})
assert r.headers["Location"] == "/"
def test_redirect_to_a_local_path_still_works(client):
assert client.get("/go?next=/settings").headers["Location"] == "/settings"ومن ناحية الأدوات فهذه الثغرة مثال جيد على أن اختيار القواعد Rules يغير النتيجة، فمجموعتا Semgrep العامتان p/python وp/flask لم تجداها، بينما وجدتها مجموعة p/default بالقاعدة open-redirect على السطر 114. ثم بقيت القاعدة نفسها تشير إلى النسخة المصلحة، لأنها ترى قيمة من الطلب تصل إلى redirect() ولا تعرف ما تفعله safe_next()، فراجعنا النتيجة وتجاهلناها في هذا السطر وحده، باسم القاعدة ومع السبب، بالطريقة التي شرحناها في الإنذار الخاطئ وتجاهله:
@app.route("/go")
def go():
# Reviewed 2026-10: safe_next() returns "/" for anything that is not a local path (tests in test_web_vulns.py).
# nosemgrep: python.flask.security.open-redirect.open-redirect
return redirect(safe_next(request.args.get("next", "/")))ولاحظ أن السبب المكتوب يشير إلى الاختبارات، فالتجاهل مقبول لأن هناك اختباراً يفشل إذا تغيرت safe_next() يوماً. ولم يظهر هذا المسار في تقارير ZAP في الفحص الكامل، والتفسير الأقرب أن الصفحة الرئيسية لا تحتوي رابطاً إليه، وما لا يصل إليه الزاحف لا يفحص.
SSRF باختصار: السيرفر يجلب ما لا تصل إليه أنت
تزوير الطلب من جهة السيرفر Server-Side Request Forgery واختصاراً SSRF شرحناه بالتفصيل في API7 في أمن واجهات API، ومعه تجربة كاملة فيها خدمة بيانات وصفية Metadata وهمية وإعادة توجيه تتجاوز قائمة السماح في النسخة الضعيفة من تجربة ال API، لذلك نكتفي هنا بصورتها في صفحة ويب: ميزة معاينة الرابط Link Preview التي تجلب عنوان أي صفحة يرسلها المستخدم. نطلب العنوان الداخلي من جهازنا مباشرة، ثم عبر المتجر في النسختين، ثم رابطاً مسموحاً في النسخة المصلحة:
curl -s -m 3 -o /dev/null -w "from my laptop: HTTP %{http_code}\n" http://internal:8080/
curl -s -G -w " HTTP %{http_code}\n" "http://127.0.0.1:21080/preview" --data-urlencode "url=http://internal:8080/"
curl -s -G -w " HTTP %{http_code}\n" "http://127.0.0.1:21082/preview" --data-urlencode "url=http://internal:8080/"
curl -s -G -w " HTTP %{http_code}\n" "http://127.0.0.1:21082/preview" --data-urlencode "url=https://docs.python.org/3/"والمخرج سوف يكون كما يلي:
from my laptop: HTTP 000
{"title":"Internal status page (reachable from the server network only)","url":"http://internal:8080/"}
HTTP 200
{"error":"host not allowed"}
HTTP 400
{"title":"3.14.8 Documentation","url":"https://docs.python.org/3/"}
HTTP 200جهازنا لا يعرف الاسم internal أصلاً، لكن المتجر يعرفه لأنه على نفس شبكة Docker، فجلب الصفحة الداخلية وأعاد عنوانها، وهذا كل ما تعنيه SSRF: المستخدم يستعير موقع السيرفر في الشبكة. والسطر المسؤول هو 172، urllib.request.urlopen(url) على رابط من الطلب دون أي فحص، والإصلاح يقبل https فقط، ومضيفاً من قائمة سماح، والمنفذ الافتراضي، ولا يتبع إعادة التوجيه، فيرفض العنوان الداخلي ب 400 قبل أن يخرج أي طلب، كما يوصي OWASP SSRF Prevention Cheat Sheet:
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *args, **kwargs):
return None # FIX-13: a redirect could lead from an allowed host to an internal one
@app.route("/preview")
def preview():
url = request.args.get("url", "")
parts = urlsplit(url)
# FIX-13: https only, a host from the allowlist, the default port, and no redirects.
if parts.scheme != "https" or parts.hostname not in PREVIEW_HOSTS or parts.port not in (None, 443):
return {"error": "host not allowed"}, 400
html = urllib.request.build_opener(NoRedirect).open(url, timeout=3).read(65536).decode("utf-8", "replace")
m = re.search(r"<title>(.*?)</title>", html, re.S)
return {"url": url, "title": m.group(1).strip() if m else None}والاختبار يتأكد أن الرفض يحدث قبل أي طلب، فهو يعمل في عميل الاختبار دون شبكة، وفي النسخة الضعيفة يفشل بخطأ 500 لأنها تحاول الاتصال فعلاً:
# VULN-13: the preview refuses hosts outside the allowlist before sending any request
def test_preview_refuses_internal_host(client):
r = client.get("/preview", query_string={"url": "http://internal:8080/"})
assert r.status_code == 400ووجدها Semgrep على السطر 171 بالقاعدة ssrf-injection-urllib في التشغيلين، مع p/python وp/flask ومع p/default، وأضافت p/default تنبيهاً ثانياً dynamic-urllib-use-detected على السطر 172.
أي أداة تجد أي ثغرة؟ Semgrep وZAP والاختبارات Tests
شغلنا Semgrep 1.178.0 على الملفين بالمجموعات نفسها التي استخدمناها في مقال الفحص الثابت، مرة على النسخة الضعيفة ومرة على المصلحة مع قوالبها:
docker run --rm -v "$PWD:/src:ro" -w /src semgrep/semgrep:1.178.0 \
semgrep scan --config p/default --metrics=off --emacs --quiet app.py
docker run --rm -v "$PWD:/src:ro" -w /src semgrep/semgrep:1.178.0 \
semgrep scan --config p/default --metrics=off --emacs --quiet app_fixed.py templatesوالمخرج سوف يكون كما يلي، بعد قطع كل سطر عند نهاية الكود لأن الرسائل طويلة:
app.py:19:1:error(avoid_hardcoded_config_SECRET_KEY):app.config["SECRET_KEY"] = "flask-dev-secret-2024"
app.py:99:5:warning(sql-injection-db-cursor-execute): q = request.args.get("q", "")
app.py:103:12:warning(formatted-sql-query): rows = db().execute(f"SELECT name, price FROM products WHERE name LIKE '%{q}%'").fetchall()
app.py:103:12:error(sqlalchemy-execute-raw-query): rows = db().execute(f"SELECT name, price FROM products WHERE name LIKE '%{q}%'").fetchall()
app.py:103:25:error(tainted-sql-string): rows = db().execute(f"SELECT name, price FROM products WHERE name LIKE '%{q}%'").fetchall()
app.py:103:25:error(tainted-sql-string): rows = db().execute(f"SELECT name, price FROM products WHERE name LIKE '%{q}%'").fetchall()
app.py:104:21:warning(raw-html-format): items = "".join(f"<li>{r[0]}: {r[1]}</li>" for r in rows)
app.py:104:21:warning(raw-html-format): items = "".join(f"<li>{r[0]}: {r[1]}</li>" for r in rows)
app.py:107:5:warning(directly-returned-format-string): return PAGE.format(f"<h1>Results for {q}</h1><ul>{items}</ul>")
app.py:107:12:warning(raw-html-format): return PAGE.format(f"<h1>Results for {q}</h1><ul>{items}</ul>")
app.py:107:12:warning(raw-html-format): return PAGE.format(f"<h1>Results for {q}</h1><ul>{items}</ul>")
app.py:114:12:error(open-redirect): return redirect(request.args.get("next", "/"))
app.py:127:21:warning(raw-html-format): items = "".join(f"<li><b>{r['author']}</b>: {r['body']}</li>" for r in reviews)
app.py:171:5:error(ssrf-injection-urllib): url = request.args.get("url", "")
app.py:172:12:warning(dynamic-urllib-use-detected): html = urllib.request.urlopen(url, timeout=3).read(65536).decode("utf-8", "replace")
app.py:197:5:warning(avoid_app_run_with_bad_host): app.run(host="0.0.0.0", port=8000)
app_fixed.py:173:5:warning(avoid_app_run_with_bad_host): app.run(host="0.0.0.0", port=8000)
templates/login.html:4:1:warning(django-no-csrf-token):<form method="post">
templates/product.html:5:1:warning(django-no-csrf-token):<form method="post">
templates/settings.html:4:1:warning(django-no-csrf-token):<form method="post" action="/settings/email">في المخرج أعلاه لاحظ التالي:
- النسخة الضعيفة أعطت ست عشرة نتيجة، منها خمس للحقن وحده من قواعد مختلفة، ونتيجة لمفتاح الجلسة المكتوب في الكود (VULN-01)، وهذا ما شرحناه عن الضجيج Noise في الدقة والشمول.
- لا توجد أي نتيجة على السطر 150 (CSRF) ولا على السطر 161 (IDOR)، وهما ثغرتان حقيقيتان نجح استغلالهما أعلاه.
- النسخة المصلحة أعطت أربع نتائج كلها ليست ثغرات: الاستماع على 0.0.0.0 الذي يحتاجه التطبيق داخل ال Container ومنافذه منشورة على 127.0.0.1 فقط، وثلاث نتائج لقاعدة Django على قوالب Flask كما ذكرنا، أما نتيجة
open-redirectفاختفت بالتجاهل المراجع.
والصورة التالية تجمع النتائج كلها، Semgrep من تجربتنا هنا، وZAP من الفحص الكامل في مقال الفحص الديناميكي على تطبيق السلسلة الأصلي، والاختبارات من الملف الذي كتبناه:

والجدول نفسه بالتفصيل، مع الإصلاح لكل ثغرة:
| الثغرة | السطر في app.py | الإصلاح | Semgrep | ZAP | الاختبار |
|---|---|---|---|---|---|
| حقن SQL Injection | 103 | استعلام بمعاملات | tainted-sql-string وأربع قواعد أخرى | 40018 في الفحص الكامل | test_search_with_a_quote_works |
| Reflected XSS | 104 و107 | قوالب Jinja2 وCSP | raw-html-format | 40012 في الفحص الكامل | test_search_term_is_escaped |
| Stored XSS | 127 | قوالب Jinja2 وCSP | raw-html-format | القاعدة 40014، والميزة غير موجودة في تطبيق ذلك المقال | test_review_is_escaped |
| CSRF | 150 | توكن في كل نموذج وSameSite=Lax | لا شيء | 10202 غياب التوكن | test_email_change_without_csrf_token_is_refused |
| IDOR | 161 | المالك في الاستعلام و404 | لا شيء | لا شيء | test_invoice_of_another_user_is_not_found |
| Open Redirect | 114 | مسار داخلي فقط | p/default فقط | لم يبلغ عنها | test_redirect_stays_on_this_site |
| SSRF | 172 | https وقائمة سماح ودون إعادة توجيه | ssrf-injection-urllib | الميزة غير موجودة في تطبيق ذلك المقال | test_preview_refuses_internal_host |
وأخيراً نشغل الاختبارات على النسخة المصلحة:
docker run --rm -e APP_MODULE=app_fixed lab-webvuln-app:1 python -m pytest -v -p no:cacheproviderوالمخرج سوف يكون كما يلي:
============================= test session starts ==============================
platform linux -- Python 3.12.15, pytest-9.1.1, pluggy-1.6.0 -- /usr/local/bin/python
rootdir: /app
collecting ... collected 12 items
test_web_vulns.py::test_search_with_a_quote_works PASSED [ 8%]
test_web_vulns.py::test_search_term_is_escaped PASSED [ 16%]
test_web_vulns.py::test_review_is_escaped PASSED [ 25%]
test_web_vulns.py::test_csp_header PASSED [ 33%]
test_web_vulns.py::test_email_change_without_csrf_token_is_refused PASSED [ 41%]
test_web_vulns.py::test_session_cookie_is_samesite_lax PASSED [ 50%]
test_web_vulns.py::test_invoice_of_another_user_is_not_found PASSED [ 58%]
test_web_vulns.py::test_redirect_stays_on_this_site[https://other.example/] PASSED [ 66%]
test_web_vulns.py::test_redirect_stays_on_this_site[//other.example/] PASSED [ 75%]
test_web_vulns.py::test_redirect_stays_on_this_site[/\\other.example/] PASSED [ 83%]
test_web_vulns.py::test_redirect_to_a_local_path_still_works PASSED [ 91%]
test_web_vulns.py::test_preview_refuses_internal_host PASSED [100%]
============================= 12 passed in 19.73s ==============================اثنتا عشرة نتيجة خضراء على الملف نفسه الذي أعطى إحدى عشرة نتيجة حمراء قبل الإصلاح. ومكان هذا الملف بعد ذلك خط CI، بجانب الفحص الثابت والفحص الديناميكي كما في DevSecOps للمبتدئين، فالأدوات تجد الأنماط المعروفة في الكود الجديد، والاختبارات تحرس الثغرات التي أصلحتها فعلاً حتى لا تعود.
إزالة التجربة Cleanup
نحذف ما أنشأناه فقط، بالأسماء نفسها، وال Docker Image الخاصة بالتجربة وDocker Image الخاصة ب Semgrep التي سحبناها لها:
docker rm -f lab-webvuln-vuln lab-webvuln-fixed lab-webvuln-other lab-webvuln-internal
docker network rm lab-webvuln-net
docker image rm lab-webvuln-app:1 semgrep/semgrep:1.178.0
rm -f alice-v.txt alice-f.txtأخطاء شائعة Common Mistakes
- تنظيف المدخلات بدل فصلها: حذف علامة الاقتباس أو وسوم HTML عند الحفظ يكسر بيانات حقيقية مثل O'Reilly، ويفوته شكل لم تتوقعه، والحل استعلام بمعاملات للقاعدة وتهريب عند الإخراج للصفحة.
- إصلاح طبقة والظن أن الأخرى محمية: صفحة التقييمات فيها استعلام آمن تماماً وXSS مخزن في الوقت نفسه.
- بناء HTML بالنصوص في الكود: كل f-string يكتب HTML هو مكان لنسيان التهريب، والقالب يجعل التهريب الوضع الافتراضي.
|safeأوMarkup()على بيانات المستخدم: هذا إيقاف صريح للحماية، ويجب أن يمر بمراجعة مثل أي ثغرة.- الاعتماد على CSP وحدها: هي طبقة ثانية تقلل الأثر، والثغرة تبقى في الكود.
- الاعتماد على المتصفح في CSRF: رأينا أن Chrome أرسل الجلسة في أول دقيقتين، وأن ملف الجلسة في Flask لا يحمل SameSite إلا إذا طلبت، فضع التوكن وSameSite بنفسك.
- طلب GET يغير البيانات: SameSite=Lax ترسل ملف الجلسة مع الروابط من مواقع أخرى، فأي GET يحذف أو يغير يصبح هدفاً سهلاً.
- فحص الدخول بدل فحص الملكية: كل مسار فيه رقم كائن يحتاج شرط المالك، و404 للرقم الذي لا تملكه، وتفصيل ذلك في الصلاحيات المركزية.
- فحص
startswith("/")وحده لإعادة التوجيه: الرابط//other.example/يمر منه. - قائمة منع Blocklist لعناوين SSRF، أو جالب يتبع إعادة التوجيه: قائمة السماح ومنع إعادة التوجيه معاً.
- اختبار أمان لا يفشل على الكود الضعيف: شغل الاختبار الجديد على الكود قبل الإصلاح أولاً، فإذا نجح فهو لا يختبر الثغرة.
- تجاهل نتيجة أداة دون سبب ودون اختبار: التجاهل يكتب باسم القاعدة وتاريخ وسبب، ويستند إلى اختبار يفشل إذا تغير الافتراض.
قائمة تحقق Checklist لكل نموذج ومسار جديد
- كل استعلام بمعاملات، وأسماء الأعمدة والجداول من قائمة سماح في الكود إذا اختارها المستخدم.
- كل HTML من قوالب بالتهريب التلقائي، وكل قيمة داخل خاصية محاطة بعلامتي اقتباس، ولا يوجد
|safeعلى بيانات المستخدم. - ترويسة CSP على كل الصفحات، دون
'unsafe-inline'للسكربتات. - كل طلب يغير البيانات POST أو PUT أو DELETE، ويحمل توكن CSRF يفحص مركزياً.
- ملف الجلسة HttpOnly وSecure وSameSite=Lax على الأقل.
- كل مسار فيه معرف كائن يفحص الملكية في الاستعلام نفسه، ويرد 404 لما لا يملكه المستخدم.
- إعادة التوجيه إلى مسار داخلي فقط، أو قائمة سماح بالمضيفين.
- كل جلب من السيرفر لرابط من المستخدم: https، وقائمة سماح، ودون إعادة توجيه، ومهلة وحد للحجم.
- لكل ثغرة أصلحتها اختبار يفشل على الكود القديم وينجح على الجديد، ويعمل في خط CI.
- Semgrep على كل طلب دمج وZAP على بيئة المعاينة، وكل نتيجة تراجع، وكل تجاهل مكتوب السبب.
الخلاصة
وصلنا لنهاية الموضوع، وأهم ما فيه:
- حقن SQL وXSS نفس الخطأ عند مفسرين مختلفين: نص من المستخدم يصل إلى المفسر مخلوطاً بالكود، والإصلاح فصل البيانات عن الكود، بالمعاملات للقاعدة وبالتهريب عند الإخراج للصفحة.
- علامة اقتباس في اسم كتاب ووسم <b> يكفيان لإثبات الثغرة، ولا تحتاج أكثر من ذلك لتفهمها أو تختبرها.
- CSRF تستغل أن المتصفح يرسل ملف الجلسة مع الطلب أياً كان مصدره، والحماية توكن في كل نموذج وSameSite على الجلسة، وليست سلوك متصفح بعينه.
- IDOR مشكلة منطق لا تراها الأدوات، والإصلاح شرط الملكية في الاستعلام، والضمان اختبار «مستخدم يطلب كائن غيره».
- إعادة التوجيه المفتوحة وSSRF ثقة برابط من المستخدم، والإصلاح قائمة بما هو مسموح.
- Semgrep وجد الأنماط وفاته المنطق، وZAP وجد ما يستطيع أن يثيره من الخارج، والاختبارات وحدها غطت السبع، لذلك كل إصلاح ينتهي باختبار في خط CI.
وفي المقالات القادمة بإذن الله سوف نتحدث عن البرمجة الآمنة Secure Coding بأمثلة في كل لغة، ومراجعة التطبيق بمعيار ASVS.
دليلOWASP Top 10 للمبتدئين: أخطر عشر مشكلات في تطبيقات الويبأخطر عشر مشكلات أمنية في تطبيقات الويب، كل واحدة بمثال ضعيف ثم مصلح، وكيف تمنعها في الكود وأين تكتشفها.
دليلأمن ال API بالأمثلة مع OWASP API Security Top 10أشهر عشرة مخاطر في واجهات API كما ترتبها OWASP، ولكل خطر مثال لكود ضعيف وكيف تصلحه وتختبره.
دليلHSTS وCSP: ترويسات الأمان التي يحتاجها كل تطبيقما الذي تطلبه كل ترويسة أمان من المتصفح، ولماذا يصعب التراجع عن HSTS، وكيف تبني سياسة CSP لا تكسر تطبيقك.
دليلكيف يتذكرك الموقع بعد تسجيل الدخول؟ الجلسات وJWTبروتوكول HTTP لا يتذكر أحداً، فكيف يبقى المستخدم مسجلاً بين الصفحات، ومتى تختار الجلسة على السيرفر ومتى تختار JWT.
دليلSAST: كيف يجد تحليل الكود الثابت الثغرات قبل التشغيلكيف تقرأ أداة SAST الكود بحثاً عن الثغرات، وما الذي لا تراه أبداً، وكيف تمنع الإنذارات الخاطئة من أن تفقد الفريق ثقته بها.
دليلDAST: فحص تطبيقك من الخارج وهو يعملكيف يفحص ZAP تطبيقك من الخارج وهو يعمل، ومتى يحق لك أن تشغله، وكيف تحول نتائجه إلى بوابة في خط CI.سجل التحديثات
- أكتوبر 2026: كتابة المقال، وتنفيذ تجربة على نسخة من تطبيق سلسلة DevSecOps فيها سبع ثغرات ونسخة مصلحة، مع اثني عشر اختباراً ب pytest 9.1.1 فشل منها أحد عشر قبل الإصلاح ونجحت كلها بعده، وتجربة CSRF في Chrome 154 مع انتظار دقيقتين وبدونه، وفحص النسختين ب Semgrep 1.178.0، والتحقق من حوادث TalkTalk وMySpace من مصادرها.