> ## 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/كل-الطرق-لحماية-صفحة-او-اداة-داخلية/
- Published: 2026-10-12T18:30:00.000Z
- Updated: 2026-10-12T07:17:40.000Z
- Description: من إخفاء الأداة عن الإنترنت إلى تسجيل الدخول داخلها، متى تكفي كل طريقة، وأين تفشل، وكيف تجمع بينها.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, الشبكات والبنية التحتية, الشبكات والوصول, الاستضافة الذاتية

لنفرض أنك كتبت صفحة إدارة صغيرة لفريقك، أو ثبتت لوحة مراقبة أو واجهة لقاعدة البيانات على السيرفر، وتريد أن يصل إليها خمسة أشخاص فقط، فسوف تجد أمامك طرقاً كثيرة: أن تربطها بالعنوان 127.0.0.1، أو أن تسمح لعناوين IP محددة، أو أن تضع أمامها كلمة مرور Basic Auth، أو VPN، أو Forward Auth مع authentik، أو Cloudflare Access، أو شهادات العميل، أو تسجيل دخول داخل التطبيق نفسه. وكل طريقة منها تحمي من شيء ولا تحمي من شيء آخر، والخطأ الشائع أن يختار المسؤول واحدة ويظن أنها تكفي، ثم يكتشف أن الأداة كانت مفتوحة من طريق لم ينتبه إليه، كمنفذ منشور Published Port بجانب ال Reverse Proxy.

لذلك سوف نرتب الطرق في هذا الدليل على شكل سلم من الأبسط إلى الأقوى، ونشرح في كل درجة ما الذي تعرفه الطبقة عن الزائر، ومتى تكفي، وأين تفشل، مع رابط إلى الدليل الذي يشرحها بالتفصيل في الموقع بدل أن نكرره، ونستثني من ذلك HTTP Basic Auth، فهذا القسم هو مرجع الموقع له. ثم ننهي بجدول للقرار وتجربة عملية حقيقية نحمي فيها الصفحة نفسها بخمس طرق ونكسر كل واحدة منها.

وسوف نناقش في هذا المقال ما يلي:

- الدرجات الثماني: عدم النشر، وقائمة العناوين، وBasic Auth بالتفصيل، وVPN، وForward Auth، وCloudflare Access، وmTLS، وتسجيل الدخول داخل التطبيق.
- ما يقابل Cloudflare Access عند Tailscale وGoogle وMicrosoft، بإيجاز.
- جدول للقرار بحسب المستخدمين وMFA والسجل وإزالة الموظف والتكلفة ونقطة الفشل الواحدة، ثم كيف تجمع بين الطبقات.
- تجربة عملية فيها التقاط كلمة المرور من HTTP، وتجاوز قائمة العناوين بترويسة مزورة، وتجاوز Forward Auth من منفذ التطبيق، وفحص Cloudflare الناقص، وmTLS مع إلغاء شهادة.

## السلم من الأبسط إلى الأقوى

الصورة التالية تبين الدرجات الثماني التي سوف نمر عليها، ولاحظ أن الدرجات الأولى تحكم على الطلب من عنوانه أو من سر مشترك، أما الدرجات الأخيرة فتعرف الشخص نفسه، فتستطيع أن تفرض عليه مصادقة متعددة العوامل Multi-Factor Authentication وتسجل ما فعل وتغلق حسابه وحده عندما يغادر:

