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

والدرجة الأعلى ليست بالضرورة الأنسب لك، فأداة يستخدمها شخص واحد من جهازه تكفيها الدرجة الأولى، وأداة يستخدمها مئة موظف من أجهزة مختلفة تحتاج إلى الخامسة أو السادسة أو الثامنة، وغالباً ما تجمع بين درجتين أو ثلاث كما سيأتي في قسم الجمع بين الطبقات.
الدرجة الأولى: لا تنشر الأداة أصلاً
أقوى حماية لأداة لا يحتاجها إلا أنت أن لا يصل إليها أحد من الشبكة، أي أن تستمع على العنوان 127.0.0.1 فقط أو على عنوان الشبكة الخاصة، وفي Docker يعني ذلك أن تكتب المنفذ بالشكل "127.0.0.1:8080:80" بدل "8080:80"، والسبب أن الشكل الثاني ينشر المنفذ على كل عناوين السيرفر، وDocker يضيف قواعده في iptables قبل قواعد ufw، فلا يمنعه جدار الحماية Firewall الذي تظن أنه يحميك، كما شرحنا في شبكات Docker وأفضل الممارسات. وعندما تحتاج إلى فتح الأداة من جهازك تستخدم نفق SSH بالأمر ssh -L كما في التوجيه المحلي في دليل الأنفاق، فتمر على مصادقة SSH بمفتاحك، ولا يبقى على الإنترنت أي باب جديد.
ولكن انتبه إلى أن «غير منشورة» لا تعني «معزولة»، فكل Container على شبكة Docker نفسها يستطيع أن يطلب الأداة باسمها مباشرة دون أن يمر على أي حماية، وهذا ما سوف نراه في التجربة العملية، والحل أن تضع الأداة في شبكة لا يشاركها فيها إلا ال Reverse Proxy، وهو نمط الشبكة المشتركة في دليل شبكات Docker.
متى تكفي: أداة لشخص أو شخصين يدخلان عبر SSH، أو خدمة لا يطلبها إلا تطبيق آخر على السيرفر نفسه. متى لا تكفي: عندما يحتاجها فريق من المتصفح دون أن يعرف كل واحد منهم SSH.
الدرجة الثانية: قائمة عناوين IP المسموحة
في هذه الدرجة يرد ال Reverse Proxy بالرمز 403 على أي عنوان ليس في القائمة، وقد شرحنا الإعداد في NPM وTraefik وCaddy وHAProxy في قصر لوحات الإدارة على عناوين محددة، فلا نكرره هنا. والقائمة مفيدة فعلاً لشبكة المكتب أو لشبكة VPN، ولكنها تفشل في ثلاث حالات:
- العنوان المشترك CGNAT: كثير من شركات الاتصالات تضع عملاءها خلف عنوان عام واحد من خلال النطاق المشترك Shared Address Space
100.64.0.0/10، فإذا سمحت لعنوان بيت الموظف فقد تسمح معه لمئات من الغرباء الذين يخرجون من العنوان نفسه. - العنوان المتغير: عنوان الجوال والاتصال المنزلي يتغير، فيتصل بك الموظف المسافر كل يوم لتضيف عنوانه الجديد، ويبقى عنوانه القديم في القائمة لأن أحداً لا ينظفها.
- العنوان الذي يراه ال Proxy خاطئ: إذا وثق ال Reverse Proxy بترويسة
X-Forwarded-Forمن أي أحد، فيستطيع المهاجم أن يكتب فيها عنواناً مسموحاً ويدخل، وهذا ما سوف نكسره في التجربة. وقد شرحنا أي عنوان تختار من هذه الترويسة في تطبيقك خلف Reverse Proxy.
والقائمة لا تعرف من هو الشخص، فكل من يجلس في المكتب يدخل، ولا يوجد سجل يقول من فتح الأداة، لذلك اعتبرها طبقة إضافية تقلل من يطرق الباب، وليست الباب نفسه.
الدرجة الثالثة: HTTP Basic Auth بالتفصيل
هذه أشهر طريقة وأسرعها، فسطران في Nginx أو Caddy أو قائمة وصول Access List في NPM تكفي لتظهر للزائر نافذة اسم المستخدم وكلمة المرور، ولأن المواقع تستخدمها كثيراً دون أن تفهم ما يحدث فيها، سوف نشرحها هنا بالتفصيل كمرجع للموقع، ونربط إليه من الأدلة الأخرى.
كيف يعمل Basic Auth؟
المعيار هو RFC 7617، وهو يصف تبادلاً من أربع خطوات: يطلب المتصفح الصفحة دون بيانات، فيرد السيرفر بالرمز 401 Unauthorized ومعه ترويسة WWW-Authenticate فيها اسم المخطط Basic والمعامل realm الإلزامي، وهو اسم «منطقة الحماية» الذي يظهر أحياناً في نافذة المتصفح، ويمكن أن يضاف معامل اختياري charset="UTF-8" ليخبر المتصفح بترميز الحروف. عندها يسأل المتصفح المستخدم، ثم يجمع اسم المستخدم ونقطتين رأسيتين وكلمة المرور في نص واحد user:password، ويرمزه بترميز Base64، ويرسله في ترويسة Authorization: Basic …. والصورة التالية تبين هذا التبادل بالقيم الحقيقية من تجربتنا:

