> ## Content Index
> Fetch the complete content index at: https://arabroot.io/llms.txt
> Use this file to discover other available public pages before exploring further.

# ثغرات الويب بالتجربة: تطبيق نكسره ثم نصلحه
- URL: https://arabroot.io/articles/ثغرات-الويب-بالتجربة-sql-injection-و-xss-و-ssrf/
- Published: 2026-10-06T02:45:00.000Z
- Updated: 2026-10-09T09:43:24.000Z
- Description: نرى أثر أشهر ثغرات الويب على متجر صغير على جهازك، ثم السطر المسؤول عن كل ثغرة، ثم الإصلاح والاختبار الذي يثبته.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, الأمن السيبراني, DevOps وCI/CD, الاستضافة الذاتية

في [OWASP Top 10 للمبتدئين](https://arabroot.io/articles/owasp-top-10-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/) مررنا على أخطر عشر مشكلات في تطبيقات الويب فئة فئة، ورأينا لكل فئة مثالاً قصيراً ضعيفاً وآخر مصلحاً، لكن قراءة السطر الضعيف شيء، ورؤيته يكسر تطبيقاً يعمل أمامك شيء آخر تماماً. لذلك سوف نأخذ في هذا المقال تطبيق متجر صغيراً مكتوباً ب Python وFlask، وهو نفس التطبيق التجريبي الذي فحصناه في [تحليل الكود الثابت SAST](https://arabroot.io/articles/%D8%AA%D8%AD%D9%84%D9%8A%D9%84-%D8%A7%D9%84%D9%83%D9%88%D8%AF-%D8%A7%D9%84%D8%AB%D8%A7%D8%A8%D8%AA-sast-%D9%85%D8%B9-semgrep-%D9%88-sonarqube/) و[فحص التطبيق أثناء التشغيل DAST](https://arabroot.io/articles/%D9%81%D8%AD%D8%B5-%D8%A7%D9%84%D8%AA%D8%B7%D8%A8%D9%8A%D9%82-%D8%A7%D8%AB%D9%86%D8%A7%D8%A1-%D8%A7%D9%84%D8%AA%D8%B4%D8%BA%D9%8A%D9%84-dast-%D9%85%D8%B9-zap/)، ونشغله على جهازنا، ثم نكسر فيه سبع ثغرات 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: تطبيقنا، وجهازنا، ومدخلات لا تؤذي أحداً

قبل أي أمر، هذه القواعد التي تحكم المقال كله، وهي نفسها ما شرحناه في [على أي هدف يحق لك أن تشغل الفحص](https://arabroot.io/articles/%D9%81%D8%AD%D8%B5-%D8%A7%D9%84%D8%AA%D8%B7%D8%A8%D9%8A%D9%82-%D8%A7%D8%AB%D9%86%D8%A7%D8%A1-%D8%A7%D9%84%D8%AA%D8%B4%D8%BA%D9%8A%D9%84-dast-%D9%85%D8%B9-zap/#ethics):

- **التطبيق تطبيقنا:** كل الثغرات في كود كتبناه للتجربة، ولا يوجد في المقال أي طلب إلى موقع أو خدمة لا نملكها، والسبب أن تجربة ثغرة على نظام غيرك دون إذن مكتوب مخالفة للقانون في أغلب الدول، حتى لو كان المدخل نفسه بريئاً.
- **الشبكة جهازنا:** كل ال Containers تنشر منافذها على 127.0.0.1 فقط، والخدمة الداخلية لا تنشر أي منفذ أصلاً.
- **المدخلات أبسط ما يثبت الفكرة:** علامة اقتباس، ووسم <b>، ونموذج يغير بريد مستخدم تجريبي، ورابط لموقع وهمي ينتهي ب example.
- **كل ثغرة تنتهي باختبار:** لا يكفي أن ترى الإصلاح يعمل مرة، وإنما يجب أن يفشل البناء إذا عادت الثغرة بعد سنة على يد مطور لا يعرف القصة.

## تجهيز التجربة Lab: نسخة من تطبيق السلسلة بسبع ثغرات

الصورة التالية تبين التجربة كاملة: المتجر يعمل بنسختين، الضعيفة على المنفذ 21080 والمصلحة على 21082، وصفحة «موقع آخر» على 21081 نستخدمها في تجربة CSRF، وخدمة داخلية لا يصل إليها إلا ال Containers على الشبكة نفسها، وتحتها الثغرات السبع والمكان الذي يقع فيه الضرر لكل منها:

![في الأعلى متصفح alice المسجل في المتجر، وصفحة موقع آخر على localhost:21081 ترسل طلب POST إلى المتجر، وداخل شبكة Docker اسمها lab-webvuln-net المتجر بنسختيه على 127.0.0.1:21080 و21082 مع ملف SQLite، وخدمة داخلية دون منفذ على الجهاز يصل إليها المتجر بجلب من السيرفر، وفي الأسفل سبع بطاقات: SQL Injection في /search وضررها قاعدة البيانات، وReflected XSS في /search وضررها متصفح واحد، وStored XSS في /product/1 وضررها كل زائر، وCSRF في /settings/email وضررها حساب المستخدم، وIDOR في /invoice/1002 وضررها بيانات المستخدمين الآخرين، وOpen Redirect في /go وضررها ثقة المستخدم، وSSRF في /preview وضررها شبكة السيرفر](https://arabroot.io/content/images/2026/10/web-vulns-lab-01-map.webp)

سبع ثغرات في متجر صغير، وكل منها يقع ضررها في مكان مختلف: قاعدة البيانات، أو متصفح الزائر، أو حساب المستخدم، أو شبكة السيرفر

### ملفات التجربة 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) موضوع مقالات أخرى في السلسلة، ولا نريد أن تختلط نتائجها هنا.

```text
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 وتشغل الخدمات الأربع من داخل مجلد التجربة، ولاحظ أن مفتاح الجلسة للنسخة المصلحة يأتي من متغير بيئة يولد عند التشغيل، وليس من الكود:

```bash
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}}' 
```

والمخرج سوف يكون كما يلي:

```text
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 الذي شرحناه في [أنواع الاختبارات](https://arabroot.io/articles/%D8%A7%D9%86%D9%88%D8%A7%D8%B9-%D8%A7%D9%84%D8%A7%D8%AE%D8%AA%D8%A8%D8%A7%D8%B1%D8%A7%D8%AA-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86-unit-%D9%88-integration-%D9%88-e2e/). وهذه بدايته، وسوف نعرض كل اختبار في قسم ثغرته:

```python
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()
```

نشغله الآن على النسخة الضعيفة:

```bash
docker run --rm -e APP_MODULE=app lab-webvuln-app:1 python -m pytest -q --tb=no -rf -p no:cacheprovider
```

والمخرج سوف يكون كما يلي:

```text
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»، ثم ببحث عن اسم الكتاب الذي فيه علامة اقتباس، كما يفعل أي مستخدم:

```bash
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
```

والمخرج سوف يكون كما يلي:

```text
<!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 نفسه. وقد يتساءل البعض: هل المشكلة مجرد خطأ مزعج في البحث؟ والإجابة لا، فإذا كان المدخل يستطيع أن يكسر الاستعلام فهو يستطيع أن يغير معناه أيضاً، والطلب التالي يثبت ذلك دون أن يلمس أي بيانات حساسة، فهو يضيف شرطاً على السعر من خانة البحث:

```bash
curl -s -G -w "\nHTTP %{http_code}\n" "http://127.0.0.1:21080/search" \
  --data-urlencode "q=%' AND price > 500 AND name LIKE '%"
```

والمخرج سوف يكون كما يلي:

```text
<!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()` في الملف نفسه تستخدم الطريقة الصحيحة قبل عشرة أسطر فقط:

```python
# 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 الحقن](https://arabroot.io/articles/owasp-top-10-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#a05) بالصورة لماذا لا تعرف قاعدة البيانات أين انتهى كودك وأين بدأت بيانات المستخدم عندما يصلان في نص واحد، لذلك لن نعيده هنا، ونكتفي بالإصلاح.

### الإصلاح Fix: استعلام بمعاملات Parameterized Query

الإصلاح سطر واحد: نص الاستعلام ثابت وفيه علامة `?`، والقيمة ترسل منفصلة في Tuple، وهذا ما توثقه مكتبة [sqlite3](https://docs.python.org/3/library/sqlite3.html?ref=arabroot.io) نفسها تحت اسم العناصر النائبة Placeholders، ويوصي به [OWASP Query Parameterization Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Query%5FParameterization%5FCheat%5FSheet.html?ref=arabroot.io):

```diff
-    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()
```

ونرسل الطلبين نفسيهما إلى النسخة المصلحة، ونطبع العنوان والنتائج فقط:

```bash
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>"
```

والمخرج سوف يكون كما يلي:

```text
<h1>Results for O&#39;Reilly</h1>
<li>O&#39;Reilly Flask Book: 150.0</li>
<h1>Results for %&#39; AND price &gt; 500 AND name LIKE &#39;%</h1>
<ul></ul>
```

في المخرج أعلاه لاحظ التالي:

- الكتاب ظهر في النتائج، فعلامة الاقتباس أصبحت حرفاً عادياً في قيمة البحث، والمستخدم الحقيقي حصل على ما يريد.
- الطلب الثاني أعاد قائمة فارغة، لأن قاعدة البيانات بحثت عن منتج اسمه يحتوي هذا النص الطويل كما هو، ولم تنفذ منه أي شرط.
- ظهرت علامة الاقتباس في الصفحة بالشكل `&#39;`، وهذا إصلاح آخر سوف نصل إليه في XSS، لأن النسخة المصلحة تكتب كل HTML من قوالب Jinja2.

### الاختبار Test الذي يثبت الإصلاح

```python
# 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](https://arabroot.io/articles/%D8%AA%D8%AD%D9%84%D9%8A%D9%84-%D8%A7%D9%84%D9%83%D9%88%D8%AF-%D8%A7%D9%84%D8%AB%D8%A7%D8%A8%D8%AA-sast-%D9%85%D8%B9-semgrep-%D9%88-sonarqube/#taint)، ووجدها ZAP من الخارج في [الفحص الكامل](https://arabroot.io/articles/%D9%81%D8%AD%D8%B5-%D8%A7%D9%84%D8%AA%D8%B7%D8%A8%D9%8A%D9%82-%D8%A7%D8%AB%D9%86%D8%A7%D8%A1-%D8%A7%D9%84%D8%AA%D8%B4%D8%BA%D9%8A%D9%84-dast-%D9%85%D8%B9-zap/#lab-full) بالقاعدة [40018 SQL Injection](https://www.zaproxy.org/docs/alerts/40018/?ref=arabroot.io) على المعامل q، بدرجة ثقة منخفضة لأنه استنتجها من خطأ 500 كالذي رأيناه أعلاه.

📌

في أكتوبر 2015 اخترق مهاجمون موقع شركة الاتصالات البريطانية TalkTalk عبر حقن SQL في ثلاث صفحات قديمة ورثتها الشركة مع شراء Tiscali عام 2009، فوصلوا إلى البيانات الشخصية ل 156,959 عميلاً، ومنها 15,656 رقم حساب بنكي، بحسب [تحقيق مكتب مفوض المعلومات البريطاني ICO](https://ico.org.uk/about-the-ico/media-centre/talktalk-cyber-attack-how-the-ico-investigation-unfolded/?ref=arabroot.io)، الذي فرض غرامة 400 ألف جنيه، وكانت أكبر غرامة يصدرها حتى ذلك الوقت. وذكر التحقيق أن الصفحات نفسها تعرضت لمحاولتي حقن قبل ذلك في يوليو وسبتمبر 2015 ولم يلاحظهما أحد لغياب المراقبة، وأن برنامج قاعدة البيانات كان ينقصه إصلاح متاح منذ أكثر من ثلاث سنوات ونصف. والدرس أن الصفحة القديمة التي لا يتذكرها أحد هي غالباً الصفحة التي لم يمر عليها أي اختبار.

## البرمجة عبر المواقع XSS: عندما يصبح النص HTML

XSS هي حقن أيضاً، لكن المفسر Interpreter هنا هو المتصفح وليس قاعدة البيانات، فالكود يكتب نصاً من المستخدم داخل صفحة HTML دون تهريب Escaping، فيقرؤه المتصفح كوسوم Tags وليس كحروف. وقد شرحنا في [ما الذي تمنعه CSP](https://arabroot.io/articles/%D8%AA%D8%B1%D9%88%D9%8A%D8%B3%D8%A7%D8%AA-%D8%A7%D9%84%D8%A3%D9%85%D8%A7%D9%86-hsts-%D9%88-csp/#csp-what) ما يستطيع السكربت المحقون أن يفعله بصلاحيات المستخدم، لذلك نركز هنا على الطريقين اللذين يصل منهما النص إلى الصفحة، وهما في الصورة التالية:

![ثلاثة صفوف: في الأول Reflected XSS حيث يرسل المتصفح GET /search?q=<b>hi</b> فيعيد المتجر <h1>Results for <b>hi</b></h1> لأن الكود يكتب q في f-string، فيفسر المتصفح النص كوسوم، ويصيب الرابط من يفتحه فقط، وفي الثاني Stored XSS حيث تكتب alice تقييماً فيه <b>great</b> فيحفظ كما هو في قاعدة البيانات ثم يظهر في صفحة كل زائر، مع أن استعلام SQL آمن، وفي الثالث الإصلاح المشترك: قالب Jinja2 يحول النص إلى <b>hi</b> فيعرض المتصفح الحروف، وترويسة Content-Security-Policy كطبقة ثانية تمنع السكربتات داخل الصفحة إذا نسي أحد التهريب يوماً](https://arabroot.io/content/images/2026/10/web-vulns-lab-02-xss.webp)

في الانعكاس يعود النص في نفس الرد، وفي التخزين يحفظ مرة ويعرض لكل من يفتح الصفحة، والإصلاح واحد في الحالتين: التهريب عند الإخراج

### XSS المنعكس Reflected XSS في صفحة البحث

نبحث عن نص فيه وسم <b>، ونطلب الصفحة نفسها من النسختين:

```bash
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"
```

والمخرج سوف يكون كما يلي:

```text
<!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 &lt;b&gt;hi&lt;/b&gt;</h1>
```

في النسخة الضعيفة عاد الوسم كما هو داخل العنوان، فيعرض المتصفح كلمة hi بخط عريض، وهذا يكفي ليثبت أن المتصفح فسر ما كتبه المستخدم ك HTML، وأي وسم آخر سوف يفسر بالطريقة نفسها، ومنه وسم السكربت. والسطر المسؤول هو 107، حيث تكتب القيمة `q` داخل f-string ثم تعاد كما هي:

```python
return PAGE.format(f"<h1>Results for {q}</h1><ul>{items}</ul>")
```

وفي النسخة المصلحة تحولت علامات < و> إلى ما يسمى الكيانات Entities، فيعرض المتصفح الحروف نفسها كنص، ولا يرى أي وسم.

### XSS المخزن Stored XSS في التقييمات

في صفحة المنتج تستطيع أن تكتب تقييماً Review، وهنا يختلف المسار: alice تكتب التقييم مرة، ويحفظ في قاعدة البيانات، ثم يظهر لكل من يفتح الصفحة لاحقاً. نسجل الدخول ك alice، ونكتب تقييماً فيه وسم <b>، ثم نفتح الصفحة كزائر دون تسجيل:

```bash
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>"
```

والمخرج سوف يكون كما يلي:

```text
review: HTTP 302
<ul><li><b>alice</b>: <b>great</b> keyboard</li></ul>
```

الوسم الأول حول اسم alice من تصميم الصفحة، والثاني حول great كتبته alice، والمتصفح لا يفرق بينهما. والسطر المسؤول هو 127:

```python
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 يحمي الصفحة من النص، ولكل مفسر حمايته. وهذا النوع أخطر من المنعكس، لأن المنعكس يحتاج أن يفتح الضحية رابطاً معيناً، أما المخزن فيعمل على كل من يزور الصفحة، دون رابط ودون أن يفعل شيئاً غير القراءة.

📌

في 4 أكتوبر 2005 نشر المطور Samy Kamkar في صفحته على MySpace كوداً استغل ثغرة XSS في فلتر HTML الخاص بالموقع، فكان كل من يفتح صفحته يضيفه صديقاً تلقائياً، وينسخ الكود نفسه إلى صفحة الزائر، فينتشر من صفحة إلى صفحة. وبحسب [روايته المفصلة](https://sa.my/myspace/?ref=arabroot.io) تجاوز عدد المصابين مليون مستخدم في حوالي عشرين ساعة، فأوقفت MySpace الموقع للصيانة وحذفت حسابه. والدرس أن XSS المخزن في صفحة يزورها الناس يتحول إلى شيء ينتشر وحده، وأن الفلتر الذي يحاول تنظيف HTML الخطر يخسر أمام التهريب الذي يحول كل HTML إلى نص.

### الإصلاح Fix: قوالب Jinja2 تهرب كل شيء، وCSP كطبقة ثانية

الحل الأول الذي يخطر على البال هو تنظيف المدخلات عند الحفظ بحذف علامات < و>، وهذا الحل غير مناسب، والسبب أن البيانات نفسها قد تحتاج هذه الحروف (معادلة رياضية، أو مقطع كود في تقييم)، وأن التنظيف يعتمد على أن تعرف كل الطرق الخطرة مسبقاً، وأن البيانات قد تصل إلى قاعدة البيانات من طريق آخر لا يمر بالتنظيف. والحل الأنسب هو التهريب عند الإخراج Output Encoding، أي في المكان الذي يصبح فيه النص HTML، كما يوصي [OWASP XSS Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross%5FSite%5FScripting%5FPrevention%5FCheat%5FSheet.html?ref=arabroot.io). وفي Flask لا تحتاج أن تفعل ذلك بيدك، فتوثيق [Templates](https://flask.palletsprojects.com/en/stable/templating/?ref=arabroot.io) يقول إن التهريب التلقائي Autoescaping يعمل في كل القوالب التي تنتهي ب .html عند استخدام `render_template()`، لذلك نقلنا كل HTML في النسخة المصلحة إلى ملفات قوالب، وأصبحت الدالة كما يلي:

```python
@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` كما يلي:

```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](https://flask.palletsprojects.com/en/stable/web-security/?ref=arabroot.io) في Flask ينبه إلى أن التهريب لا يحميك في خاصية دون اقتباس.
- الطرق التي تتجاوز التهريب هي ما يجب أن تبحث عنه في مراجعة الكود: الفلتر `|safe`، و`Markup()` على بيانات من المستخدم، وبناء HTML في Python كما في النسخة الضعيفة.

ثم أضفنا طبقة ثانية في كل استجابة، وهي ترويسة CSP مع X-Content-Type-Options، والتفاصيل الكاملة لكل توجيه Directive في [دليل ترويسات الأمان](https://arabroot.io/articles/%D8%AA%D8%B1%D9%88%D9%8A%D8%B3%D8%A7%D8%AA-%D8%A7%D9%84%D8%A3%D9%85%D8%A7%D9%86-hsts-%D9%88-csp/#csp):

```python
@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 الذي سوف نشرحه في القسم التالي، لذلك نقرؤه من النموذج أولاً:

```bash
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)"
```

والمخرج سوف يكون كما يلي:

```text
login: HTTP 302
review: HTTP 302
<li><b>alice</b>: &lt;b&gt;great&lt;/b&gt; 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 تقلل أثر الثغرة ولا تزيلها، فالوسوم التي لا تحتوي سكربتاً، مثل نموذج مزيف أو رابط مزيف، تبقى تعمل، وقد رأينا ذلك بأرقام حقيقية في [الترويسات لا تصلح الكود](https://arabroot.io/articles/%D9%81%D8%AD%D8%B5-%D8%A7%D9%84%D8%AA%D8%B7%D8%A8%D9%8A%D9%82-%D8%A7%D8%AB%D9%86%D8%A7%D8%A1-%D8%A7%D9%84%D8%AA%D8%B4%D8%BA%D9%8A%D9%84-dast-%D9%85%D8%B9-zap/#lab-headers-not-enough).

### الاختبارات Tests التي تثبت الإصلاح

```python
# 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 "&lt;b&gt;hi&lt;/b&gt;" 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 "&lt;b&gt;great&lt;/b&gt;" 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)](https://www.zaproxy.org/docs/alerts/40012/?ref=arabroot.io)، أما المخزن فله في ZAP قاعدة مستقلة هي [40014 Cross Site Scripting (Persistent)](https://www.zaproxy.org/docs/alerts/40014/?ref=arabroot.io)، ولم تكن في تطبيق السلسلة الأصلي صفحة تقييمات حتى نراها في تلك التجربة. وبعد الإصلاح اختفت كل هذه النتائج من Semgrep، كما سوف ترى في [جدول الأدوات](#where-caught).

## تزوير الطلب عبر المواقع CSRF: موقع آخر يغير بريدك

شرحنا في [ما الذي لا تمنعه سياسة المصدر الواحد](https://arabroot.io/articles/%D8%B4%D8%B1%D8%AD-cors-%D9%88%D8%B3%D9%8A%D8%A7%D8%B3%D8%A9-%D8%A7%D9%84%D9%85%D8%B5%D8%AF%D8%B1-%D8%A7%D9%84%D9%88%D8%A7%D8%AD%D8%AF/#what-sop-allows) أن المتصفح يمنع الموقع الآخر من قراءة ردودك، لكنه لا يمنعه من إرسال نموذج Form إليك، ويضيف المتصفح ملف تعريف الارتباط Cookie الخاص بجلستك إلى هذا الطلب حسب الخاصية SameSite. وهذا كل ما يحتاجه CSRF: طلب يغير البيانات، وتطبيق يكتفي بملف الجلسة دليلاً على أن المستخدم هو من أرسله. والصورة التالية تبين الخطوات في تجربتنا، ثم الطبقتين اللتين توقفانه:

![في الأعلى بالأحمر: متصفح alice ومعه ملف الجلسة، في الخطوة الأولى يسجل الدخول إلى 127.0.0.1:21080، وفي الثانية يفتح صفحة على localhost:21081، وفي الثالثة ترسل الصفحة نموذجاً POST /settings/email ويضيف المتصفح ملف الجلسة، وفي الرابعة يتغير البريد في المتجر، وفي الأسفل بالأخضر طبقتان مستقلتان: توكن CSRF عشوائي لكل جلسة في كل نموذج ويفحص في كل POST فإذا غاب فالرد 403، وخاصية SameSite=Lax على ملف الجلسة فلا يرسله المتصفح مع POST يبدأ من موقع آخر](https://arabroot.io/content/images/2026/10/web-vulns-lab-03-csrf.webp)

الموقع الآخر لا يرى ملف الجلسة ولا الرد، فهو يجعل المتصفح يرسل الطلب فقط، والتوكن وSameSite يكسران هذه الخطوة من جهتين مختلفتين

### السطر المسؤول Root Cause

```python
@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 على ملف الجلسة إلا إذا طلبت ذلك، فهذا هو رأس الاستجابة عند الدخول في النسختين:

```text
Set-Cookie: session=…; HttpOnly; Path=/
Set-Cookie: session=…; HttpOnly; Path=/; SameSite=Lax
```

السطر الأول من النسخة الضعيفة، والثاني من المصلحة، وحذفنا قيمة الجلسة نفسها.

### التجربة في متصفح Browser حقيقي هو Chrome

هذه الثغرة لا تظهر بأمر curl، لأن الذي يضيف ملف الجلسة هو المتصفح، لذلك كتبنا سكربتاً صغيراً `csrf-browser.mjs` يقود متصفح Chrome دون واجهة Headless: يسجل دخول alice في المتجر، ثم يفتح صفحة الموقع الآخر، ثم يعود ليقرأ البريد. والصفحة على المنفذ 21081 كما يلي، ولاحظ أنها لا تفعل أكثر من إرسال نموذج فيه بريد تجريبي:

```html
<!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="changed-by-other-site@example.test">
</form>
<script>document.getElementById("f").submit();</script>
</body></html>
```

وقد يتساءل البعض: لماذا تفتح الصفحة من localhost والمتجر من 127.0.0.1، وهما الجهاز نفسه؟ والإجابة أن SameSite تتكلم عن «الموقع» Site وليس عن «المصدر» Origin، وبحسب [تعريف MDN](https://developer.mozilla.org/en-US/docs/Glossary/Site?ref=arabroot.io) فالمنفذ Port لا يدخل في تحديد الموقع، أي أن 127.0.0.1:21081 و127.0.0.1:21080 موقع واحد في نظر المتصفح، فلن تختبر شيئاً، أما localhost و127.0.0.1 فهما اسمان مختلفان، فيعاملهما المتصفح كموقعين مختلفين Cross-Site كما في الحياة الحقيقية. نشغل السكربت على النسخة الضعيفة من جذر المستودع:

```bash
node content/articles/web-vulns-lab/lab/csrf-browser.mjs 21080 csrf-vuln.html
docker logs --tail 3 lab-webvuln-vuln
```

والمخرج سوف يكون كما يلي:

```text
signed in: Signed in as alice

Search
before: Email: alice@shop.test

Change email
cookie: session sameSite=(not set)
after the other site: http://127.0.0.1:21080/settings | Email: changed-by-other-site@example.test Change email
settings now: Email: changed-by-other-site@example.test

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](https://www.chromium.org/updates/same-site/faq/?ref=arabroot.io) يعامل Chrome ملف تعريف الارتباط الذي لا يحدد SameSite كأنه Lax، لكن مع استثناء: ملف عمره دقيقتان أو أقل يرسل مع طلب POST من موقع آخر ينقل الصفحة كلها، وهذا الاستثناء وضع حتى لا تنكسر بعض أنظمة الدخول الموحد. أي أن تجربتنا نجحت لأن الصفحة فتحت بعد الدخول بثوان. ولنتأكد، أعدنا التجربة نفسها مع انتظار 130 ثانية بين الدخول وفتح الموقع الآخر:

```bash
node content/articles/web-vulns-lab/lab/csrf-browser.mjs 21080 csrf-vuln.html 130
```

والمخرج سوف يكون كما يلي، مع سطر الطلب من سجل المتجر:

```text
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: alice@shop.test

Change email
192.168.48.1 - - [07/Oct/2026 06:06:56] "POST /settings/email HTTP/1.1" 401 -
```

هذه المرة وصل الطلب إلى المتجر دون ملف الجلسة، فرد ب 401 ولم يتغير شيء. وهذه الحماية مفيدة، لكنها ليست حمايتك أنت، فهي سلوك متصفح واحد، فيه نافذة زمنية بعد كل دخول، ويطبق على ملفات لم تحدد لها SameSite فقط، وكل ذلك قد يتغير في إصدار قادم أو لا يوجد أصلاً في متصفح آخر، كما ذكرنا في [خصائص ملفات تعريف الارتباط](https://arabroot.io/articles/%D8%A7%D9%84%D8%AC%D9%84%D8%B3%D8%A7%D8%AA-%D9%88%D9%85%D9%84%D9%81%D8%A7%D8%AA-%D8%AA%D8%B9%D8%B1%D9%8A%D9%81-%D8%A7%D9%84%D8%A7%D8%B1%D8%AA%D8%A8%D8%A7%D8%B7-%D9%88-jwt-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#cookie-attributes). لذلك لا تقم بترك الحماية من CSRF للمتصفح، وضع الطبقتين في تطبيقك.

### الإصلاح Fix: توكن CSRF في كل نموذج، وSameSite=Lax

الطبقة الأولى هي توكن التزامن Synchronizer Token الذي يصفه [OWASP CSRF Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site%5FRequest%5FForgery%5FPrevention%5FCheat%5FSheet.html?ref=arabroot.io): قيمة عشوائية لكل جلسة، توضع في حقل مخفي في كل نموذج، ويفحصها السيرفر في كل طلب POST. والموقع الآخر لا يستطيع أن يعرفها، لأنه لا يستطيع قراءة صفحاتنا بسبب سياسة المصدر الواحد. وفي النسخة المصلحة كتبناها بأسطر قليلة حتى ترى الفكرة كاملة:

```python
# 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](https://arabroot.io/articles/%D8%A7%D9%84%D8%AA%D8%B4%D9%81%D9%8A%D8%B1-%D9%84%D9%84%D9%85%D8%B7%D9%88%D8%B1%D9%8A%D9%86-%D8%A7%D9%84%D9%87%D8%A7%D8%B4-%D9%88%D9%83%D9%84%D9%85%D8%A7%D8%AA-%D8%A7%D9%84%D9%85%D8%B1%D9%88%D8%B1-%D9%88%D8%A7%D9%84%D9%85%D9%81%D8%A7%D8%AA%D9%8A%D8%AD/#randomness).
- المقارنة ب `hmac.compare_digest` التي تأخذ الزمن نفسه أياً كان موضع الاختلاف، كما شرحنا في [المقارنة في زمن ثابت](https://arabroot.io/articles/%D8%A7%D9%84%D8%AA%D8%B4%D9%81%D9%8A%D8%B1-%D9%84%D9%84%D9%85%D8%B7%D9%88%D8%B1%D9%8A%D9%86-%D8%A7%D9%84%D9%87%D8%A7%D8%B4-%D9%88%D9%83%D9%84%D9%85%D8%A7%D8%AA-%D8%A7%D9%84%D9%85%D8%B1%D9%88%D8%B1-%D9%88%D8%A7%D9%84%D9%85%D9%81%D8%A7%D8%AA%D9%8A%D8%AD/#hmac-right-way).
- الفحص في `before_request` لكل طلب POST، وليس داخل كل دالة، فالمسار الجديد الذي يضيفه أحد بعد سنة محمي دون أن يتذكر أحد ذلك. وفي تطبيق حقيقي لا تكتب هذا بنفسك، واستخدم [CSRFProtect من Flask-WTF](https://flask-wtf.readthedocs.io/en/latest/csrf/?ref=arabroot.io) الذي يفعل الشيء نفسه مع حالات أكثر، فتوثيق Flask يقول صراحة إن إطار العمل لا يحمي من CSRF وحده.
- عند الدخول نستدعي `session.clear()` قبل وضع المستخدم، فتبدأ الجلسة الجديدة بتوكن جديد.

والطبقة الثانية سطر إعداد واحد، يجعل المتصفح لا يرسل ملف الجلسة مع طلب POST يبدأ من موقع آخر، بغض النظر عن نافذة Chrome أو سلوك أي متصفح افتراضي:

```python
app.config.update(SESSION_COOKIE_SAMESITE="Lax", SESSION_COOKIE_HTTPONLY=True)
```

ونعيد تجربة المتصفح على النسخة المصلحة، مع صفحة الموقع الآخر نفسها لكن موجهة إلى المنفذ 21082:

```bash
node content/articles/web-vulns-lab/lab/csrf-browser.mjs 21082 csrf-fixed.html
docker logs --tail 3 lab-webvuln-fixed
```

والمخرج سوف يكون كما يلي:

```text
signed in: Signed in as alice

Search
before: Signed in as alice

Email: alice@shop.test

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: alice@shop.test

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 التي تثبت الإصلاح

```python
# 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": "changed@other.test"})
    assert r.status_code == 403
    assert "alice@shop.test" 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 التي رأيناها في [تجربة الفحص الثابت](https://arabroot.io/articles/%D8%AA%D8%AD%D9%84%D9%8A%D9%84-%D8%A7%D9%84%D9%83%D9%88%D8%AF-%D8%A7%D9%84%D8%AB%D8%A7%D8%A8%D8%AA-sast-%D9%85%D8%B9-semgrep-%D9%88-sonarqube/#lab-map). أما ZAP فيجد غياب التوكن بالفحص السلبي، بالقاعدة [10202 Absence of Anti-CSRF Tokens](https://www.zaproxy.org/docs/alerts/10202/?ref=arabroot.io) التي ظهرت في [الفحص الأساسي](https://arabroot.io/articles/%D9%81%D8%AD%D8%B5-%D8%A7%D9%84%D8%AA%D8%B7%D8%A8%D9%8A%D9%82-%D8%A7%D8%AB%D9%86%D8%A7%D8%A1-%D8%A7%D9%84%D8%AA%D8%B4%D8%BA%D9%8A%D9%84-dast-%D9%85%D8%B9-zap/#lab-baseline) على نموذج الدخول، وهو مؤشر مفيد، لكنه يخبرك أن التوكن غائب، ولا يخبرك أن الطلب نجح من موقع آخر، فهذا ما يثبته الاختبار.

## IDOR: الرقم في الرابط ليس إذناً Permission

الإشارة المباشرة غير الآمنة إلى الكائن Insecure Direct Object Reference واختصاراً IDOR هي أشهر صور التحكم في الوصول المكسور Broken Access Control، وقد بنينا لها تجربة كاملة في [A01 في OWASP Top 10](https://arabroot.io/articles/owasp-top-10-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#a01-lab)، وشرحنا صورتها في ال APIs باسم BOLA في [أمن واجهات API](https://arabroot.io/articles/%D8%A7%D9%85%D9%86-%D9%88%D8%A7%D8%AC%D9%87%D8%A7%D8%AA-api-%D9%85%D8%B9-owasp-api-security-top-10/#api1)، لذلك نمر عليها هنا بسرعة في صفحة الفواتير، والصورة التالية تبين الفكرة:

![alice المسجلة ترسل GET /invoice/1002 بعد أن غيرت رقماً واحداً في الرابط، وفي النسخة الضعيفة الاستعلام WHERE id = ? فيعيد فاتورة bob بمبلغ 9800 مع الرمز 200، وفي النسخة المصلحة الاستعلام WHERE id = ? AND owner = ? مع القيمتين 1002 وalice فيعيد not found مع 404، وفي صندوق جانبي: فحص الدخول يجيب عن سؤال من أنت، وفحص الملكية وحده يجيب عن سؤال هل هذا لك، و404 للرقم الذي لا تملكه مثل الرقم غير الموجود](https://arabroot.io/content/images/2026/10/web-vulns-lab-04-idor.webp)

المصادقة Authentication تعمل في النسختين، والفرق كله شرط الملكية Ownership في الاستعلام

نسجل الدخول ك alice في النسختين، ونطلب فاتورتها ثم فاتورة bob، ومن النسخة المصلحة نطلب أيضاً رقماً غير موجود:

```bash
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
```

والمخرج سوف يكون كما يلي:

```text
{"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، لأن الكود يتأكد أنها مسجلة ثم يثق بالرقم القادم في الرابط. والإصلاح أن يكون المالك جزءاً من الاستعلام نفسه، فلا يمكن أن تخرج من قاعدة البيانات فاتورة لا يملكها صاحب الجلسة:

```diff
-    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](https://cheatsheetseries.owasp.org/cheatsheets/Insecure%5FDirect%5FObject%5FReference%5FPrevention%5FCheat%5FSheet.html?ref=arabroot.io) بفحص الصلاحية في كل طلب. وإذا كان تطبيقك يخدم عدة مؤسسات فهذا الشرط نفسه هو ما تفرضه قاعدة البيانات عليك بسياسات الصفوف Row-Level Security في [بناء تطبيق SaaS متعدد المستأجرين](https://arabroot.io/articles/%D8%A8%D9%86%D8%A7%D8%A1-%D8%AA%D8%B7%D8%A8%D9%8A%D9%82-saas-%D9%85%D8%AA%D8%B9%D8%AF%D8%AF-%D8%A7%D9%84%D9%85%D8%B3%D8%AA%D8%A7%D8%AC%D8%B1%D9%8A%D9%86-%D8%B9%D9%85%D9%84%D9%8A%D8%A7/#rls)، حتى لا يعتمد العزل على أن يتذكر كل مطور الشرط في كل استعلام، وإذا تعقدت القواعد (مدير يرى فواتير فريقه، ومحاسب يرى كل الفواتير) فمكانها [الصلاحيات المركزية Authorization](https://arabroot.io/articles/%D8%A7%D9%84%D8%B5%D9%84%D8%A7%D8%AD%D9%8A%D8%A7%D8%AA-%D8%A7%D9%84%D9%85%D8%B1%D9%83%D8%B2%D9%8A%D8%A9-authorization-rbac-%D9%88-rebac/). والاختبار كما يلي:

```python
# 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 كما ذكرنا في [الفحص النشط](https://arabroot.io/articles/%D9%81%D8%AD%D8%B5-%D8%A7%D9%84%D8%AA%D8%B7%D8%A8%D9%8A%D9%82-%D8%A7%D8%AB%D9%86%D8%A7%D8%A1-%D8%A7%D9%84%D8%AA%D8%B4%D8%BA%D9%8A%D9%84-dast-%D9%85%D8%B9-zap/#active) إنه لا يجد الثغرات المنطقية مثل التحكم في الوصول المكسور، لأنه لا يعرف أن الفاتورة 1002 ليست ل alice. لذلك اجعل اختبار «مستخدم يطلب كائن غيره» جزءاً ثابتاً من كل مسار فيه رقم.

## إعادة التوجيه المفتوحة Open Redirect: رابطنا، وموقع غيرنا

المسار `/go?next=` موجود في تطبيقات كثيرة بأسماء مختلفة، مثل العودة إلى الصفحة السابقة بعد الدخول، والمشكلة أنه يوجه إلى أي عنوان يصله. والصورة التالية تبين هذه الثغرة مع SSRF، لأن الاثنتين تبدآن من نفس الخطأ: الثقة برابط يرسله المستخدم:

![لوحتان: في الأولى Open Redirect حيث يضغط المستخدم رابطاً على نطاقنا فيرد المتجر ب 302 إلى موقع آخر لأن الكود يمرر request.args.get next إلى redirect، والإصلاح safe_next الذي يقبل مساراً يبدأ ب / واحدة دون مخطط ودون مضيف فيحول https://other.example/ و//other.example/ إلى /، وفي الثانية SSRF حيث يرسل الجهاز url=http://internal:8080/ إلى المتجر فيجلبه المتجر من خدمة داخلية لا منفذ لها على الجهاز لأن الكود يستدعي urlopen على الرابط، والإصلاح https فقط ومضيف من قائمة سماح ودون تتبع إعادة التوجيه فيرفض الطلب ب 400 قبل أن يخرج أي طلب](https://arabroot.io/content/images/2026/10/web-vulns-lab-05-url-trust.webp)

في إعادة التوجيه يذهب المتصفح إلى حيث يريد الرابط، وفي SSRF يذهب السيرفر نفسه، والإصلاح في الحالتين قائمة بما هو مسموح

نطلب المسار بثلاث قيم في النسختين، مسار داخلي، ورابط كامل لموقع آخر، ورابط يبدأ بشرطتين، ونطبع رأس Location فقط:

```bash
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
```

والمخرج سوف يكون كما يلي:

```text
== 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، والمستخدم الذي تعلم أن يتأكد من النطاق قبل الضغط سوف يرى نطاقك. والإصلاح في النسخة المصلحة يقبل مساراً داخلياً فقط، ويرجع إلى الصفحة الرئيسية في أي حالة أخرى:

```python
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](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated%5FRedirects%5Fand%5FForwards%5FCheat%5FSheet.html?ref=arabroot.io). والاختبار يجرب الأشكال الثلاثة، ويتأكد أن الميزة نفسها ما زالت تعمل:

```python
# 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()`، فراجعنا النتيجة وتجاهلناها في هذا السطر وحده، باسم القاعدة ومع السبب، بالطريقة التي شرحناها في [الإنذار الخاطئ وتجاهله](https://arabroot.io/articles/%D8%AA%D8%AD%D9%84%D9%8A%D9%84-%D8%A7%D9%84%D9%83%D9%88%D8%AF-%D8%A7%D9%84%D8%AB%D8%A7%D8%A8%D8%AA-sast-%D9%85%D8%B9-semgrep-%D9%88-sonarqube/#lab-false-positive):

```python
@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 في [الفحص الكامل](https://arabroot.io/articles/%D9%81%D8%AD%D8%B5-%D8%A7%D9%84%D8%AA%D8%B7%D8%A8%D9%8A%D9%82-%D8%A7%D8%AB%D9%86%D8%A7%D8%A1-%D8%A7%D9%84%D8%AA%D8%B4%D8%BA%D9%8A%D9%84-dast-%D9%85%D8%B9-zap/#lab-full)، والتفسير الأقرب أن الصفحة الرئيسية لا تحتوي رابطاً إليه، وما لا يصل إليه الزاحف لا يفحص.

## SSRF باختصار: السيرفر يجلب ما لا تصل إليه أنت

تزوير الطلب من جهة السيرفر Server-Side Request Forgery واختصاراً SSRF شرحناه بالتفصيل في [API7 في أمن واجهات API](https://arabroot.io/articles/%D8%A7%D9%85%D9%86-%D9%88%D8%A7%D8%AC%D9%87%D8%A7%D8%AA-api-%D9%85%D8%B9-owasp-api-security-top-10/#api7)، ومعه تجربة كاملة فيها خدمة بيانات وصفية Metadata وهمية وإعادة توجيه تتجاوز قائمة السماح في [النسخة الضعيفة من تجربة ال API](https://arabroot.io/articles/%D8%A7%D9%85%D9%86-%D9%88%D8%A7%D8%AC%D9%87%D8%A7%D8%AA-api-%D9%85%D8%B9-owasp-api-security-top-10/#lab-load)، لذلك نكتفي هنا بصورتها في صفحة ويب: ميزة معاينة الرابط Link Preview التي تجلب عنوان أي صفحة يرسلها المستخدم. نطلب العنوان الداخلي من جهازنا مباشرة، ثم عبر المتجر في النسختين، ثم رابطاً مسموحاً في النسخة المصلحة:

```bash
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/"
```

والمخرج سوف يكون كما يلي:

```text
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](https://cheatsheetseries.owasp.org/cheatsheets/Server%5FSide%5FRequest%5FForgery%5FPrevention%5FCheat%5FSheet.html?ref=arabroot.io):

```python
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 لأنها تحاول الاتصال فعلاً:

```python
# 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 على الملفين بالمجموعات نفسها التي استخدمناها في مقال الفحص الثابت، مرة على النسخة الضعيفة ومرة على المصلحة مع قوالبها:

```bash
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
```

والمخرج سوف يكون كما يلي، بعد قطع كل سطر عند نهاية الكود لأن الرسائل طويلة:

```text
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 في [الدقة والشمول](https://arabroot.io/articles/%D8%AA%D8%AD%D9%84%D9%8A%D9%84-%D8%A7%D9%84%D9%83%D9%88%D8%AF-%D8%A7%D9%84%D8%AB%D8%A7%D8%A8%D8%AA-sast-%D9%85%D8%B9-semgrep-%D9%88-sonarqube/#precision-recall).
- لا توجد أي نتيجة على السطر 150 (CSRF) ولا على السطر 161 (IDOR)، وهما ثغرتان حقيقيتان نجح استغلالهما أعلاه.
- النسخة المصلحة أعطت أربع نتائج كلها ليست ثغرات: الاستماع على 0.0.0.0 الذي يحتاجه التطبيق داخل ال Container ومنافذه منشورة على 127.0.0.1 فقط، وثلاث نتائج لقاعدة Django على قوالب Flask كما ذكرنا، أما نتيجة `open-redirect` فاختفت بالتجاهل المراجع.

والصورة التالية تجمع النتائج كلها، Semgrep من تجربتنا هنا، وZAP من الفحص الكامل في مقال الفحص الديناميكي على تطبيق السلسلة الأصلي، والاختبارات من الملف الذي كتبناه:

![جدول بسبعة صفوف وثلاثة أعمدة: Semgrep وZAP وpytest، SQL Injection وجدها Semgrep بالقاعدة tainted-sql-string وZAP بالقاعدة 40018 واختبار علامة الاقتباس، Reflected XSS وجدها Semgrep بالقاعدة raw-html-format وZAP بالقاعدة 40012 واختبار التهريب، Stored XSS وجدها Semgrep وليست في تطبيق مقال ZAP ويثبتها اختبار التهريب، CSRF لم يجدها Semgrep ووجد ZAP غياب التوكن بالقاعدة 10202 ويثبتها اختبار 403 وSameSite، IDOR لم يجدها Semgrep ولا ZAP ويثبتها اختبار 404 لفاتورة bob، Open Redirect وجدتها مجموعة p/default وحدها في Semgrep ولم يبلغ عنها ZAP ويثبتها اختبار الأشكال الثلاثة، SSRF وجدها Semgrep بالقاعدة ssrf-injection-urllib وليست في تطبيق مقال ZAP ويثبتها اختبار 400](https://arabroot.io/content/images/2026/10/web-vulns-lab-06-where-caught.webp)

الأدوات تجد الأنماط، والاختبار وحده يعرف من يملك ماذا، لذلك عمود الاختبارات هو الوحيد الأخضر بالكامل

والجدول نفسه بالتفصيل، مع الإصلاح لكل ثغرة:

| الثغرة            | السطر في 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                 |

وأخيراً نشغل الاختبارات على النسخة المصلحة:

```bash
docker run --rm -e APP_MODULE=app_fixed lab-webvuln-app:1 python -m pytest -v -p no:cacheprovider
```

والمخرج سوف يكون كما يلي:

```text
============================= 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 للمبتدئين](https://arabroot.io/articles/devsecops-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86-sast-%D9%88-dast-%D9%88-sca/)، فالأدوات تجد الأنماط المعروفة في الكود الجديد، والاختبارات تحرس الثغرات التي أصلحتها فعلاً حتى لا تعود.

## إزالة التجربة Cleanup

نحذف ما أنشأناه فقط، بالأسماء نفسها، وال Docker Image الخاصة بالتجربة وDocker Image الخاصة ب Semgrep التي سحبناها لها:

```bash
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 للرقم الذي لا تملكه، وتفصيل ذلك في [الصلاحيات المركزية](https://arabroot.io/articles/%D8%A7%D9%84%D8%B5%D9%84%D8%A7%D8%AD%D9%8A%D8%A7%D8%AA-%D8%A7%D9%84%D9%85%D8%B1%D9%83%D8%B2%D9%8A%D8%A9-authorization-rbac-%D9%88-rebac/).
- **فحص `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.

[![](https://arabroot.io/content/images/2026/10/owasp-top10-cover-cover.webp)دليلOWASP Top 10 للمبتدئين: أخطر عشر مشكلات في تطبيقات الويبأخطر عشر مشكلات أمنية في تطبيقات الويب، كل واحدة بمثال ضعيف ثم مصلح، وكيف تمنعها في الكود وأين تكتشفها.](https://arabroot.io/articles/owasp-top-10-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/)[![](https://arabroot.io/content/images/2026/10/api-security-cover-cover.webp)دليلأمن ال API بالأمثلة مع OWASP API Security Top 10أشهر عشرة مخاطر في واجهات API كما ترتبها OWASP، ولكل خطر مثال لكود ضعيف وكيف تصلحه وتختبره.](https://arabroot.io/articles/%D8%A7%D9%85%D9%86-%D9%88%D8%A7%D8%AC%D9%87%D8%A7%D8%AA-api-%D9%85%D8%B9-owasp-api-security-top-10/)[![](https://arabroot.io/content/images/2026/10/security-headers-cover-cover.webp)دليلHSTS وCSP: ترويسات الأمان التي يحتاجها كل تطبيقما الذي تطلبه كل ترويسة أمان من المتصفح، ولماذا يصعب التراجع عن HSTS، وكيف تبني سياسة CSP لا تكسر تطبيقك.](https://arabroot.io/articles/%D8%AA%D8%B1%D9%88%D9%8A%D8%B3%D8%A7%D8%AA-%D8%A7%D9%84%D8%A3%D9%85%D8%A7%D9%86-hsts-%D9%88-csp/)[![](https://arabroot.io/content/images/2026/10/sessions-cookies-jwt-cover-cover.webp)دليلكيف يتذكرك الموقع بعد تسجيل الدخول؟ الجلسات وJWTبروتوكول HTTP لا يتذكر أحداً، فكيف يبقى المستخدم مسجلاً بين الصفحات، ومتى تختار الجلسة على السيرفر ومتى تختار JWT.](https://arabroot.io/articles/%D8%A7%D9%84%D8%AC%D9%84%D8%B3%D8%A7%D8%AA-%D9%88%D9%85%D9%84%D9%81%D8%A7%D8%AA-%D8%AA%D8%B9%D8%B1%D9%8A%D9%81-%D8%A7%D9%84%D8%A7%D8%B1%D8%AA%D8%A8%D8%A7%D8%B7-%D9%88-jwt-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/)[![](https://arabroot.io/content/images/2026/10/sast-cover-cover.webp)دليلSAST: كيف يجد تحليل الكود الثابت الثغرات قبل التشغيلكيف تقرأ أداة SAST الكود بحثاً عن الثغرات، وما الذي لا تراه أبداً، وكيف تمنع الإنذارات الخاطئة من أن تفقد الفريق ثقته بها.](https://arabroot.io/articles/%D8%AA%D8%AD%D9%84%D9%8A%D9%84-%D8%A7%D9%84%D9%83%D9%88%D8%AF-%D8%A7%D9%84%D8%AB%D8%A7%D8%A8%D8%AA-sast-%D9%85%D8%B9-semgrep-%D9%88-sonarqube/)[![](https://arabroot.io/content/images/2026/10/dast-cover-cover.webp)دليلDAST: فحص تطبيقك من الخارج وهو يعملكيف يفحص ZAP تطبيقك من الخارج وهو يعمل، ومتى يحق لك أن تشغله، وكيف تحول نتائجه إلى بوابة في خط CI.](https://arabroot.io/articles/%D9%81%D8%AD%D8%B5-%D8%A7%D9%84%D8%AA%D8%B7%D8%A8%D9%8A%D9%82-%D8%A7%D8%AB%D9%86%D8%A7%D8%A1-%D8%A7%D9%84%D8%AA%D8%B4%D8%BA%D9%8A%D9%84-dast-%D9%85%D8%B9-zap/)

## سجل التحديثات

- أكتوبر 2026: كتابة المقال، وتنفيذ تجربة على نسخة من تطبيق سلسلة DevSecOps فيها سبع ثغرات ونسخة مصلحة، مع اثني عشر اختباراً ب pytest 9.1.1 فشل منها أحد عشر قبل الإصلاح ونجحت كلها بعده، وتجربة CSRF في Chrome 154 مع انتظار دقيقتين وبدونه، وفحص النسختين ب Semgrep 1.178.0، والتحقق من حوادث TalkTalk وMySpace من مصادرها.