![سلم من ثماني درجات: localhost فقط، ثم قائمة عناوين IP، ثم Basic Auth، ثم VPN أو Mesh، ثم Forward Auth، ثم Cloudflare Access مع Tunnel، ثم شهادات العميل mTLS، ثم تسجيل الدخول داخل التطبيق عبر OIDC](https://arabroot.io/content/images/2026/10/protect-web-app-01-ladder.webp)

كلما صعدت عرفت الطبقة عن الزائر أكثر

والدرجة الأعلى ليست بالضرورة الأنسب لك، فأداة يستخدمها شخص واحد من جهازه تكفيها الدرجة الأولى، وأداة يستخدمها مئة موظف من أجهزة مختلفة تحتاج إلى الخامسة أو السادسة أو الثامنة، وغالباً ما تجمع بين درجتين أو ثلاث كما سيأتي في قسم [الجمع بين الطبقات](#layering).

## الدرجة الأولى: لا تنشر الأداة أصلاً

أقوى حماية لأداة لا يحتاجها إلا أنت أن لا يصل إليها أحد من الشبكة، أي أن تستمع على العنوان 127.0.0.1 فقط أو على عنوان الشبكة الخاصة، وفي Docker يعني ذلك أن تكتب المنفذ بالشكل `"127.0.0.1:8080:80"` بدل `"8080:80"`، والسبب أن الشكل الثاني ينشر المنفذ على كل عناوين السيرفر، وDocker يضيف قواعده في iptables قبل قواعد ufw، فلا يمنعه جدار الحماية Firewall الذي تظن أنه يحميك، كما شرحنا في [شبكات Docker وأفضل الممارسات](https://arabroot.io/articles/%D8%B4%D8%A8%D9%83%D8%A7%D8%AA-docker-%D9%88%D8%A3%D9%81%D8%B6%D9%84-%D8%A7%D9%84%D9%85%D9%85%D8%A7%D8%B1%D8%B3%D8%A7%D8%AA/#ports-vs-expose). وعندما تحتاج إلى فتح الأداة من جهازك تستخدم نفق SSH بالأمر `ssh -L` كما في [التوجيه المحلي في دليل الأنفاق](https://arabroot.io/articles/%D9%85%D8%A7-%D9%87%D9%88-%D8%A7%D9%84%D9%86%D9%81%D9%82-tunnel-%D9%81%D9%8A-%D8%A7%D9%84%D8%B4%D8%A8%D9%83%D8%A7%D8%AA/#ssh-local)، فتمر على مصادقة SSH بمفتاحك، ولا يبقى على الإنترنت أي باب جديد.

ولكن انتبه إلى أن «غير منشورة» لا تعني «معزولة»، فكل Container على شبكة Docker نفسها يستطيع أن يطلب الأداة باسمها مباشرة دون أن يمر على أي حماية، وهذا ما سوف نراه في [التجربة العملية](#lab-neighbour)، والحل أن تضع الأداة في شبكة لا يشاركها فيها إلا ال Reverse Proxy، وهو نمط الشبكة المشتركة في [دليل شبكات Docker](https://arabroot.io/articles/%D8%B4%D8%A8%D9%83%D8%A7%D8%AA-docker-%D9%88%D8%A3%D9%81%D8%B6%D9%84-%D8%A7%D9%84%D9%85%D9%85%D8%A7%D8%B1%D8%B3%D8%A7%D8%AA/#proxy-network).

**متى تكفي:** أداة لشخص أو شخصين يدخلان عبر SSH، أو خدمة لا يطلبها إلا تطبيق آخر على السيرفر نفسه. **متى لا تكفي:** عندما يحتاجها فريق من المتصفح دون أن يعرف كل واحد منهم SSH.

## الدرجة الثانية: قائمة عناوين IP المسموحة

في هذه الدرجة يرد ال Reverse Proxy بالرمز 403 على أي عنوان ليس في القائمة، وقد شرحنا الإعداد في NPM وTraefik وCaddy وHAProxy في [قصر لوحات الإدارة على عناوين محددة](https://arabroot.io/articles/%D8%AD%D9%85%D8%A7%D9%8A%D8%A9-%D8%AA%D8%B7%D8%A8%D9%8A%D9%82%D8%A7%D8%AA-%D8%A7%D9%84%D9%88%D9%8A%D8%A8-%D8%B9%D9%84%D9%89-%D9%85%D8%B3%D8%AA%D9%88%D9%89-http/#ip-allowlist)، فلا نكرره هنا. والقائمة مفيدة فعلاً لشبكة المكتب أو لشبكة VPN، ولكنها تفشل في ثلاث حالات:

- **العنوان المشترك CGNAT:** كثير من شركات الاتصالات تضع عملاءها خلف عنوان عام واحد من خلال [النطاق المشترك Shared Address Space](https://www.rfc-editor.org/rfc/rfc6598?ref=arabroot.io) `100.64.0.0/10`، فإذا سمحت لعنوان بيت الموظف فقد تسمح معه لمئات من الغرباء الذين يخرجون من العنوان نفسه.
- **العنوان المتغير:** عنوان الجوال والاتصال المنزلي يتغير، فيتصل بك الموظف المسافر كل يوم لتضيف عنوانه الجديد، ويبقى عنوانه القديم في القائمة لأن أحداً لا ينظفها.
- **العنوان الذي يراه ال Proxy خاطئ:** إذا وثق ال Reverse Proxy بترويسة `X-Forwarded-For` من أي أحد، فيستطيع المهاجم أن يكتب فيها عنواناً مسموحاً ويدخل، وهذا ما سوف نكسره في [التجربة](#lab-allowlist). وقد شرحنا أي عنوان تختار من هذه الترويسة في [تطبيقك خلف Reverse Proxy](https://arabroot.io/articles/%D8%AA%D8%B7%D8%A8%D9%8A%D9%82%D9%83-%D8%AE%D9%84%D9%81-reverse-proxy/#spoofing).

والقائمة لا تعرف من هو الشخص، فكل من يجلس في المكتب يدخل، ولا يوجد سجل يقول من فتح الأداة، لذلك اعتبرها طبقة إضافية تقلل من يطرق الباب، وليست الباب نفسه.

## الدرجة الثالثة: HTTP Basic Auth بالتفصيل

هذه أشهر طريقة وأسرعها، فسطران في Nginx أو Caddy أو قائمة وصول Access List في NPM تكفي لتظهر للزائر نافذة اسم المستخدم وكلمة المرور، ولأن المواقع تستخدمها كثيراً دون أن تفهم ما يحدث فيها، سوف نشرحها هنا بالتفصيل كمرجع للموقع، ونربط إليه من الأدلة الأخرى.

### كيف يعمل Basic Auth؟

المعيار هو [RFC 7617](https://www.rfc-editor.org/rfc/rfc7617?ref=arabroot.io)، وهو يصف تبادلاً من أربع خطوات: يطلب المتصفح الصفحة دون بيانات، فيرد السيرفر بالرمز 401 Unauthorized ومعه ترويسة `WWW-Authenticate` فيها اسم المخطط `Basic` والمعامل `realm` الإلزامي، وهو اسم «منطقة الحماية» الذي يظهر أحياناً في نافذة المتصفح، ويمكن أن يضاف معامل اختياري `charset="UTF-8"` ليخبر المتصفح بترميز الحروف. عندها يسأل المتصفح المستخدم، ثم يجمع اسم المستخدم ونقطتين رأسيتين وكلمة المرور في نص واحد `user:password`، ويرمزه بترميز Base64، ويرسله في ترويسة `Authorization: Basic …`. والصورة التالية تبين هذا التبادل بالقيم الحقيقية من تجربتنا:

![المتصفح يطلب الصفحة، فيرد nginx بالرمز 401 وترويسة WWW-Authenticate مع realm، ثم يعيد المتصفح الطلب مع ترويسة Authorization Basic، ومن يرى الطريق عبر HTTP يفك Base64 ويقرأ ops:Lab-Pass-2026](https://arabroot.io/content/images/2026/10/protect-web-app-02-basic-auth.webp)

تبادل Basic Auth، وما يراه من يقف على الطريق

### Base64 ترميز وليس تشفيراً

الترميز Encoding يحول البايتات إلى حروف يمكن نقلها، ويفكه أي أحد دون مفتاح، كما شرحنا في [الترميز ليس تشفيراً وليس هاش](https://arabroot.io/articles/%D8%A7%D9%84%D8%AA%D8%B1%D9%85%D9%8A%D8%B2-base64-%D9%88-base32-%D9%88-base58/#not-encryption)، لذلك يقول المعيار نفسه إن أخطر عيب في Basic Auth أنه ينقل كلمة المرور نصاً واضحاً عبر الشبكة، وإنه لا ينبغي أن يستخدم لحماية معلومات حساسة دون HTTPS. وفي [التجربة](#lab-basic) سوف تلتقط الترويسة من الشبكة وتفكها بأمر واحد.

### يرسل مع كل طلب، ولا يعرف الجلسات

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

- **لا توجد جلسة Session ولا تسجيل خروج Logout:** لا يحدد المعيار طريقة لإنهاء الدخول، فالمتصفح يرسل كلمة المرور حتى تغلقه أو تمسح بياناته، ولا يستطيع المسؤول أن ينهي دخول شخص معين، والطريقة الوحيدة أن يغير كلمة المرور.
- **هجمات CSRF:** بحسب [توثيق MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Authentication?ref=arabroot.io) فالمواقع التي تستخدم Basic Auth معرضة بشكل خاص لهجمات تزوير الطلبات Cross-Site Request Forgery، والسبب أن المتصفح يرسل بيانات الدخول مع كل طلب، حتى الطلب الذي بدأته صفحة أخرى.
- **يتحقق السيرفر من الهاش في كل طلب:** إذا خزنت كلمة المرور بهاش بطيء مثل bcrypt، وهذا هو الصواب، فإن السيرفر يدفع ثمن هذا الحساب مع كل طلب وكل ملف CSS أو صورة في الصفحة، وسوف نقيس ذلك في [التجربة](#lab-basic-cost).

### ملف htpasswd: أي هاش تختار؟

يقرأ Nginx أسماء المستخدمين من ملف بصيغة `name:hash` عبر التوجيه [auth\_basic\_user\_file](https://nginx.org/en/docs/http/ngx%5Fhttp%5Fauth%5Fbasic%5Fmodule.html?ref=arabroot.io)، ويقبل الهاش الذي تنتجه الدالة `crypt()` في النظام، أو صيغة MD5 الخاصة بـ Apache واسمها apr1، أو الصيغ `{PLAIN}` و`{SHA}` و`{SSHA}`، والتوثيق نفسه يقول إن الأوليين لا ينبغي أن تستخدما. والأداة المعتادة لإنشاء الملف هي [htpasswd](https://httpd.apache.org/docs/2.4/programs/htpasswd.html?ref=arabroot.io) من Apache، ولاحظ أن خيارها الافتراضي منذ الإصدار 2.2.18 هو MD5، أما bcrypt فتطلبه بالخيار `-B`، ومعه الخيار `-C` لتكلفة الحساب Cost، وقيمته الافتراضية 5 وتقبل من 4 إلى 17:

```bash
docker run --rm alpine:3.24.2 sh -c 'apk add -q apache2-utils >/dev/null; htpasswd -nbB -C 12 ops "Lab-Pass-2026"; htpasswd -nb oldmd5 "Lab-Pass-2026"'
```

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

```text
ops:$2y$12$nlvmfnZY1/6xCraOgsH88.oSBwWxU2v1ITZEcQfrQj6H1gh/QP7gG

oldmd5:$apr1$OGirmwR7$6vIFX1qRdkfKk/kJtntvW0
```

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

- السطر الأول هاش bcrypt يبدأ بـ `$2y$12$`، والرقم 12 هو التكلفة، وكل زيادة بواحد تضاعف وقت الحساب، والسطر الثاني MD5 بصيغة apr1، وهو ما تحصل عليه إذا نسيت `-B`.
- bcrypt مع Nginx يعتمد على دالة `crypt()` في النظام، وقد اختبرناه على Image الرسمي `nginx:1.31.6-alpine` فقبله، فإذا استخدمت Image آخر فاختبر الدخول قبل أن تعتمد عليه.
- Caddy لا يقبل إلا bcrypt، ويولده بالأمر `caddy hash-password` كما في [دليل Caddy](https://arabroot.io/articles/%D8%AA%D8%AB%D8%A8%D9%8A%D8%AA-caddy-reverse-proxy/#admin-password)، والتوجيه نفسه في [حماية مسار الإدارة بكلمة مرور](https://arabroot.io/articles/%D8%AA%D8%AB%D8%A8%D9%8A%D8%AA-caddy-reverse-proxy/#basic-auth). وفي NPM تضيف المستخدمين من قائمة الوصول كما في [دليل NPM](https://arabroot.io/articles/nginx-proxy-manager-%D9%86%D8%B7%D8%A7%D9%82%D8%A7%D8%AA-%D9%88%D8%B4%D9%87%D8%A7%D8%AF%D8%A7%D8%AA-tls/#access-lists).
- لا تضع ملف htpasswd في مستودع Git، فالهاش البطيء يؤخر كسر كلمة المرور ولا يمنعه.

### التخمين وMFA

لا يوجد في Basic Auth قفل للحساب بعد محاولات فاشلة، ولا مصادقة ثنائية، فالسيرفر يرد بالرمز 401 على كل محاولة خاطئة ويستمر في الرد إلى الأبد، لذلك إذا كانت الصفحة على الإنترنت فأضف أمامها حداً لمعدل الطلبات Rate Limiting وحظراً تلقائياً بـ fail2ban على أسطر 401 في السجل، وقد شرحنا الاثنين في [الحد من معدل الطلبات](https://arabroot.io/articles/%D8%AD%D9%85%D8%A7%D9%8A%D8%A9-%D8%AA%D8%B7%D8%A8%D9%8A%D9%82%D8%A7%D8%AA-%D8%A7%D9%84%D9%88%D9%8A%D8%A8-%D8%B9%D9%84%D9%89-%D9%85%D8%B3%D8%AA%D9%88%D9%89-http/#rate-limiting) و[fail2ban على سجلات ال Reverse Proxy](https://arabroot.io/articles/%D8%AD%D9%85%D8%A7%D9%8A%D8%A9-%D8%AA%D8%B7%D8%A8%D9%8A%D9%82%D8%A7%D8%AA-%D8%A7%D9%84%D9%88%D9%8A%D8%A8-%D8%B9%D9%84%D9%89-%D9%85%D8%B3%D8%AA%D9%88%D9%89-http/#fail2ban)، وشرحنا الخوارزميات نفسها في [تحديد معدل الطلبات Rate Limiting](https://arabroot.io/articles/%D8%AA%D8%AD%D8%AF%D9%8A%D8%AF-%D9%85%D8%B9%D8%AF%D9%84-%D8%A7%D9%84%D8%B7%D9%84%D8%A8%D8%A7%D8%AA-rate-limiting-%D9%88%D8%AE%D9%88%D8%A7%D8%B1%D8%B2%D9%85%D9%8A%D8%A7%D8%AA%D9%87/).

### متى يكفي Basic Auth؟

يكفي عندما تجتمع ثلاثة شروط: الاتصال عبر HTTPS دائماً، وعدد قليل من الأشخاص لكل منهم اسم مستخدم خاص به وليس كلمة مرور مشتركة، وأداة لا يضر كثيراً أن يدخلها شخص بكلمة مرور مسروقة، كبيئة اختبار Staging أو صفحة إحصائيات. ولا يكفي عندما تحتاج إلى MFA، أو إلى معرفة من دخل، أو عندما يتغير الفريق كثيراً، والسبب أن كل موظف يغادر يعني أن تعدل ملفاً على كل سيرفر.

⚠️

إذا وضعت Basic Auth أمام تطبيق له تسجيل دخول خاص به، فلا تفعل خيار تمرير ترويسة `Authorization` إلى التطبيق (Pass Auth to Upstream في NPM) إلا إذا كان التطبيق يحتاجها، والسبب أن التطبيق يستلم عندها كلمة المرور نفسها مع كل طلب، فقد تظهر في سجلاته أو في أي أداة تراقب الطلبات.

## الدرجة الرابعة: VPN أو شبكة Mesh

هنا لا تنشر الأداة على الإنترنت أبداً، وإنما تبقى على عنوان خاص، ولا يصل إليها إلا جهاز داخل شبكة VPN يملك مفتاحاً، فالمهاجم لا يرى صفحة دخول ليجرب عليها كلمات المرور أصلاً. وقد شرحنا الفكرة والمصطلحات في [ما هو VPN؟](https://arabroot.io/articles/%D9%85%D8%A7-%D9%87%D9%88-vpn-%D8%B4%D8%B1%D8%AD-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/)، والإعداد العملي في [شبكة خاصة مع WireGuard وwg-easy](https://arabroot.io/articles/%D8%B4%D8%A8%D9%83%D8%A9-%D8%AE%D8%A7%D8%B5%D8%A9-%D9%85%D8%B9-wireguard-%D9%88-wg-easy/)، وأدوات Mesh مثل Headscale وNetBird في [أدوات الوصول الآمن إلى السيرفرات](https://arabroot.io/articles/%D8%A7%D8%AF%D9%88%D8%A7%D8%AA-%D8%A7%D9%84%D9%88%D8%B5%D9%88%D9%84-%D8%A7%D9%84%D8%A7%D9%85%D9%86-%D8%A7%D9%84%D9%89-%D8%A7%D9%84%D8%B3%D9%8A%D8%B1%D9%81%D8%B1%D8%A7%D8%AA/#mesh).

ونقطة ضعف VPN أن من دخل الشبكة يصل غالباً إلى كل ما فيها، وأن الجهاز هو الذي يحمل المفتاح وليس الشخص، فإذا سرق حاسوب الموظف دخل السارق، لذلك اربط تسجيل الأجهزة بمزود هوية مع MFA إن كانت الأداة تسمح، وقيد بقواعد ACL ما يصل إليه كل جهاز. وفي شبكات Tailscale تحديداً تستطيع أن تعرض الأداة لأجهزة الشبكة فقط بالأمر [Tailscale Serve](https://tailscale.com/kb/1312/serve?ref=arabroot.io)، وهو يضيف إلى الطلب ترويسات فيها هوية المستخدم مثل `Tailscale-User-Login`، ولكن التوثيق نفسه ينصح بأن تستمع الخدمة على localhost فقط إذا اعتمدت على هذه الترويسات، والسبب أن من يصل إلى الخدمة مباشرة يستطيع أن يكتب هذه الترويسات بنفسه.

## الدرجة الخامسة: Proxy يعرف الهوية عبر Forward Auth

في هذه الدرجة يسأل ال Reverse Proxy مزود الهوية Identity Provider قبل كل طلب: هل لهذا المستخدم جلسة صالحة، وهل يحق له فتح هذه الأداة؟ فإذا لم يكن له جلسة حوله إلى صفحة دخول فيها MFA وسياسات المجموعات، وبعد الدخول يمرر الطلب إلى الأداة ومعه اسم المستخدم ومجموعاته في ترويسات. وقد شرحنا هذا بالتفصيل مع authentik، ومعه مخطط للمكونات وخطوات الطلب الخمس، في [كيف يعمل Forward Auth؟](https://arabroot.io/articles/%D8%AD%D9%85%D8%A7%D9%8A%D8%A9-%D8%A3%D9%8A-%D8%AA%D8%B7%D8%A8%D9%8A%D9%82-%D8%A8%D8%A7%D8%B3%D8%AA%D8%AE%D8%AF%D8%A7%D9%85-authentik-forward-auth/#how-it-works)، وقارنا أدوات هذه العائلة في [ال Proxy الذي يعرف الهوية](https://arabroot.io/articles/%D8%A7%D8%AF%D9%88%D8%A7%D8%AA-%D8%A7%D9%84%D9%88%D8%B5%D9%88%D9%84-%D8%A7%D9%84%D8%A7%D9%85%D9%86-%D8%A7%D9%84%D9%89-%D8%A7%D9%84%D8%B3%D9%8A%D8%B1%D9%81%D8%B1%D8%A7%D8%AA/#identity-proxies). وباختصار:

- **authentik:** مزود هوية كامل مع outpost مدمج يجيب عن طلبات Forward Auth، ونواته برخصة MIT، وهو الأنسب إذا كان عندك أو ستحتاج إلى OIDC لتطبيقات أخرى.
- [**Authelia**](https://github.com/authelia/authelia?ref=arabroot.io)**:** خدمة واحدة خفيفة برخصة Apache-2.0، تقرأ المستخدمين من ملف YAML أو من LDAP، وتجيب عن Nginx عبر نقطة `/api/authz/auth-request` كما في [توثيق Authelia مع Nginx](https://www.authelia.com/integration/proxies/nginx/?ref=arabroot.io)، وآخر إصدار منها وقت الكتابة v4.39.28، وهي التي استخدمناها في التجربة.
- [**OAuth2 Proxy**](https://github.com/oauth2-proxy/oauth2-proxy?ref=arabroot.io)**:** برخصة MIT، لا يحفظ المستخدمين وإنما يرسلهم إلى مزود OIDC موجود مثل Google أو GitHub أو Keycloak، وآخر إصدار منه v7.15.5.
- [**Pomerium**](https://github.com/pomerium/pomerium?ref=arabroot.io)**:** Reverse Proxy مستقل برخصة Apache-2.0 يطبق سياسة لكل مسار، وآخر إصدار منه v0.33.4.

والخطر الأكبر هنا ليس في الأداة وإنما في الطريق، فالفحص لا يحدث إلا للطلبات التي تمر على ال Reverse Proxy، فإذا كان للتطبيق منفذ منشور أو كان على شبكة يصل إليها غيره، فسوف يتجاوز الطلب صفحة الدخول كلها، والأسوأ أن التطبيق الذي يثق بترويسة `Remote-User` سوف يقبل اسم أي مستخدم يكتبه المهاجم فيها، وهذا ما سوف نراه في [التجربة](#lab-bypass).

## الدرجة السادسة: Cloudflare Access أمام Cloudflare Tunnel

هنا تنتقل صفحة الدخول من سيرفرك إلى شبكة Cloudflare، فالبرنامج `cloudflared` على سيرفرك يفتح اتصالاً خارجاً إلى Cloudflare، فلا تحتاج إلى أي منفذ مفتوح، وقد شرحنا ذلك في [Cloudflare Tunnel](https://arabroot.io/articles/%D8%AE%D8%AF%D9%85%D8%A7%D8%AA-cloudflare-%D9%88%D8%A8%D8%AF%D8%A7%D8%A6%D9%84%D9%87%D8%A7-%D9%85%D9%81%D8%AA%D9%88%D8%AD%D8%A9-%D8%A7%D9%84%D9%85%D8%B5%D8%AF%D8%B1/#tunnel) و[تشغيل خادمك من إنترنت المنزل](https://arabroot.io/articles/%D8%AA%D8%B4%D8%BA%D9%8A%D9%84-%D8%AE%D8%A7%D8%AF%D9%85%D9%83-%D9%85%D9%86-%D8%A5%D9%86%D8%AA%D8%B1%D9%86%D8%AA-%D8%A7%D9%84%D9%85%D9%86%D8%B2%D9%84/)، ثم تضيف أمام اسم النطاق تطبيقاً في Cloudflare Access فيه سياسة تحدد من يدخل، فيسجل المستخدم دخوله عبر Google أو Microsoft أو أي مزود SAML أو OIDC قبل أن يصل أي طلب إلى النفق، كما ذكرنا في [Zero Trust Access](https://arabroot.io/articles/%D8%AE%D8%AF%D9%85%D8%A7%D8%AA-cloudflare-%D9%88%D8%A8%D8%AF%D8%A7%D8%A6%D9%84%D9%87%D8%A7-%D9%85%D9%81%D8%AA%D9%88%D8%AD%D8%A9-%D8%A7%D9%84%D9%85%D8%B5%D8%AF%D8%B1/#zero-trust-access). والصورة التالية تبين الطريق الصحيح، والطريقين اللذين يتجاوزانه:

![عضو الفريق يسجل دخوله في Cloudflare Access، ثم يمر الطلب في نفق cloudflared إلى التطبيق مع ترويسة Cf-Access-Jwt-Assertion، بينما يصل منفذ منشور وContainer مجاور إلى التطبيق مباشرة دون دخول](https://arabroot.io/content/images/2026/10/protect-web-app-03-around.webp)

Cloudflare Access يحمي الطريق الذي يمر به فقط

### الخطة المجانية وحدودها

راجعنا [صفحة Cloudflare Access](https://www.cloudflare.com/products/cloudflare-access/?ref=arabroot.io) في 12 أكتوبر 2026، وفيها ثلاث خطط: المجانية Free بسعر 0 دولار ومعها حد 50 مستخدماً، وهي بحسب الصفحة للفرق الأقل من 50 مستخدماً أو لتجارب الشركات، ثم Pay-as-you-go بسعر 7 دولارات لكل مستخدم في الشهر تدفع سنوياً ودون حد للمستخدمين، ثم Contract بسعر تتفق عليه مع المبيعات. ومدة حفظ السجلات Log Retention في جدول المقارنة نفسه 24 ساعة في المجانية، و30 يوماً في Pay-as-you-go، وحتى 6 أشهر في Contract. وهذه الأرقام تتغير، فراجعها قبل أن تبني عليها قرارك.

والمستخدم هنا مقعد Seat، فبحسب [توثيق إدارة المقاعد](https://developers.cloudflare.com/cloudflare-one/identity/users/seat-management/?ref=arabroot.io) يأخذ المستخدم مقعداً بمجرد أن يسجل دخوله أول مرة، ويبقى له حتى تحذفه، وعندما تمتلئ المقاعد يمنع أي مستخدم جديد من الدخول. وعند مغادرة موظف لاحظ أن الأمرين مختلفان: إلغاء المستخدم Revoke ينهي جلساته الحالية ولكنه لا يحرر مقعده، وحذفه Remove يحرر المقعد ولكنه لا يمنعه من الدخول مرة أخرى، لذلك يجب أن تعطل حسابه في مزود الهوية نفسه أو تخرجه من السياسة.

### لا تثق بالطلب لأنه وصل، تحقق من التوكن

عندما يمرر Cloudflare الطلب إلى سيرفرك يضيف إليه توكن JWT في الترويسة `Cf-Access-Jwt-Assertion`، ويضيفه للمتصفحات أيضاً في Cookie اسمه `CF_Authorization`، وبحسب [توثيق التحقق من JWT](https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/authorization-cookie/validating-json/?ref=arabroot.io) يجب أن تتحقق من توقيع التوكن بالمفتاح العام الخاص بحسابك ومن الحقل `aud` الذي يحدد التطبيق، والسبب أن هذا هو ما يثبت أن الطلب جاء من Access وليس من طرف آخر. ولا يكفي أن تتأكد أن الترويسة موجودة، فأي أحد يستطيع أن يكتبها، وهذا ما سوف نكسره في [التجربة](#lab-cloudflare). وأسهل طريقة مع Tunnel أن تطلب من `cloudflared` نفسه أن يتحقق قبل أن يمرر الطلب، عبر الإعداد [access في originRequest](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/configure-tunnels/cloudflared-parameters/origin-parameters/?ref=arabroot.io):

```yaml
ingress:
  - hostname: admin.example.com
    service: http://app:80
    originRequest:
      access:
        required: true
        teamName: your-team-name
        audTag:
          - your-access-application-aud-tag
  - service: http_status:404
```

ولم نشغل هذا الإعداد في التجربة لأنه يحتاج إلى حساب Cloudflare حقيقي، وإنما نقلناه من التوثيق، وتجد قيمة AUD في إعدادات تطبيق Access. وتذكر أن هذا التحقق يحمي طريق النفق فقط، فإذا بقي للتطبيق منفذ منشور على عنوان السيرفر فالطلب إليه لا يمر على Cloudflare ولا على cloudflared أصلاً، لذلك لا تنشر أي منفذ، أو أغلق السيرفر على عناوين Cloudflare كما في [إعداد Cloudflare مع سيرفرك](https://arabroot.io/articles/%D8%A5%D8%B9%D8%AF%D8%A7%D8%AF-cloudflare-%D9%85%D8%B9-%D8%B3%D9%8A%D8%B1%D9%81%D8%B1%D9%83-%D8%A8%D8%B4%D9%83%D9%84-%D8%B5%D8%AD%D9%8A%D8%AD/#lock-origin). وتذكر أيضاً أن Cloudflare يرى كل ما يمر عبره، كما ناقشنا في [الخصوصية والسيادة على البيانات](https://arabroot.io/articles/%D8%AE%D8%AF%D9%85%D8%A7%D8%AA-cloudflare-%D9%88%D8%A8%D8%AF%D8%A7%D8%A6%D9%84%D9%87%D8%A7-%D9%85%D9%81%D8%AA%D9%88%D8%AD%D8%A9-%D8%A7%D9%84%D9%85%D8%B5%D8%AF%D8%B1/#privacy-sovereignty).

### ما يقابله عند الآخرين

- **Tailscale Funnel:** بحسب [توثيق Funnel](https://tailscale.com/kb/1223/funnel?ref=arabroot.io) ينشر خدمة من جهاز في الشبكة إلى الإنترنت كله دون أن يحتاج الزائر إلى Tailscale، وهو متاح في كل الخطط، ولا يستمع إلا على المنافذ 443 و8443 و10000\. ولكنه ليس صفحة دخول، فطلبات Funnel لا تحمل ترويسات الهوية التي يضيفها Serve، أي أن الخدمة المنشورة به مفتوحة لكل أحد ما لم تحمها أنت.
- **Google Identity-Aware Proxy:** بحسب [توثيق IAP](https://docs.cloud.google.com/iap/docs/concepts-overview?ref=arabroot.io) يضع طبقة تصريح مركزية أمام التطبيقات التي تعمل عبر HTTPS في Google Cloud، ويحذر التوثيق نفسه من أن من يصل إلى منفذ التطبيق على الآلة الافتراضية يتجاوز IAP، لذلك يطلب منك أن تتحقق من التوكن الموقع في الترويسة `x-goog-iap-jwt-assertion` كما في [توثيق الترويسات الموقعة](https://docs.cloud.google.com/iap/docs/signed-headers-howto?ref=arabroot.io). وتقول [صفحة الأسعار](https://cloud.google.com/iap/pricing?ref=arabroot.io) إن IAP بلا رسوم للموارد المستضافة في Google Cloud، مع رسوم موزع الحمل المطلوب، أما حماية موارد خارج Google Cloud فمن ميزات Chrome Enterprise Premium المدفوعة.
- **Microsoft Entra application proxy:** بحسب [توثيق Microsoft](https://learn.microsoft.com/en-us/entra/identity/app-proxy/overview-what-is-app-proxy?ref=arabroot.io) ينشر تطبيقات الويب الداخلية بعد دخول المستخدم في Microsoft Entra ID، عبر برنامج Connector على Windows Server في شبكتك لا يستخدم إلا اتصالات خارجة، فلا تفتح أي منفذ داخل. ويشترط [اشتراك Microsoft Entra ID P1 أو P2](https://learn.microsoft.com/en-us/entra/identity/app-proxy/application-proxy-add-on-premises-application?ref=arabroot.io).

## الدرجة السابعة: شهادات العميل mTLS

في TLS العادي يثبت السيرفر هويته بشهادة، أما في TLS المتبادل Mutual TLS فيطلب السيرفر من العميل أيضاً شهادة صادرة من جهة إصدار Certificate Authority يثق بها، فإذا لم يقدمها العميل انتهى الاتصال قبل أن يصل أي طلب HTTP إلى التطبيق، وبالتالي لا توجد صفحة دخول يمكن تخمين كلمة المرور فيها. وقد شرحنا الشهادات وسلسلة الثقة وصيغها في [الشهادات الرقمية TLS وX.509](https://arabroot.io/articles/%D8%A7%D9%84%D8%B4%D9%87%D8%A7%D8%AF%D8%A7%D8%AA-%D8%A7%D9%84%D8%B1%D9%82%D9%85%D9%8A%D8%A9-tls-%D9%88-x509-%D9%88%D8%B5%D9%8A%D8%BA-pem-%D9%88-der/)، وفي Nginx يكفي التوجيهان [ssl\_client\_certificate وssl\_verify\_client](https://nginx.org/en/docs/http/ngx%5Fhttp%5Fssl%5Fmodule.html?ref=arabroot.io#ssl%5Fverify%5Fclient)، ويضاف إليهما [ssl\_crl](https://nginx.org/en/docs/http/ngx%5Fhttp%5Fssl%5Fmodule.html?ref=arabroot.io#ssl%5Fcrl) لقائمة الشهادات الملغاة.

ونقطة ضعف mTLS أنها صعبة على الناس، فيجب أن تصدر شهادة لكل جهاز، وتسلمها بأمان في ملف PKCS#12 كما في [قسم PKCS#12](https://arabroot.io/articles/%D8%A7%D9%84%D8%B4%D9%87%D8%A7%D8%AF%D8%A7%D8%AA-%D8%A7%D9%84%D8%B1%D9%82%D9%85%D9%8A%D8%A9-tls-%D9%88-x509-%D9%88%D8%B5%D9%8A%D8%BA-pem-%D9%88-der/#pkcs12)، وتثبتها في المتصفح أو الجوال، وتلغيها عند مغادرة الموظف وتحدث قائمة الإلغاء على السيرفر، ولذلك تناسب mTLS الأجهزة والخدمات أكثر من البشر، كجهاز في فرع يرسل بيانات إلى لوحة مركزية، أو تطبيق يستدعي واجهة API داخلية.

## الدرجة الثامنة: تسجيل الدخول داخل التطبيق عبر OIDC

الدرجات السابقة كلها تقف أمام التطبيق، أما هنا فالتطبيق نفسه يعرف المستخدم، فيرسله إلى مزود الهوية عبر OpenID Connect، ويستلم منه من يكون وما مجموعاته، ثم يدير صلاحياته Permissions بنفسه: من يقرأ، ومن يعدل، ومن هو المدير. وهذا ما لا تستطيع أي طبقة أمامية أن تفعله، فال Proxy يقرر من يفتح الباب، ولكنه لا يعرف أن هذا المستخدم يحق له أن يرى الفواتير ولا يحق له أن يحذفها. وقد شرحنا البروتوكول في [شرح OAuth وOpenID Connect](https://arabroot.io/articles/%D8%B4%D8%B1%D8%AD-oauth-%D9%88-openid-connect-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/)، والجلسات التي ينشئها التطبيق بعد الدخول في [الجلسات وملفات تعريف الارتباط و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/)، ومثالاً كاملاً في [ربط Gitea مع authentik عبر OIDC](https://arabroot.io/articles/%D8%B1%D8%A8%D8%B7-gitea-%D9%85%D8%B9-authentik-%D8%B9%D8%A8%D8%B1-oidc/).

وإذا كانت الأداة من كتابة فريقك فهذه هي الطريقة الصحيحة على المدى البعيد، وإذا لم تكن تدعم OIDC فأنت تعود إلى الدرجة الخامسة، وهذا هو سبب وجود Forward Auth أصلاً.

## جدول القرار

الجدول التالي يلخص الدرجات بحسب الأسئلة التي تحدد اختيارك، ونقصد بنقطة الفشل الواحدة Single Point of Failure المكون الذي إذا توقف توقف الدخول إلى الأداة أو انفتحت:

| الطريقة           | المستخدمون              | MFA                       | السجل Audit                 | مغادرة موظف Offboarding      | التكلفة                             | نقطة الفشل الواحدة      |
| ----------------- | ----------------------- | ------------------------- | --------------------------- | ---------------------------- | ----------------------------------- | ----------------------- |
| localhost مع SSH  | شخص أو اثنان يعرفان SSH | من SSH إن فعلتها          | سجل SSH                     | حذف مفتاحه من السيرفر        | مجانية                              | لا توجد طبقة جديدة      |
| قائمة عناوين IP   | من في شبكة معروفة       | لا يوجد                   | عناوين فقط                  | لا تنفع، فالعنوان للمكان     | مجانية                              | ثقة ال Proxy بالترويسات |
| Basic Auth        | عدد قليل ثابت           | لا يوجد                   | اسم المستخدم في سجل الوصول  | تعديل الملف على كل سيرفر     | مجانية                              | ال Reverse Proxy        |
| VPN أو Mesh       | الفريق من أجهزته        | من مزود الهوية إن ربطته   | اتصالات الأجهزة             | حذف الجهاز أو المستخدم       | مجانية مستضافة ذاتياً               | سيرفر VPN أو التنسيق    |
| Forward Auth      | فريق من أي متصفح        | نعم                       | أحداث الدخول في مزود الهوية | تعطيل حساب واحد              | مجانية مستضافة ذاتياً               | مزود الهوية             |
| Cloudflare Access | فريق وجهات خارجية       | من مزود الهوية            | 24 ساعة في المجانية         | من مزود الهوية مع حذف المقعد | مجانية حتى 50 ثم 7 دولارات للمستخدم | Cloudflare ومزود الهوية |
| mTLS              | أجهزة وخدمات            | الشهادة عامل يملكه الجهاز | اسم الشهادة ورقمها          | إلغاء الشهادة وتحديث CRL     | مجانية                              | جهة الإصدار الداخلية    |
| OIDC في التطبيق   | كل المستخدمين بأدوارهم  | نعم                       | في التطبيق ومزود الهوية     | تعطيل حساب واحد              | جهد التطوير                         | مزود الهوية             |

وقد يتساءل البعض: لماذا لا أختار الأقوى دائماً؟ والإجابة أن كل درجة تضيف مكوناً يجب أن تشغله وتحدثه وتنسخه احتياطياً، فمزود الهوية الذي يحمي عشر أدوات يصبح نقطة فشل لها كلها، وإذا توقف فلن يدخل أحد، لذلك اختر أدنى درجة تحقق ما يحتاجه المستخدمون فعلاً، ثم أضف طبقة ثانية من نوع مختلف.

## الجمع بين الطبقات

الطبقات لا تتنافس، وإنما يغطي كل منها ثغرة في الأخرى، وأشهر التركيبات ما يلي:

- **VPN ثم Forward Auth:** لا يرى صفحة الدخول إلا من في الشبكة، ثم يسجل دخوله بحسابه الشخصي مع MFA، وهذا ما أوصينا به للأدوات الداخلية في [أين يقع Forward Auth بين الطبقات؟](https://arabroot.io/articles/%D8%AD%D9%85%D8%A7%D9%8A%D8%A9-%D8%AA%D8%B7%D8%A8%D9%8A%D9%82%D8%A7%D8%AA-%D8%A7%D9%84%D9%88%D9%8A%D8%A8-%D8%B9%D9%84%D9%89-%D9%85%D8%B3%D8%AA%D9%88%D9%89-http/#forward-auth).
- **Tunnel ثم Access ثم تسجيل الدخول في التطبيق:** لا يوجد منفذ مفتوح، ولا يصل إلى التطبيق إلا من أثبت هويته في Cloudflare، ثم يحدد التطبيق صلاحياته، وإذا أخطأت يوماً في سياسة Access بقي تسجيل الدخول في التطبيق خلفها.
- **mTLS ثم Basic Auth أو Forward Auth:** الشهادة تثبت أن الجهاز للشركة، وكلمة المرور أو MFA تثبت أن الشخص هو صاحب الحساب.
- **أي طبقة مع شبكة داخلية للتطبيق:** مهما كانت الطبقة الأمامية، يجب أن لا يصل إلى التطبيق إلا ال Proxy، وإلا كانت كل الطبقات السابقة زينة.

## تجربة عملية: صفحة واحدة بخمس طرق حماية

سوف نشغل صفحة إدارة تجريبية هي [traefik/whoami](https://github.com/traefik/whoami?ref=arabroot.io)، والسبب أنها تعرض كل ترويسة تصلها فنرى بأعيننا ما يمرره ال Proxy، ونضع أمامها nginx بخمسة أسماء نطاقات تمثل خمس طرق: Basic Auth عبر HTTP، وقائمة عناوين بإعداد خاطئ ثم صحيح، وفحص يشبه ما يفعله البعض مع Cloudflare، وForward Auth مع Authelia، وmTLS. ومعها جهازان بعنوانين ثابتين، واحد يمثل المكتب والآخر يمثل الغريب، وContainer ثالث يلتقط حركة الشبكة. والصورة التالية تبين المكونات:

![مكونات التجربة: office وoutsider وclient على شبكة lab-pwa-net، وnginx بعنوان 10.251.31.250 مع أسماء النطاقات والمنافذ، وAuthelia، وsniffer داخل شبكة nginx، والتطبيق whoami على شبكة داخلية بعد الإصلاح](https://arabroot.io/content/images/2026/10/protect-web-app-04-lab.webp)

مكونات التجربة، والطريق المباشر إلى التطبيق قبل الإصلاح

وقد شغلنا التجربة على Docker Engine 29.8.0 مع nginx 1.31.6 وAuthelia 4.39.28 وwhoami 1.12.0 وcurl 8.11.1 وOpenSSL 3.5.9 وtcpdump 4.99.6، وكل المنافذ مربوطة على 127.0.0.1، ولاحظ أن قيم الوقت في الأقسام القادمة من جهاز كان مشغولاً بتجارب أخرى، فخذ منها الفرق بين الحالات وليس الأرقام نفسها.

### ملفات التجربة

ملف Compose في أول التجربة، وفيه خطأ متعمد هو المنفذ المنشور للتطبيق، وسوف نصلحه في [آخر التجربة](#lab-fix):

```yaml
name: lab-pwa
services:
  app:
    image: traefik/whoami:v1.12.0
    container_name: lab-pwa-app
    networks: [lab]
    ports:
      - "127.0.0.1:18081:80"   # deliberate mistake for the bypass steps
  nginx:
    image: nginx:1.31.6-alpine
    container_name: lab-pwa-nginx
    depends_on: [app, authelia]
    volumes:
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
      - ./nginx/htpasswd:/etc/nginx/htpasswd:ro
      - ./certs:/etc/nginx/certs:ro
    networks:
      lab: { ipv4_address: 10.251.31.250 }
    ports:
      - "127.0.0.1:18080:8080"
      - "127.0.0.1:18443:8443"
      - "127.0.0.1:18444:8444"
  authelia:
    image: authelia/authelia:4.39.28
    container_name: lab-pwa-authelia
    volumes:
      - ./authelia:/config
      - authelia-data:/data
    networks: [lab]
  office:
    image: curlimages/curl:8.11.1
    container_name: lab-pwa-office
    command: ["sleep", "infinity"]
    networks:
      lab: { ipv4_address: 10.251.31.210 }
  outsider:
    image: curlimages/curl:8.11.1
    container_name: lab-pwa-outsider
    command: ["sleep", "infinity"]
    networks:
      lab: { ipv4_address: 10.251.31.166 }
  sniffer:
    image: alpine:3.24.2
    container_name: lab-pwa-sniffer
    command: ["sh", "-c", "apk add -q tcpdump && sleep infinity"]
    network_mode: "service:nginx"
networks:
  lab:
    name: lab-pwa-net
    ipam:
      config: [{ subnet: 10.251.31.0/24, ip_range: 10.251.31.0/25 }]
volumes:
  authelia-data:
    name: lab-pwa-authelia-data
```

في الإعداد أعلاه لاحظ التالي:

- العناوين الثابتة للمكتب والغريب وnginx في النصف الأعلى من الشبكة، و`ip_range` يحصر العناوين التلقائية في النصف الأدنى، والسبب أن Docker أعطى التطبيق في محاولتنا الأولى العنوان الذي حجزناه لـ nginx، ففشل تشغيله بالخطأ `Address already in use`.
- الخيار `network_mode: "service:nginx"` يضع ال sniffer داخل شبكة nginx نفسها، فيرى tcpdump كل ما يصل إلى nginx، تماماً كما يرى مهاجم على شبكة Wi-Fi عامة أو جهاز مخترق في الطريق.
- الخيار `depends_on` ضروري هنا، والسبب أن nginx يحل أسماء ال upstream عند التشغيل، فتوقف في محاولتنا الأولى بالخطأ `host not found in upstream "app"` لأنه سبق التطبيق.

وملف nginx فيه خادم Server لكل طريقة، ويفرق بينها اسم النطاق:

```nginx
# 1) Basic Auth over plain HTTP (the sniffing step)
server {
    listen 8080;
    server_name basic.lab.test;
    location / {
        auth_basic           "lab-pwa admin";
        auth_basic_user_file /etc/nginx/htpasswd;
        proxy_pass           http://app:80;
    }
}

# 2a) IP allowlist with a wrong trust setting
server {
    listen 8080;
    server_name allow.lab.test;
    set_real_ip_from 0.0.0.0/0;          # mistake: trusts X-Forwarded-For from anyone
    real_ip_header   X-Forwarded-For;
    location / {
        allow 10.251.31.210;              # the office
        deny  all;
        proxy_pass http://app:80;
    }
}

# 2b) the same allowlist, trusting only the real front proxy
server {
    listen 8080;
    server_name allowfix.lab.test;
    set_real_ip_from 10.251.31.245;       # the only proxy allowed to speak for a client
    real_ip_header   X-Forwarded-For;
    location / {
        allow 10.251.31.210;
        deny  all;
        proxy_pass http://app:80;
    }
}

# 4) a "Cloudflare Access" check that only looks for the header
server {
    listen 8080;
    server_name cf.lab.test;
    location / {
        if ($http_cf_access_jwt_assertion = "") { return 403; }
        proxy_pass http://app:80;
    }
}

# 3) forward auth with Authelia (https)
server {
    listen 8443 ssl;
    server_name auth.lab.test;
    ssl_certificate     /etc/nginx/certs/server.crt;
    ssl_certificate_key /etc/nginx/certs/server.key;
    location / {
        proxy_set_header Host $http_host;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Host $http_host;
        proxy_set_header X-Forwarded-URI $request_uri;
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_pass http://authelia:9091;
    }
}

server {
    listen 8443 ssl;
    server_name app.lab.test;
    ssl_certificate     /etc/nginx/certs/server.crt;
    ssl_certificate_key /etc/nginx/certs/server.key;

    location /internal/authelia/authz {
        internal;
        proxy_pass http://authelia:9091/api/authz/auth-request;
        proxy_set_header X-Original-Method $request_method;
        proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_set_header Content-Length "";
        proxy_set_header Connection "";
        proxy_pass_request_body off;
    }

    location / {
        auth_request /internal/authelia/authz;
        auth_request_set $user   $upstream_http_remote_user;
        auth_request_set $groups $upstream_http_remote_groups;
        auth_request_set $redirection_url $upstream_http_location;
        error_page 401 =302 $redirection_url;

        proxy_set_header Remote-User   $user;
        proxy_set_header Remote-Groups $groups;
        proxy_set_header Host $http_host;
        proxy_pass http://app:80;
    }
}

# 5) mTLS: no client certificate from our CA, no page
server {
    listen 8444 ssl;
    server_name admin.lab.test;
    ssl_certificate        /etc/nginx/certs/server.crt;
    ssl_certificate_key    /etc/nginx/certs/server.key;
    ssl_client_certificate /etc/nginx/certs/clients-ca.crt;
    ssl_verify_client      on;
    location / {
        proxy_set_header X-Client-DN $ssl_client_s_dn;
        proxy_set_header X-Client-Serial $ssl_client_serial;
        proxy_pass http://app:80;
    }
}
```

وملف htpasswd فيه سطر `ops` الذي ولدناه في [قسم htpasswd](#basic-htpasswd) بتكلفة 12\. أما Authelia فملف إعداده `authelia/configuration.yml` كما يلي، وفيه قاعدة واحدة تسمح لمجموعة `ops` فقط بفتح `app.lab.test` بعامل واحد One Factor، والأسرار فيه للتجربة فقط:

```yaml
server:
  address: 'tcp://:9091/'
log:
  level: info
identity_validation:
  reset_password:
    jwt_secret: 'lab-only-jwt-secret-change-me-0123456789'
authentication_backend:
  file:
    path: /config/users.yml
access_control:
  default_policy: deny
  rules:
    - domain: 'app.lab.test'
      subject: 'group:ops'
      policy: one_factor
session:
  secret: 'lab-only-session-secret-change-me-0123456789'
  cookies:
    - domain: 'lab.test'
      authelia_url: 'https://auth.lab.test:18443'
storage:
  encryption_key: 'lab-only-storage-key-change-me-0123456789'
  local:
    path: /data/db.sqlite3
notifier:
  filesystem:
    filename: /data/notification.txt
```

وفي ملف المستخدمين `authelia/users.yml` مستخدمان: alice في مجموعة `ops`، وbob في مجموعة `sales`، وكلمة المرور لكل منهما هاش argon2id ولدناه بالأمر التالي:

```bash
docker run --rm authelia/authelia:4.39.28 authelia crypto hash generate argon2 --password 'Lab-Pass-2026'
```

```text
Digest: $argon2id$v=19$m=65536,t=3,p=4$xKwSr+vfXPgAABfGBd3xDQ$oYisOkoRl9sidXhojuPFs+YETIkALbGbl29+Krv/bxc
```

```yaml
users:
  alice:
    displayname: "Alice"
    password: "$argon2id$v=19$m=65536,t=3,p=4$xKwSr+vfXPgAABfGBd3xDQ$oYisOkoRl9sidXhojuPFs+YETIkALbGbl29+Krv/bxc"
    email: alice@lab.test
    groups: [ops]
  bob:
    displayname: "Bob"
    password: "$argon2id$v=19$m=65536,t=3,p=4$xKwSr+vfXPgAABfGBd3xDQ$oYisOkoRl9sidXhojuPFs+YETIkALbGbl29+Krv/bxc"
    email: bob@lab.test
    groups: [sales]
```

وأخيراً الشهادات، فنحتاج إلى جهة إصدار للسيرفر، وجهة إصدار ثانية لشهادات العملاء مع شهادة لـ alice، وجهة ثالثة لا يثق بها السيرفر مع شهادة مزورة تحمل الاسم نفسه، ونولدها كلها في Container مؤقت:

```bash
docker run --rm -v "$PWD/certs:/certs" -w /certs alpine:3.24.2 sh -c '
apk add -q openssl >/dev/null
# server CA + server cert
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -days 30 -subj "/CN=lab-pwa server CA" -keyout server-ca.key -out server-ca.crt
openssl req -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -subj "/CN=lab.test" -keyout server.key -out server.csr
printf "subjectAltName=DNS:app.lab.test,DNS:auth.lab.test,DNS:admin.lab.test\nextendedKeyUsage=serverAuth\n" > server.ext
openssl x509 -req -in server.csr -CA server-ca.crt -CAkey server-ca.key -CAcreateserial -days 30 -extfile server.ext -out server.crt
# client CA + client cert for one person
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -days 30 -subj "/CN=lab-pwa clients CA" -keyout clients-ca.key -out clients-ca.crt
openssl req -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -subj "/CN=alice/O=ops" -keyout alice.key -out alice.csr
printf "extendedKeyUsage=clientAuth\n" > client.ext
openssl x509 -req -in alice.csr -CA clients-ca.crt -CAkey clients-ca.key -CAcreateserial -days 30 -extfile client.ext -out alice.crt
# a self-made CA the server does not trust
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -days 30 -subj "/CN=mallory CA" -keyout rogue-ca.key -out rogue-ca.crt
openssl req -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -subj "/CN=alice/O=ops" -keyout rogue.key -out rogue.csr
openssl x509 -req -in rogue.csr -CA rogue-ca.crt -CAkey rogue-ca.key -CAcreateserial -days 30 -extfile client.ext -out rogue.crt
chmod 644 *.key'
```

### التشغيل

```bash
docker compose up -d
docker ps --filter name=lab-pwa --format '{{.Names}}\t{{.Status}}\t{{.Ports}}'
```

```text
lab-pwa-sniffer   Up 13 seconds
lab-pwa-nginx     Up 17 seconds             127.0.0.1:18080->8080/tcp, 127.0.0.1:18443->8443/tcp, 127.0.0.1:18444->8444/tcp
lab-pwa-office    Up 14 seconds
lab-pwa-authelia  Up 19 seconds (healthy)   9091/tcp
lab-pwa-app       Up 22 seconds             127.0.0.1:18081->80/tcp
lab-pwa-outsider  Up 25 seconds
```

لاحظ في السطر الأخير قبل الأخير أن للتطبيق منفذاً منشوراً، وهو الخطأ الذي سوف نستغله بعد قليل.

### نكسرها: كلمة مرور Basic Auth عبر HTTP

نطلب الصفحة أولاً من جهاز المكتب دون بيانات دخول، لنرى رد السيرفر:

```bash
docker exec lab-pwa-office curl -s -i -H 'Host: basic.lab.test' http://10.251.31.250:8080/ | head -8
```

```text
HTTP/1.1 401 Unauthorized
Server: nginx/1.31.6
Date: Mon, 12 Oct 2026 05:55:51 GMT
Content-Type: text/html
Content-Length: 179
Connection: keep-alive
WWW-Authenticate: Basic realm="lab-pwa admin"
```

هذا هو الرمز 401 مع اسم المنطقة الذي كتبناه في `auth_basic`. والآن نشغل tcpdump في ال sniffer لثماني ثوان، ونسجل الدخول من المكتب بكلمة المرور الصحيحة، ثم نبحث في الالتقاط:

```bash
docker exec -d lab-pwa-sniffer sh -c 'timeout 8 tcpdump -i eth0 -A -s 0 -l "tcp port 8080 and src host 10.251.31.210" > /tmp/cap.txt 2>/dev/null'
docker exec lab-pwa-office curl -s -o /dev/null -u 'ops:Lab-Pass-2026' -H 'Host: basic.lab.test' http://10.251.31.250:8080/
docker exec lab-pwa-sniffer grep -a -E "GET|Host:|Authorization" /tmp/cap.txt
docker exec lab-pwa-sniffer sh -c 'echo b3BzOkxhYi1QYXNzLTIwMjY= | base64 -d; echo'
```

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

```text
05:56:57.690168 IP lab-pwa-office.lab-pwa-net.33144 > 89a102ad6fbc.8080: Flags [P.], seq 0:125, ack 1, win 502, options [nop,nop,TS val 1485937358 ecr 3659091470], length 125: HTTP: GET / HTTP/1.1
X.....N.GET / HTTP/1.1
Host: basic.lab.test
Authorization: Basic b3BzOkxhYi1QYXNzLTIwMjY=
ops:Lab-Pass-2026
```

كلمة المرور في السطر الأخير كما كتبها المستخدم، ولم يحتج ال sniffer إلى أي مفتاح، لذلك لا تستخدم Basic Auth عبر HTTP حتى داخل شبكتك، والسبب أن أي جهاز مخترق على الطريق يرى ما رأيناه. ومع HTTPS يصبح هذا الطلب نفسه بايتات مشفرة لا يقرأ منها ال sniffer شيئاً.

### نكسرها: التخمين لا يتوقف

نجرب من جهاز الغريب عشر كلمات مرور متتالية، آخرها الصحيحة:

```bash
docker exec lab-pwa-outsider sh -c 'start=$(date +%s); for p in 123456 password admin qwerty letmein Passw0rd ops2026 welcome summer2026 Lab-Pass-2026; do printf "%s %s\n" "$p" "$(curl -s -o /dev/null -w "%{http_code}" -u "ops:$p" -H "Host: basic.lab.test" http://10.251.31.250:8080/)"; done; echo "seconds: $(( $(date +%s) - start ))"'
docker logs lab-pwa-nginx 2>&1 | grep -c '10.251.31.166 .* 401 '
```

```text
123456 401
password 401
admin 401
qwerty 401
letmein 401
Passw0rd 401
ops2026 401
welcome 401
summer2026 401
Lab-Pass-2026 200
seconds: 11
9
```

لم يتوقف nginx عن الرد ولم يقفل الحساب، والتسع محاولات الفاشلة موجودة في السجل بالرمز 401 ومعها اسم المستخدم الذي جربه المهاجم، وهذا بالضبط ما يقرؤه fail2ban في [دليل حماية تطبيقات الويب](https://arabroot.io/articles/%D8%AD%D9%85%D8%A7%D9%8A%D8%A9-%D8%AA%D8%B7%D8%A8%D9%8A%D9%82%D8%A7%D8%AA-%D8%A7%D9%84%D9%88%D9%8A%D8%A8-%D8%B9%D9%84%D9%89-%D9%85%D8%B3%D8%AA%D9%88%D9%89-http/#fail2ban) ليحظر العنوان.

### ثمن bcrypt في كل طلب

لاحظ أن عشر محاولات أخذت 11 ثانية، والسبب ليس الشبكة وإنما هاش bcrypt بتكلفة 12 الذي يحسبه nginx مع كل طلب. نقارن ثلاثة طلبات صحيحة إلى الصفحة المحمية بثلاثة طلبات إلى الصفحة نفسها خلف قائمة العناوين، أي دون أي هاش:

```bash
docker exec lab-pwa-office sh -c 'for i in 1 2 3; do curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" -u "ops:Lab-Pass-2026" -H "Host: basic.lab.test" http://10.251.31.250:8080/; done; for i in 1 2 3; do curl -s -o /dev/null -w "no-auth-check %{http_code} %{time_total}s\n" -H "Host: allow.lab.test" http://10.251.31.250:8080/; done'
```

```text
200 0.649145s
200 0.787844s
200 0.816602s
no-auth-check 200 0.002520s
no-auth-check 200 0.038644s
no-auth-check 200 0.009357s
```

الأرقام نفسها من جهاز مشغول ولا تقاس عليها، ولكن الفرق واضح: كل طلب يحمل Basic Auth يدفع ثمن الهاش، وصفحة فيها ثلاثون ملفاً تعني ثلاثين حساباً. وهذا لا يعني أن تعود إلى MD5، وإنما أن تعرف أن Basic Auth لا يناسب أداة كثيرة الطلبات، وأن الجلسة التي ينشئها Forward Auth أو التطبيق تحسب الهاش مرة واحدة عند الدخول ثم تكتفي بالـ Cookie.

### نكسرها: قائمة العناوين وترويسة X-Forwarded-For

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

```bash
for c in office outsider; do docker exec lab-pwa-$c curl -s -o /dev/null -w "$c allow.lab.test %{http_code}\n" -H 'Host: allow.lab.test' http://10.251.31.250:8080/; done
docker exec lab-pwa-outsider curl -s -H 'Host: allow.lab.test' -H 'X-Forwarded-For: 10.251.31.210' http://10.251.31.250:8080/ | head -20
```

```text
office allow.lab.test 200
outsider allow.lab.test 403
Hostname: c19d266cff30
IP: 127.0.0.1
IP: ::1
IP: 10.251.31.2
RemoteAddr: 10.251.31.250:42942
GET / HTTP/1.1
Host: app
User-Agent: curl/8.11.1
Accept: */*
X-Forwarded-For: 10.251.31.210
```

دخل الغريب وفتح الصفحة، والسبب السطر `set_real_ip_from 0.0.0.0/0`، فهو يقول لـ nginx أن يصدق ترويسة `X-Forwarded-For` من أي أحد، فيستبدل عنوان الاتصال الحقيقي بالعنوان المكتوب فيها قبل أن يطبق `allow`. والأسوأ أن السجل نفسه تم خداعه، فهذه أسطره:

```bash
docker logs lab-pwa-nginx 2>&1 | grep -E '"GET / HTTP/1.1" (200|403)' | grep -v ops | tail -5
```

```text
10.251.31.210 - - [12/Oct/2026:06:06:25 +0000] "GET / HTTP/1.1" 200 160 "-" "curl/8.11.1" "-"
10.251.31.166 - - [12/Oct/2026:06:06:44 +0000] "GET / HTTP/1.1" 403 153 "-" "curl/8.11.1" "-"
10.251.31.210 - - [12/Oct/2026:06:06:59 +0000] "GET / HTTP/1.1" 200 192 "-" "curl/8.11.1" "10.251.31.210"
10.251.31.166 - - [12/Oct/2026:06:07:16 +0000] "GET / HTTP/1.1" 403 153 "-" "curl/8.11.1" "10.251.31.210"
10.251.31.210 - - [12/Oct/2026:06:07:27 +0000] "GET / HTTP/1.1" 200 192 "-" "curl/8.11.1" "-"
```

السطر الثالث هو طلب الغريب، ومسجل باسم عنوان المكتب، ولا يكشفه إلا الحقل الأخير الذي فيه قيمة الترويسة. أما في الخادم `allowfix.lab.test` فلا نصدق الترويسة إلا إذا جاءت من عنوان ال Proxy الأمامي الوحيد، وهو هنا `10.251.31.245`:

```bash
docker exec lab-pwa-outsider curl -s -o /dev/null -w "outsider spoof allowfix %{http_code}\n" -H 'Host: allowfix.lab.test' -H 'X-Forwarded-For: 10.251.31.210' http://10.251.31.250:8080/
docker exec lab-pwa-office curl -s -o /dev/null -w "office allowfix %{http_code}\n" -H 'Host: allowfix.lab.test' http://10.251.31.250:8080/
```

```text
outsider spoof allowfix 403
office allowfix 200
```

والقاعدة أن [set\_real\_ip\_from](https://nginx.org/en/docs/http/ngx%5Fhttp%5Frealip%5Fmodule.html?ref=arabroot.io) تكتب فيها عناوين ال Proxy التي تثق بها فقط، كعناوين Cloudflare أو موزع الحمل أمامك، وقد كسرنا انتحال العنوان من زاوية أخرى في [تجربة الطبقات](https://arabroot.io/articles/reverse-proxy-%D8%A7%D9%85-load-balancer-%D8%A7%D9%85-api-gateway/#lab-spoof).

### Forward Auth مع Authelia

نختبر هذه الطريقة من Container يحمل شهادة جهة الإصدار، ونستخدم الخيار `--connect-to` حتى تبقى أسماء النطاقات ومنفذ 18443 كما يراها المتصفح، والملف `fa.sh` كما يلي:

```bash
C="--connect-to ::10.251.31.250:8443 --cacert /certs/server-ca.crt"
echo "== no session"
curl -s $C -o /dev/null -w '%{http_code} %{redirect_url}\n' https://app.lab.test:18443/
for u in bob alice; do
  echo "== login $u"
  curl -s $C -c /tmp/$u.jar -H 'Content-Type: application/json' \
    -d "{\"username\":\"$u\",\"password\":\"Lab-Pass-2026\",\"keepMeLoggedIn\":false,\"targetURL\":\"https://app.lab.test:18443/\"}" \
    https://auth.lab.test:18443/api/firstfactor; echo
done
echo "== bob (group sales)"
curl -s $C -b /tmp/bob.jar -o /dev/null -w '%{http_code}\n' https://app.lab.test:18443/
echo "== alice (group ops)"
curl -s $C -b /tmp/alice.jar https://app.lab.test:18443/ | grep -E '^(GET|Remote-)'
```

```bash
docker run --rm --name lab-pwa-client --network lab-pwa-net -v "$PWD/certs:/certs:ro" -v "$PWD/fa.sh:/fa.sh:ro" --entrypoint sh curlimages/curl:8.11.1 /fa.sh
```

```text
== no session
302 https://auth.lab.test:18443/?rd=https%3A%2F%2Fapp.lab.test%3A18443%2F&rm=GET
== login bob
{"status":"OK","data":{"redirect":"https://app.lab.test:18443/"}}
== login alice
{"status":"OK","data":{"redirect":"https://app.lab.test:18443/"}}
== bob (group sales)
403
== alice (group ops)
GET / HTTP/1.1
Remote-Groups: ops
Remote-User: alice
```

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

- الطلب دون جلسة لم يصل إلى التطبيق، وإنما حوله nginx إلى بوابة Authelia ومعه عنوان العودة في المعامل `rd`.
- نجح دخول bob لأن كلمة مروره صحيحة، ولكن الصفحة رفضته بالرمز 403 لأنه ليس في مجموعة `ops`، وسجل Authelia يقول ذلك صراحة: `Access to 'https://app.lab.test:18443/' is forbidden to user 'bob'`، وهذا هو السجل الذي لا يعطيك إياه Basic Auth.
- وصل إلى التطبيق اسم alice ومجموعتها في الترويسات `Remote-User` و`Remote-Groups`، ولو أرسل bob هذه الترويسات بنفسه عبر nginx لاستبدلها `proxy_set_header` بالقيم التي جاءت من Authelia، وقد اختبرنا ذلك فبقي الرد 403.

### نكسرها: الطريق المباشر إلى منفذ التطبيق

كل ما سبق يحمي الطريق عبر nginx، أما المنفذ 18081 المنشور للتطبيق فلا يمر على أي شيء:

```bash
curl -s http://127.0.0.1:18081/ -H 'Remote-User: alice' -H 'Remote-Groups: ops,admins' | grep -E '^(GET|Host|Remote-)'
```

```text
GET / HTTP/1.1
Host: 127.0.0.1:18081
Remote-Groups: ops,admins
Remote-User: alice
```

لا يوجد هنا تسجيل دخول ولا Authelia، والتطبيق استلم اسم alice ومجموعة `admins` التي لا وجود لها أصلاً، فإذا كان التطبيق يثق بهذه الترويسات، كوضع Auth Proxy في Grafana، فقد دخل المهاجم مديراً. وعلى سيرفر حقيقي يكون المنفذ المنشور غالباً على كل العناوين وليس على 127.0.0.1، فيصل إليه أي أحد في الإنترنت.

### نكسرها: Container مجاور على الشبكة نفسها

وحتى دون منفذ منشور، فالغريب على شبكة `lab-pwa-net` نفسها، فيطلب التطبيق باسمه مباشرة:

```bash
docker exec lab-pwa-outsider curl -s -o /dev/null -w 'neighbour straight to app:80: %{http_code}\n' http://app:80/
```

```text
neighbour straight to app:80: 200
```

وهذا يحدث على أي سيرفر تضع فيه كل الخدمات على شبكة Docker واحدة، فأي Container مخترق فيها يصل إلى كل أداة إدارة دون حماية.

### نكسرها: فحص Cloudflare الذي يكتفي بوجود الترويسة

لا نملك هنا حساب Cloudflare، ولكن الخطأ نفسه يمكن أن نراه محلياً، فالخادم `cf.lab.test` يرفض الطلب إذا كانت ترويسة `Cf-Access-Jwt-Assertion` فارغة ويقبله إذا وجدت، وهو ما يكتبه البعض ظناً أنه يكفي:

```bash
docker exec lab-pwa-outsider curl -s -o /dev/null -w 'no header: %{http_code}\n' -H 'Host: cf.lab.test' http://10.251.31.250:8080/
docker exec lab-pwa-outsider curl -s -o /dev/null -w 'fake header: %{http_code}\n' -H 'Host: cf.lab.test' -H 'Cf-Access-Jwt-Assertion: anything' http://10.251.31.250:8080/
```

```text
no header: 403
fake header: 200
```

كلمة `anything` كانت كافية، والسبب أن وجود الترويسة لا يثبت شيئاً، وإنما التوقيع والحقل `aud` هما ما يثبت أن Access أصدرها لتطبيقك، لذلك تحقق منهما في التطبيق أو بخيار `access.required` في cloudflared كما في [قسم التحقق من التوكن](#cloudflare-origin)، والطلب الذي يصل من المنفذ المنشور في القسم السابق يتجاوز هذا الفحص كله، فهو لا يمر على nginx أصلاً.

### mTLS: بلا شهادة لا توجد صفحة

الملف `mtls.sh` يطلب الصفحة ثلاث مرات: دون شهادة، ثم بشهادة من جهة لا يثق بها السيرفر تحمل اسم alice نفسه، ثم بشهادة alice الحقيقية:

```bash
C="--connect-to ::10.251.31.250:8444 --cacert /certs/server-ca.crt"
echo "== no client certificate"
curl -s $C https://admin.lab.test:18444/ | grep -o '<title>.*</title>\|<center>.*</center>' | head -2
echo "== certificate from a CA the server does not trust"
curl -s $C --cert /certs/rogue.crt --key /certs/rogue.key https://admin.lab.test:18444/ | grep -o '<center>.*</center>' | head -2
echo "== alice's certificate"
curl -s $C --cert /certs/alice.crt --key /certs/alice.key https://admin.lab.test:18444/ | grep -E '^(GET|X-Client-)'
```

```text
== no client certificate
<title>400 No required SSL certificate was sent</title>
<center><h1>400 Bad Request</h1></center>
== certificate from a CA the server does not trust
<center><h1>400 Bad Request</h1></center>
<center>The SSL certificate error</center>
== alice's certificate
GET / HTTP/1.1
X-Client-Dn: O=ops,CN=alice
X-Client-Serial: 054652DB8B550FE5FE6C255AEF6B1F09FF7A5206
```

الشهادة المزورة تحمل الاسم نفسه `CN=alice`، ولكن nginx رفضها لأن من وقعها ليس جهة الإصدار التي يثق بها، وهذا الفرق بين الشهادة وكلمة المرور، فالاسم وحده لا يكفي. أما شهادة alice فوصلت ومعها اسمها ورقمها التسلسلي Serial إلى التطبيق.

### مغادرة alice: إلغاء الشهادة

عندما تغادر alice يجب أن تلغي شهادتها، لأنها صالحة 30 يوماً ولا يمكن أن تسحبها من جهازها، لذلك نسجلها ملغاة في جهة الإصدار ونولد قائمة الإلغاء CRL:

```bash
docker run --rm -v "$PWD/certs:/certs" -w /certs alpine:3.24.2 sh -c '
apk add -q openssl >/dev/null
mkdir -p ca && touch ca/index.txt && echo 1000 > ca/crlnumber
cat > ca/ca.cnf <<EOF
[ ca ]
default_ca = lab
[ lab ]
database = /certs/ca/index.txt
crlnumber = /certs/ca/crlnumber
certificate = /certs/clients-ca.crt
private_key = /certs/clients-ca.key
default_md = sha256
default_crl_days = 7
EOF
openssl ca -config ca/ca.cnf -revoke alice.crt 2>&1 | tail -2
openssl ca -config ca/ca.cnf -gencrl -out clients.crl 2>&1 | tail -1
openssl crl -in clients.crl -noout -text | grep -E "Serial Number|Revocation Date|Next Update"'
```

```text
Revoking Certificate 054652DB8B550FE5FE6C255AEF6B1F09FF7A5206.
Database updated
Using configuration from ca/ca.cnf
        Next Update: Oct 19 06:13:40 2026 GMT
    Serial Number: 054652DB8B550FE5FE6C255AEF6B1F09FF7A5206
        Revocation Date: Oct 12 06:13:39 2026 GMT
```

ثم نضيف إلى خادم `admin.lab.test` السطر `ssl_crl /etc/nginx/certs/clients.crl;` بعد `ssl_verify_client`، ونعيد تحميل nginx ونطلب الصفحة بشهادة alice:

```bash
docker exec lab-pwa-nginx nginx -t && docker exec lab-pwa-nginx nginx -s reload
docker run --rm --name lab-pwa-client --network lab-pwa-net -v "$PWD/certs:/certs:ro" --entrypoint sh curlimages/curl:8.11.1 -c 'curl -s --connect-to ::10.251.31.250:8444 --cacert /certs/server-ca.crt --cert /certs/alice.crt --key /certs/alice.key https://admin.lab.test:18444/ | grep -o "<title>.*</title>\|<center>.*</center>"'
```

```text
<title>400 The SSL certificate error</title>
<center><h1>400 Bad Request</h1></center>
<center>The SSL certificate error</center>
<center>nginx/1.31.6</center>
```

لاحظ السطر `Next Update` في قائمة الإلغاء، فالقائمة لها تاريخ انتهاء هو 7 أيام هنا، فماذا يحدث إذا لم تجددها؟ أصدرنا شهادة سليمة ثالثة باسم carol، ثم ولدنا قائمة إلغاء تنتهي بعد ثانيتين بالخيار `-crlsec 2`، وأعدنا تحميل nginx بعد انتهائها:

```bash
openssl ca -config ca/ca.cnf -gencrl -crlsec 2 -out clients.crl
openssl crl -in clients.crl -noout -nextupdate
# بعد ثوان: nginx -s reload ثم الطلب بشهادة carol
curl -s --connect-to ::10.251.31.250:8444 --cacert /certs/server-ca.crt --cert /certs/carol.crt --key /certs/carol.key https://admin.lab.test:18444/ | grep -o "<title>.*</title>"
```

```text
nextUpdate=Oct 12 06:37:57 2026 GMT
<title>400 The SSL certificate error</title>
```

رفض nginx شهادة carol مع أنها غير ملغاة، والسبب أن القائمة نفسها انتهت، وعندما أعدنا القائمة الصالحة وصلت carol إلى الصفحة ومعها `X-Client-Dn: O=ops,CN=carol`. أي أن قائمة الإلغاء المنسية لا تفتح الباب، وإنما تغلقه على الجميع في يوم لا تتوقعه، لذلك اجعل تجديدها مهمة دورية تراقبها، وهذا جزء من ثمن mTLS الذي ذكرناه في [قسمها](#mtls).

### الإصلاح: لا منفذ، وشبكة داخلية لا يشاركها إلا nginx

نحذف المنفذ المنشور، ونضع التطبيق في شبكة داخلية `internal: true` لا يدخلها إلا nginx، والفرق في ملف Compose كما يلي:

```diff
   app:
     image: traefik/whoami:v1.12.0
     container_name: lab-pwa-app
-    networks: [lab]
-    ports:
-      - "127.0.0.1:18081:80"   # deliberate mistake for the bypass steps
+    networks: [backend]          # no ports, and only the proxy shares this network
   nginx:
 ...
     networks:
       lab: { ipv4_address: 10.251.31.250 }
+      backend: {}
 ...
 networks:
+  backend:
+    name: lab-pwa-backend
+    internal: true
   lab:
     name: lab-pwa-net
```

```bash
docker compose up -d
curl -sS -m 5 http://127.0.0.1:18081/
docker exec lab-pwa-outsider curl -sS -m 5 http://app:80/
docker exec lab-pwa-office curl -s -o /dev/null -w 'office via proxy allow: %{http_code}\n' -H 'Host: allow.lab.test' http://10.251.31.250:8080/
```

```text
curl: (7) Failed to connect to 127.0.0.1 port 18081 after 2022 ms: Could not connect to server
curl: (6) Could not resolve host: app
office via proxy allow: 200
```

المنفذ لم يعد موجوداً، والغريب لا يعرف اسم التطبيق أصلاً لأنه ليس على شبكته، أما الطريق عبر nginx فيعمل كما كان، وأعدنا تشغيل `fa.sh` فوصلت alice بترويساتها كما في المرة الأولى. وهذا هو الشكل الذي يجب أن يكون عليه أي تطبيق خلف Forward Auth أو Cloudflare Tunnel.

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

نحذف ما أنشأناه فقط بالاسم، ولا نستخدم أوامر prune لأنها تحذف ما يخص مشاريع أخرى على الجهاز:

```bash
docker compose -p lab-pwa down
docker volume rm lab-pwa-authelia-data
```

## أخطاء شائعة

- **Basic Auth عبر HTTP:** كلمة المرور تقرأ من الشبكة كما رأينا، فلا تستخدمه إلا عبر HTTPS.
- **كلمة مرور واحدة للفريق كله:** لا تعرف من دخل، ولا تستطيع أن تخرج شخصاً واحداً، فاجعل لكل شخص اسم مستخدم خاصاً به على الأقل.
- **الثقة بترويسة X-Forwarded-For من أي أحد:** تكسر قائمة العناوين وتخدع السجل، فاكتب في `set_real_ip_from` عناوين ال Proxy الحقيقية فقط.
- **منفذ منشور بجانب Forward Auth أو Tunnel:** الطريق المباشر يتجاوز كل شيء، فلا تنشر منفذ التطبيق، وتحقق بالأمر `docker ps`.
- **التطبيق على شبكة مشتركة مع كل الخدمات:** أي Container مجاور يصل إليه، فضعه في شبكة داخلية مع ال Proxy وحده.
- **التحقق من وجود ترويسة Cloudflare بدل توقيعها:** أي أحد يكتبها، فتحقق من التوقيع والحقل `aud`.
- **نسيان المغادرين:** في Basic Auth ملف، وفي mTLS قائمة إلغاء، وفي Cloudflare مقعد وحساب في مزود الهوية، فاكتب لكل طريقة خطوات المغادرة قبل أن تحتاجها.

## الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

- رتب الطرق من الأبسط إلى الأقوى: عدم النشر، وقائمة العناوين، وBasic Auth، وVPN، وForward Auth، وCloudflare Access، وmTLS، وتسجيل الدخول في التطبيق، واختر أدنى درجة تحقق ما يحتاجه المستخدمون، ثم أضف طبقة من نوع مختلف.
- Basic Auth يرسل `user:password` بترميز Base64 مع كل طلب، ولا يعرف الجلسات ولا الخروج ولا MFA، فلا يصلح إلا عبر HTTPS لعدد قليل من الأشخاص، مع هاش bcrypt وحد لمعدل الطلبات.
- قائمة العناوين تقلل من يطرق الباب، ولكنها لا تعرف الشخص، وتفشل مع CGNAT والعناوين المتغيرة والثقة الخاطئة بالترويسات.
- Forward Auth وCloudflare Access وIAP كلها تحمي الطريق الذي يمر بها فقط، فلا تنشر منفذ التطبيق، واعزله في شبكة داخلية، وتحقق من التوكن الموقع لا من وجود الترويسة.
- الخطة المجانية في Cloudflare Access حتى 50 مستخدماً مع سجلات 24 ساعة، ثم 7 دولارات للمستخدم شهرياً، بحسب صفحة الخدمة في 12 أكتوبر 2026.
- mTLS قوية للأجهزة والخدمات، ولكنها تحتاج إلى جهة إصدار وقائمة إلغاء تجددها، وتسجيل الدخول عبر OIDC في التطبيق هو الطبقة الوحيدة التي تعرف صلاحيات كل مستخدم داخل الأداة.

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

- أكتوبر 2026: كتابة الدليل، والتجربة العملية على nginx 1.31.6 وAuthelia 4.39.28 مع Docker Engine 29.8.0، ومراجعة أسعار Cloudflare Access وحدودها في 12 أكتوبر 2026.