Base64 ترميز وليس تشفيراً
الترميز Encoding يحول البايتات إلى حروف يمكن نقلها، ويفكه أي أحد دون مفتاح، كما شرحنا في الترميز ليس تشفيراً وليس هاش، لذلك يقول المعيار نفسه إن أخطر عيب في Basic Auth أنه ينقل كلمة المرور نصاً واضحاً عبر الشبكة، وإنه لا ينبغي أن يستخدم لحماية معلومات حساسة دون HTTPS. وفي التجربة سوف تلتقط الترويسة من الشبكة وتفكها بأمر واحد.
يرسل مع كل طلب، ولا يعرف الجلسات
بعد أول نجاح يحتفظ المتصفح باسم المستخدم وكلمة المرور، ويرسلهما تلقائياً مع كل طلب إلى المنطقة نفسها، فالمعيار يسمح له أن يفترض أن كل رابط يبدأ بالمسار المحمي داخل المنطقة نفسها، وهذا يعني عدة أمور:
- لا توجد جلسة Session ولا تسجيل خروج Logout: لا يحدد المعيار طريقة لإنهاء الدخول، فالمتصفح يرسل كلمة المرور حتى تغلقه أو تمسح بياناته، ولا يستطيع المسؤول أن ينهي دخول شخص معين، والطريقة الوحيدة أن يغير كلمة المرور.
- هجمات CSRF: بحسب توثيق MDN فالمواقع التي تستخدم Basic Auth معرضة بشكل خاص لهجمات تزوير الطلبات Cross-Site Request Forgery، والسبب أن المتصفح يرسل بيانات الدخول مع كل طلب، حتى الطلب الذي بدأته صفحة أخرى.
- يتحقق السيرفر من الهاش في كل طلب: إذا خزنت كلمة المرور بهاش بطيء مثل bcrypt، وهذا هو الصواب، فإن السيرفر يدفع ثمن هذا الحساب مع كل طلب وكل ملف CSS أو صورة في الصفحة، وسوف نقيس ذلك في التجربة.
ملف htpasswd: أي هاش تختار؟
يقرأ Nginx أسماء المستخدمين من ملف بصيغة name:hash عبر التوجيه auth_basic_user_file، ويقبل الهاش الذي تنتجه الدالة crypt() في النظام، أو صيغة MD5 الخاصة بـ Apache واسمها apr1، أو الصيغ {PLAIN} و{SHA} و{SSHA}، والتوثيق نفسه يقول إن الأوليين لا ينبغي أن تستخدما. والأداة المعتادة لإنشاء الملف هي htpasswd من Apache، ولاحظ أن خيارها الافتراضي منذ الإصدار 2.2.18 هو MD5، أما bcrypt فتطلبه بالخيار -B، ومعه الخيار -C لتكلفة الحساب Cost، وقيمته الافتراضية 5 وتقبل من 4 إلى 17:
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"'والمخرج سوف يكون كما يلي:
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، والتوجيه نفسه في حماية مسار الإدارة بكلمة مرور. وفي NPM تضيف المستخدمين من قائمة الوصول كما في دليل NPM. - لا تضع ملف htpasswd في مستودع Git، فالهاش البطيء يؤخر كسر كلمة المرور ولا يمنعه.
التخمين وMFA
لا يوجد في Basic Auth قفل للحساب بعد محاولات فاشلة، ولا مصادقة ثنائية، فالسيرفر يرد بالرمز 401 على كل محاولة خاطئة ويستمر في الرد إلى الأبد، لذلك إذا كانت الصفحة على الإنترنت فأضف أمامها حداً لمعدل الطلبات Rate Limiting وحظراً تلقائياً بـ fail2ban على أسطر 401 في السجل، وقد شرحنا الاثنين في الحد من معدل الطلبات وfail2ban على سجلات ال Reverse Proxy، وشرحنا الخوارزميات نفسها في تحديد معدل الطلبات Rate Limiting.
متى يكفي Basic Auth؟
يكفي عندما تجتمع ثلاثة شروط: الاتصال عبر HTTPS دائماً، وعدد قليل من الأشخاص لكل منهم اسم مستخدم خاص به وليس كلمة مرور مشتركة، وأداة لا يضر كثيراً أن يدخلها شخص بكلمة مرور مسروقة، كبيئة اختبار Staging أو صفحة إحصائيات. ولا يكفي عندما تحتاج إلى MFA، أو إلى معرفة من دخل، أو عندما يتغير الفريق كثيراً، والسبب أن كل موظف يغادر يعني أن تعدل ملفاً على كل سيرفر.
Authorization إلى التطبيق (Pass Auth to Upstream في NPM) إلا إذا كان التطبيق يحتاجها، والسبب أن التطبيق يستلم عندها كلمة المرور نفسها مع كل طلب، فقد تظهر في سجلاته أو في أي أداة تراقب الطلبات.الدرجة الرابعة: VPN أو شبكة Mesh
هنا لا تنشر الأداة على الإنترنت أبداً، وإنما تبقى على عنوان خاص، ولا يصل إليها إلا جهاز داخل شبكة VPN يملك مفتاحاً، فالمهاجم لا يرى صفحة دخول ليجرب عليها كلمات المرور أصلاً. وقد شرحنا الفكرة والمصطلحات في ما هو VPN؟، والإعداد العملي في شبكة خاصة مع WireGuard وwg-easy، وأدوات Mesh مثل Headscale وNetBird في أدوات الوصول الآمن إلى السيرفرات.
ونقطة ضعف VPN أن من دخل الشبكة يصل غالباً إلى كل ما فيها، وأن الجهاز هو الذي يحمل المفتاح وليس الشخص، فإذا سرق حاسوب الموظف دخل السارق، لذلك اربط تسجيل الأجهزة بمزود هوية مع MFA إن كانت الأداة تسمح، وقيد بقواعد ACL ما يصل إليه كل جهاز. وفي شبكات Tailscale تحديداً تستطيع أن تعرض الأداة لأجهزة الشبكة فقط بالأمر Tailscale Serve، وهو يضيف إلى الطلب ترويسات فيها هوية المستخدم مثل Tailscale-User-Login، ولكن التوثيق نفسه ينصح بأن تستمع الخدمة على localhost فقط إذا اعتمدت على هذه الترويسات، والسبب أن من يصل إلى الخدمة مباشرة يستطيع أن يكتب هذه الترويسات بنفسه.
الدرجة الخامسة: Proxy يعرف الهوية عبر Forward Auth
في هذه الدرجة يسأل ال Reverse Proxy مزود الهوية Identity Provider قبل كل طلب: هل لهذا المستخدم جلسة صالحة، وهل يحق له فتح هذه الأداة؟ فإذا لم يكن له جلسة حوله إلى صفحة دخول فيها MFA وسياسات المجموعات، وبعد الدخول يمرر الطلب إلى الأداة ومعه اسم المستخدم ومجموعاته في ترويسات. وقد شرحنا هذا بالتفصيل مع authentik، ومعه مخطط للمكونات وخطوات الطلب الخمس، في كيف يعمل Forward Auth؟، وقارنا أدوات هذه العائلة في ال Proxy الذي يعرف الهوية. وباختصار:
- authentik: مزود هوية كامل مع outpost مدمج يجيب عن طلبات Forward Auth، ونواته برخصة MIT، وهو الأنسب إذا كان عندك أو ستحتاج إلى OIDC لتطبيقات أخرى.
- Authelia: خدمة واحدة خفيفة برخصة Apache-2.0، تقرأ المستخدمين من ملف YAML أو من LDAP، وتجيب عن Nginx عبر نقطة
/api/authz/auth-requestكما في توثيق Authelia مع Nginx، وآخر إصدار منها وقت الكتابة v4.39.28، وهي التي استخدمناها في التجربة. - OAuth2 Proxy: برخصة MIT، لا يحفظ المستخدمين وإنما يرسلهم إلى مزود OIDC موجود مثل Google أو GitHub أو Keycloak، وآخر إصدار منه v7.15.5.
- Pomerium: Reverse Proxy مستقل برخصة Apache-2.0 يطبق سياسة لكل مسار، وآخر إصدار منه v0.33.4.
والخطر الأكبر هنا ليس في الأداة وإنما في الطريق، فالفحص لا يحدث إلا للطلبات التي تمر على ال Reverse Proxy، فإذا كان للتطبيق منفذ منشور أو كان على شبكة يصل إليها غيره، فسوف يتجاوز الطلب صفحة الدخول كلها، والأسوأ أن التطبيق الذي يثق بترويسة Remote-User سوف يقبل اسم أي مستخدم يكتبه المهاجم فيها، وهذا ما سوف نراه في التجربة.
الدرجة السادسة: Cloudflare Access أمام Cloudflare Tunnel
هنا تنتقل صفحة الدخول من سيرفرك إلى شبكة Cloudflare، فالبرنامج cloudflared على سيرفرك يفتح اتصالاً خارجاً إلى Cloudflare، فلا تحتاج إلى أي منفذ مفتوح، وقد شرحنا ذلك في Cloudflare Tunnel وتشغيل خادمك من إنترنت المنزل، ثم تضيف أمام اسم النطاق تطبيقاً في Cloudflare Access فيه سياسة تحدد من يدخل، فيسجل المستخدم دخوله عبر Google أو Microsoft أو أي مزود SAML أو OIDC قبل أن يصل أي طلب إلى النفق، كما ذكرنا في Zero Trust Access. والصورة التالية تبين الطريق الصحيح، والطريقين اللذين يتجاوزانه:

الخطة المجانية وحدودها
راجعنا صفحة Cloudflare Access في 12 أكتوبر 2026، وفيها ثلاث خطط: المجانية Free بسعر 0 دولار ومعها حد 50 مستخدماً، وهي بحسب الصفحة للفرق الأقل من 50 مستخدماً أو لتجارب الشركات، ثم Pay-as-you-go بسعر 7 دولارات لكل مستخدم في الشهر تدفع سنوياً ودون حد للمستخدمين، ثم Contract بسعر تتفق عليه مع المبيعات. ومدة حفظ السجلات Log Retention في جدول المقارنة نفسه 24 ساعة في المجانية، و30 يوماً في Pay-as-you-go، وحتى 6 أشهر في Contract. وهذه الأرقام تتغير، فراجعها قبل أن تبني عليها قرارك.
والمستخدم هنا مقعد Seat، فبحسب توثيق إدارة المقاعد يأخذ المستخدم مقعداً بمجرد أن يسجل دخوله أول مرة، ويبقى له حتى تحذفه، وعندما تمتلئ المقاعد يمنع أي مستخدم جديد من الدخول. وعند مغادرة موظف لاحظ أن الأمرين مختلفان: إلغاء المستخدم Revoke ينهي جلساته الحالية ولكنه لا يحرر مقعده، وحذفه Remove يحرر المقعد ولكنه لا يمنعه من الدخول مرة أخرى، لذلك يجب أن تعطل حسابه في مزود الهوية نفسه أو تخرجه من السياسة.
لا تثق بالطلب لأنه وصل، تحقق من التوكن
عندما يمرر Cloudflare الطلب إلى سيرفرك يضيف إليه توكن JWT في الترويسة Cf-Access-Jwt-Assertion، ويضيفه للمتصفحات أيضاً في Cookie اسمه CF_Authorization، وبحسب توثيق التحقق من JWT يجب أن تتحقق من توقيع التوكن بالمفتاح العام الخاص بحسابك ومن الحقل aud الذي يحدد التطبيق، والسبب أن هذا هو ما يثبت أن الطلب جاء من Access وليس من طرف آخر. ولا يكفي أن تتأكد أن الترويسة موجودة، فأي أحد يستطيع أن يكتبها، وهذا ما سوف نكسره في التجربة. وأسهل طريقة مع Tunnel أن تطلب من cloudflared نفسه أن يتحقق قبل أن يمرر الطلب، عبر الإعداد access في originRequest:
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 مع سيرفرك. وتذكر أيضاً أن Cloudflare يرى كل ما يمر عبره، كما ناقشنا في الخصوصية والسيادة على البيانات.
ما يقابله عند الآخرين
- Tailscale Funnel: بحسب توثيق Funnel ينشر خدمة من جهاز في الشبكة إلى الإنترنت كله دون أن يحتاج الزائر إلى Tailscale، وهو متاح في كل الخطط، ولا يستمع إلا على المنافذ 443 و8443 و10000. ولكنه ليس صفحة دخول، فطلبات Funnel لا تحمل ترويسات الهوية التي يضيفها Serve، أي أن الخدمة المنشورة به مفتوحة لكل أحد ما لم تحمها أنت.
- Google Identity-Aware Proxy: بحسب توثيق IAP يضع طبقة تصريح مركزية أمام التطبيقات التي تعمل عبر HTTPS في Google Cloud، ويحذر التوثيق نفسه من أن من يصل إلى منفذ التطبيق على الآلة الافتراضية يتجاوز IAP، لذلك يطلب منك أن تتحقق من التوكن الموقع في الترويسة
x-goog-iap-jwt-assertionكما في توثيق الترويسات الموقعة. وتقول صفحة الأسعار إن IAP بلا رسوم للموارد المستضافة في Google Cloud، مع رسوم موزع الحمل المطلوب، أما حماية موارد خارج Google Cloud فمن ميزات Chrome Enterprise Premium المدفوعة. - Microsoft Entra application proxy: بحسب توثيق Microsoft ينشر تطبيقات الويب الداخلية بعد دخول المستخدم في Microsoft Entra ID، عبر برنامج Connector على Windows Server في شبكتك لا يستخدم إلا اتصالات خارجة، فلا تفتح أي منفذ داخل. ويشترط اشتراك Microsoft Entra ID P1 أو P2.
الدرجة السابعة: شهادات العميل mTLS
في TLS العادي يثبت السيرفر هويته بشهادة، أما في TLS المتبادل Mutual TLS فيطلب السيرفر من العميل أيضاً شهادة صادرة من جهة إصدار Certificate Authority يثق بها، فإذا لم يقدمها العميل انتهى الاتصال قبل أن يصل أي طلب HTTP إلى التطبيق، وبالتالي لا توجد صفحة دخول يمكن تخمين كلمة المرور فيها. وقد شرحنا الشهادات وسلسلة الثقة وصيغها في الشهادات الرقمية TLS وX.509، وفي Nginx يكفي التوجيهان ssl_client_certificate وssl_verify_client، ويضاف إليهما ssl_crl لقائمة الشهادات الملغاة.
ونقطة ضعف mTLS أنها صعبة على الناس، فيجب أن تصدر شهادة لكل جهاز، وتسلمها بأمان في ملف PKCS#12 كما في قسم PKCS#12، وتثبتها في المتصفح أو الجوال، وتلغيها عند مغادرة الموظف وتحدث قائمة الإلغاء على السيرفر، ولذلك تناسب mTLS الأجهزة والخدمات أكثر من البشر، كجهاز في فرع يرسل بيانات إلى لوحة مركزية، أو تطبيق يستدعي واجهة API داخلية.
الدرجة الثامنة: تسجيل الدخول داخل التطبيق عبر OIDC
الدرجات السابقة كلها تقف أمام التطبيق، أما هنا فالتطبيق نفسه يعرف المستخدم، فيرسله إلى مزود الهوية عبر OpenID Connect، ويستلم منه من يكون وما مجموعاته، ثم يدير صلاحياته Permissions بنفسه: من يقرأ، ومن يعدل، ومن هو المدير. وهذا ما لا تستطيع أي طبقة أمامية أن تفعله، فال Proxy يقرر من يفتح الباب، ولكنه لا يعرف أن هذا المستخدم يحق له أن يرى الفواتير ولا يحق له أن يحذفها. وقد شرحنا البروتوكول في شرح OAuth وOpenID Connect، والجلسات التي ينشئها التطبيق بعد الدخول في الجلسات وملفات تعريف الارتباط وJWT، ومثالاً كاملاً في ربط Gitea مع authentik عبر 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 بين الطبقات؟.
- Tunnel ثم Access ثم تسجيل الدخول في التطبيق: لا يوجد منفذ مفتوح، ولا يصل إلى التطبيق إلا من أثبت هويته في Cloudflare، ثم يحدد التطبيق صلاحياته، وإذا أخطأت يوماً في سياسة Access بقي تسجيل الدخول في التطبيق خلفها.
- mTLS ثم Basic Auth أو Forward Auth: الشهادة تثبت أن الجهاز للشركة، وكلمة المرور أو MFA تثبت أن الشخص هو صاحب الحساب.
- أي طبقة مع شبكة داخلية للتطبيق: مهما كانت الطبقة الأمامية، يجب أن لا يصل إلى التطبيق إلا ال Proxy، وإلا كانت كل الطبقات السابقة زينة.
تجربة عملية: صفحة واحدة بخمس طرق حماية
سوف نشغل صفحة إدارة تجريبية هي traefik/whoami، والسبب أنها تعرض كل ترويسة تصلها فنرى بأعيننا ما يمرره ال Proxy، ونضع أمامها nginx بخمسة أسماء نطاقات تمثل خمس طرق: Basic Auth عبر HTTP، وقائمة عناوين بإعداد خاطئ ثم صحيح، وفحص يشبه ما يفعله البعض مع Cloudflare، وForward Auth مع Authelia، وmTLS. ومعها جهازان بعنوانين ثابتين، واحد يمثل المكتب والآخر يمثل الغريب، وContainer ثالث يلتقط حركة الشبكة. والصورة التالية تبين المكونات:

وقد شغلنا التجربة على 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 في أول التجربة، وفيه خطأ متعمد هو المنفذ المنشور للتطبيق، وسوف نصلحه في آخر التجربة:
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 لكل طريقة، ويفرق بينها اسم النطاق:
# 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 بتكلفة 12. أما Authelia فملف إعداده authelia/configuration.yml كما يلي، وفيه قاعدة واحدة تسمح لمجموعة ops فقط بفتح app.lab.test بعامل واحد One Factor، والأسرار فيه للتجربة فقط:
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 ولدناه بالأمر التالي:
docker run --rm authelia/authelia:4.39.28 authelia crypto hash generate argon2 --password 'Lab-Pass-2026'Digest: $argon2id$v=19$m=65536,t=3,p=4$xKwSr+vfXPgAABfGBd3xDQ$oYisOkoRl9sidXhojuPFs+YETIkALbGbl29+Krv/bxcusers:
alice:
displayname: "Alice"
password: "$argon2id$v=19$m=65536,t=3,p=4$xKwSr+vfXPgAABfGBd3xDQ$oYisOkoRl9sidXhojuPFs+YETIkALbGbl29+Krv/bxc"
email: [email protected]
groups: [ops]
bob:
displayname: "Bob"
password: "$argon2id$v=19$m=65536,t=3,p=4$xKwSr+vfXPgAABfGBd3xDQ$oYisOkoRl9sidXhojuPFs+YETIkALbGbl29+Krv/bxc"
email: [email protected]
groups: [sales]وأخيراً الشهادات، فنحتاج إلى جهة إصدار للسيرفر، وجهة إصدار ثانية لشهادات العملاء مع شهادة لـ alice، وجهة ثالثة لا يثق بها السيرفر مع شهادة مزورة تحمل الاسم نفسه، ونولدها كلها في Container مؤقت:
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'التشغيل
docker compose up -d
docker ps --filter name=lab-pwa --format '{{.Names}}\t{{.Status}}\t{{.Ports}}'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
نطلب الصفحة أولاً من جهاز المكتب دون بيانات دخول، لنرى رد السيرفر:
docker exec lab-pwa-office curl -s -i -H 'Host: basic.lab.test' http://10.251.31.250:8080/ | head -8HTTP/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 لثماني ثوان، ونسجل الدخول من المكتب بكلمة المرور الصحيحة، ثم نبحث في الالتقاط:
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'والمخرج سوف يكون كما يلي:
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 شيئاً.
نكسرها: التخمين لا يتوقف
نجرب من جهاز الغريب عشر كلمات مرور متتالية، آخرها الصحيحة:
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 '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 في دليل حماية تطبيقات الويب ليحظر العنوان.
ثمن bcrypt في كل طلب
لاحظ أن عشر محاولات أخذت 11 ثانية، والسبب ليس الشبكة وإنما هاش bcrypt بتكلفة 12 الذي يحسبه nginx مع كل طلب. نقارن ثلاثة طلبات صحيحة إلى الصفحة المحمية بثلاثة طلبات إلى الصفحة نفسها خلف قائمة العناوين، أي دون أي هاش:
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'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
نبدأ بالحالة العادية، فالمكتب مسموح والغريب ممنوع، ثم يرسل الغريب الطلب نفسه ومعه ترويسة يكتب فيها عنوان المكتب:
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 -20office 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. والأسوأ أن السجل نفسه تم خداعه، فهذه أسطره:
docker logs lab-pwa-nginx 2>&1 | grep -E '"GET / HTTP/1.1" (200|403)' | grep -v ops | tail -510.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:
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/outsider spoof allowfix 403
office allowfix 200والقاعدة أن set_real_ip_from تكتب فيها عناوين ال Proxy التي تثق بها فقط، كعناوين Cloudflare أو موزع الحمل أمامك، وقد كسرنا انتحال العنوان من زاوية أخرى في تجربة الطبقات.
Forward Auth مع Authelia
نختبر هذه الطريقة من Container يحمل شهادة جهة الإصدار، ونستخدم الخيار --connect-to حتى تبقى أسماء النطاقات ومنفذ 18443 كما يراها المتصفح، والملف fa.sh كما يلي:
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-)'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== 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 المنشور للتطبيق فلا يمر على أي شيء:
curl -s http://127.0.0.1:18081/ -H 'Remote-User: alice' -H 'Remote-Groups: ops,admins' | grep -E '^(GET|Host|Remote-)'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 نفسها، فيطلب التطبيق باسمه مباشرة:
docker exec lab-pwa-outsider curl -s -o /dev/null -w 'neighbour straight to app:80: %{http_code}\n' http://app:80/neighbour straight to app:80: 200وهذا يحدث على أي سيرفر تضع فيه كل الخدمات على شبكة Docker واحدة، فأي Container مخترق فيها يصل إلى كل أداة إدارة دون حماية.
نكسرها: فحص Cloudflare الذي يكتفي بوجود الترويسة
لا نملك هنا حساب Cloudflare، ولكن الخطأ نفسه يمكن أن نراه محلياً، فالخادم cf.lab.test يرفض الطلب إذا كانت ترويسة Cf-Access-Jwt-Assertion فارغة ويقبله إذا وجدت، وهو ما يكتبه البعض ظناً أنه يكفي:
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/no header: 403
fake header: 200كلمة anything كانت كافية، والسبب أن وجود الترويسة لا يثبت شيئاً، وإنما التوقيع والحقل aud هما ما يثبت أن Access أصدرها لتطبيقك، لذلك تحقق منهما في التطبيق أو بخيار access.required في cloudflared كما في قسم التحقق من التوكن، والطلب الذي يصل من المنفذ المنشور في القسم السابق يتجاوز هذا الفحص كله، فهو لا يمر على nginx أصلاً.
mTLS: بلا شهادة لا توجد صفحة
الملف mtls.sh يطلب الصفحة ثلاث مرات: دون شهادة، ثم بشهادة من جهة لا يثق بها السيرفر تحمل اسم alice نفسه، ثم بشهادة alice الحقيقية:
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-)'== 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:
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"'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:
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>"'<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 بعد انتهائها:
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>"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 الذي ذكرناه في قسمها.
الإصلاح: لا منفذ، وشبكة داخلية لا يشاركها إلا nginx
نحذف المنفذ المنشور، ونضع التطبيق في شبكة داخلية internal: true لا يدخلها إلا nginx، والفرق في ملف Compose كما يلي:
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-netdocker 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/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 لأنها تحذف ما يخص مشاريع أخرى على الجهاز:
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.