> ## 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.

# تطبيقك خلف Reverse Proxy: عنوان الزائر الحقيقي وHTTPS والروابط الصحيحة
- URL: https://arabroot.io/articles/تطبيقك-خلف-reverse-proxy/
- Published: 2026-10-03T17:45:00.000Z
- Updated: 2026-10-04T20:52:45.000Z
- Description: إذا كان تطبيقك يرى عنوان الـ Proxy بدلاً من عنوان الزائر، أو يولد روابط http مع أن موقعك يعمل بـ HTTPS، فستعرف هنا السبب والإعداد الصحيح لإطار عملك دون أن تفتح باباً لانتحال العناوين.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, DevOps وCI/CD, الاستضافة الذاتية

عندما تنشر تطبيقك خلف Reverse Proxy فغالباً سوف يعمل النطاق Domain ويظهر القفل في المتصفح من أول مرة، وتظن أن المهمة انتهت، ثم تفتح السجلات Logs بعد يوم فتجد كل الطلبات قادمة من عنوان واحد مثل `172.18.0.2`، ويشتكي مستخدم من أن تسجيل الدخول يدور في حلقة لا تنتهي، ويصلك من آخر أن رسالة البريد التي أرسلها التطبيق فيها رابط يبدأ بـ `http://` مع أن موقعك يعمل بـ HTTPS.

![مخطط يبين مسار الطلب من جهاز الزائر بعنوان 203.0.113.50 إلى Cloudflare الذي يضيف CF-Connecting-IP، ثم إلى ال Reverse Proxy الذي يضيف X-Forwarded-For وX-Real-IP وX-Forwarded-Proto، ثم إلى التطبيق الذي يرى في الاتصال عنوان ال Proxy 172.18.0.2 ويقرأ عنوان الزائر من الترويسة، وفي الأسفل ترويسة X-Forwarded-For مزيفة يتجاهلها التطبيق لأنه لا يثق إلا بال Proxy](https://arabroot.io/content/images/2026/10/behind-proxy-01-request-path.webp)

مسار الطلب من الزائر إلى التطبيق، وما تضيفه كل محطة، ولماذا يتجاهل التطبيق الترويسة المزيفة

والكود Code لم يتغير، وإنما تغير الطرف الذي يتصل بالتطبيق، فالتطبيق لم يعد يكلم المتصفح مباشرة، بل يكلم ال Reverse Proxy الذي استلم اتصال HTTPS وفك تشفيره، ثم أرسل الطلب إلى التطبيق عبر HTTP عادي من داخل شبكة Docker Network، وبالتالي يرى التطبيق عنوان ال Proxy وبروتوكوله، ولا يعرف عن الزائر إلا ما يخبره به ال Proxy في الترويسات Headers. وفي [دليل شبكات 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/) ذكرنا أننا نضع ال Reverse Proxy والتطبيقات على شبكة `proxy` مشتركة دون أي منفذ منشور للتطبيقات، وهذا المقال هو الوجه الآخر لنفس النمط، أي ما يحتاجه التطبيق نفسه حتى يعمل صحيحاً خلف هذا ال Proxy.

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

- ما الذي تحمله ترويسات `X-Forwarded-*`، وكيف يقرؤها تطبيقك دون أن يصدق ترويسة كتبها مهاجم.
- ما الذي يجب أن يرسله ال Proxy نفسه في Nginx وNginx Proxy Manager وTraefik وCaddy.
- الإعداد الصحيح في Express وDjango وLaravel وASP.NET Core وSpring Boot وGo وPHP.
- Cloudflare أمام ال Proxy، والعنوان العام والمسارات الفرعية وروابط OAuth.
- WebSocket وSSE ومهلة الطلبات البطيئة، ثم كيف تتحقق مما يصل فعلاً، والمشكلات الشائعة وحلولها.

وإذا لم تثبت Reverse Proxy بعد، فابدأ بدليل [Nginx Proxy Manager](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/) أو [Traefik](https://arabroot.io/articles/%D8%AA%D8%AB%D8%A8%D9%8A%D8%AA-traefik-reverse-proxy/) أو [Caddy](https://arabroot.io/articles/%D8%AA%D8%AB%D8%A8%D9%8A%D8%AA-caddy-reverse-proxy/)، وتجد الفروق بينها في [مقارنة أدوات ال Reverse Proxy](https://arabroot.io/articles/%D9%85%D9%82%D8%A7%D8%B1%D9%86%D8%A9-nginx-proxy-manager-%D9%88-traefik-%D9%88-haproxy/)، أما بقية ما يلزم قبل نشر تطبيقك ففي [قائمة تحقق المطور قبل نشر تطبيقه](https://arabroot.io/articles/%D9%82%D8%A7%D8%A6%D9%85%D8%A9-%D8%AA%D8%AD%D9%82%D9%82-%D8%A7%D9%84%D9%85%D8%B7%D9%88%D8%B1-%D9%82%D8%A8%D9%84-%D9%86%D8%B4%D8%B1-%D8%AA%D8%B7%D8%A8%D9%8A%D9%82%D9%87/).

## كيف يصل الطلب إلى تطبيقك؟

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

1. الزائر يفتح `https://app.example.com` من جهازه، وعنوانه العام مثلاً `203.0.113.50`.
2. Cloudflare، إذا كان النطاق يمر به (السحابة البرتقالية Orange Cloud)، حيث يفك TLS ثم يفتح اتصالاً جديداً بسيرفرك من أحد عناوينه.
3. ال Reverse Proxy على سيرفرك، مثل Nginx Proxy Manager أو Traefik أو Caddy، ويستقبل الاتصال على المنفذ 443 ثم يفتح اتصالاً جديداً بالتطبيق، وعنوانه داخل شبكة Docker مثلاً `172.18.0.2`.
4. ال Container الذي يعمل فيه التطبيق، على منفذ داخلي Port مثل `3000`.

ولاحظ أن كل محطة تفتح اتصال TCP جديداً، لذلك لا يرى التطبيق في الاتصال نفسه إلا عنوان المحطة التي قبله مباشرة، أي ال Proxy، أما عنوان الزائر الأصلي والبروتوكول واسم النطاق فتنقلها كل محطة إلى التالية في ترويسات تضيفها إلى الطلب Request.

### الترويسات التي تحمل معلومات الزائر

| الترويسة                                                                                                                   | ما تحمله                                                                                                                  | مثال                                              |
| -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
| [X-Forwarded-For](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/X-Forwarded-For?ref=arabroot.io)     | قائمة عناوين، تضيف كل محطة إلى آخرها عنوان من اتصل بها                                                                    | 203.0.113.50, 198.51.100.20                       |
| X-Real-IP                                                                                                                  | عنوان واحد يكتبه ال Proxy، ولا يتبع أي معيار رسمي                                                                         | 203.0.113.50                                      |
| [X-Forwarded-Proto](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/X-Forwarded-Proto?ref=arabroot.io) | البروتوكول الذي استخدمه الزائر                                                                                            | https                                             |
| [X-Forwarded-Host](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/X-Forwarded-Host?ref=arabroot.io)   | اسم النطاق الذي طلبه الزائر                                                                                               | app.example.com                                   |
| X-Forwarded-Port                                                                                                           | المنفذ الذي اتصل عليه الزائر                                                                                              | 443                                               |
| [Forwarded](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Forwarded?ref=arabroot.io)                 | الترويسة القياسية التي تجمع كل ما سبق، ويعرفها المعيار [RFC 7239](https://www.rfc-editor.org/rfc/rfc7239?ref=arabroot.io) | for=203.0.113.50;proto=https;host=app.example.com |
| [CF-Connecting-IP](https://developers.cloudflare.com/fundamentals/reference/http-headers/?ref=arabroot.io)                 | عنوان الزائر كما رآه Cloudflare، ويكتبه Cloudflare وحده                                                                   | 203.0.113.50                                      |

وأهم ما تفهمه من هذا الجدول ترتيب `X-Forwarded-For`، فالعنوان الأول من اليسار هو ما ادعت المحطة الأولى أنه الزائر، والعنوان الأخير من اليمين هو آخر من مر به الطلب قبل ال Proxy الأخير، وكل Proxy يضيف إلى اليمين ولا يحذف شيئاً إلا إذا أعددته ليكتب الترويسة من جديد. والترويسة `Forwarded` هي المعيار الرسمي، ولكن أغلب الأدوات وأطر العمل Frameworks ما زالت تعتمد على ترويسات `X-Forwarded-*`، لذلك سوف نركز عليها في بقية المقال.

## لماذا يهمك عنوان الزائر الحقيقي؟

إذا رأى التطبيق عنوان ال Proxy في كل الطلبات فسوف تتعطل أمور كثيرة دون أن يظهر لك أي خطأ، ومنها:

- **السجلات**: لن تعرف من فعل ماذا، ولن تستطيع تتبع أي هجوم إلى مصدره.
- **الحد من معدل الطلبات Rate Limiting**: يعامل التطبيق كل الزوار كأنهم عنوان واحد، فإذا تجاوز أحدهم الحد حظر الجميع، وإذا رفعت الحد لم يحظر أحداً.
- **سجل التدقيق Audit Log**: لا قيمة له أمام المراجع إذا كان كل دخول مسجلاً من `172.18.0.2`.
- **الموقع الجغرافي Geolocation**: يظهر كل الزوار في مركز البيانات الذي فيه سيرفرك.
- **أدوات الحظر التلقائي مثل fail2ban**: تحظر عنوان ال Proxy نفسه، فيتوقف الموقع عن الجميع.

### الخطر: ترويسة يكتبها أي أحد

والحل الأول الذي يخطر على بال المطور أن يقرأ `X-Forwarded-For` مباشرة في الكود، أو أن يفعل في إطار العمل خيار الثقة بكل Proxy مثل `trust proxy: true` في Express، وهذا الحل غير مناسب إطلاقاً، والسبب أن هذه الترويسة نص عادي يرسله العميل Client، ويكتب فيها أي أحد ما يشاء:

```bash
curl -H 'X-Forwarded-For: 198.51.100.7' https://app.example.com/login
```

فإذا صدق التطبيق كل ما يصله، فسوف يسجل المهاجم دخوله بعنوان مزيف، ويتجاوز الحد من معدل الطلبات بتغيير الترويسة في كل محاولة، وقد يصل إلى صفحة مقصورة على عناوين معينة، وهذا ما يسمى انتحال العنوان IP Spoofing.

📌

في 27 سبتمبر 2026 نشر مشروع Zipline، وهو سيرفر لرفع الملفات ومشاركتها يستضيفه الكثيرون على سيرفراتهم، [تحذيراً أمنياً](https://github.com/diced/zipline/security/advisories/GHSA-v938-v8wg-9497?ref=arabroot.io) يشرح أن تفعيل الخيار `core.trustProxy`، وهو الإعداد الذي ينصح به المشروع لكل من يشغله خلف Reverse Proxy أو CDN، يجعل Fastify يأخذ عنوان الزائر من أول قيمة في `X-Forwarded-For`، وبالتالي يستطيع المهاجم أن يغير الترويسة مع كل طلب فيحصل على عداد جديد للحد من معدل الطلبات في كل مرة، ويخمن الباسورد ورموز التحقق بخطوتين TOTP دون أي حد. وقد شمل التحذير الإصدار 4.7.0 وما قبله، ولم يكن فيه إصدار مصحح عند نشره. والدرس أن خيار «الثقة بال Proxy» بقيمة `true` هو بالضبط الخطأ الذي يشرحه هذا القسم، فلا تثق إلا بعنوان ال Proxy الذي تعرفه.

لذلك القاعدة الصحيحة هي: **لا يقرأ التطبيق ترويسات `X-Forwarded-*` إلا إذا جاء الاتصال من Proxy تثق به**، ويعرف التطبيق ذلك من عنوان الاتصال الفعلي، وهو الشيء الوحيد الذي لا يستطيع العميل تزويره، فإذا جاء الاتصال من عنوان ال Proxy قرأ الترويسة، وإذا جاء من أي عنوان آخر تجاهلها.

🛑

لا تنشر منفذ التطبيق على السيرفر (`ports` في Compose) إذا كان يعمل خلف Proxy، والسبب أن الإعداد الذي يثق بأول Proxy في الطريق يصبح ثغرة إذا استطاع المهاجم أن يتصل بالتطبيق مباشرة، لأن التطبيق سوف يعامله معاملة ال Proxy. لذلك اجعل ال Container على شبكة Docker الداخلية وحدها، ودع ال Proxy يصل إليه بالاسم.

### أي عنوان تختار من X-Forwarded-For؟

لنفرض أن مهاجماً أرسل الترويسة المزيفة السابقة، وأن الطلب مر بـ Cloudflare ثم بال Proxy، فسوف يصل إلى التطبيق بهذا الشكل:

```bash
X-Forwarded-For: 198.51.100.7, 203.0.113.50, 172.71.0.10
```

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

- **قائمة بالعناوين الموثوقة**: عنوان ال Proxy أو شبكة Docker، ومعها نطاقات Cloudflare إن كان أمامك، وهذه الصيغة أدق لأنها لا تتأثر إذا أضفت محطة أو أزلتها.
- **عدد المحطات Hops**: تخبر الإطار أن أمامه Proxy واحداً، أو اثنين مع Cloudflare، وهذه الصيغة أبسط ولكنها تفترض أن أحداً لا يصل إلى التطبيق إلا عبر هذه المحطات بالضبط.

⚠️

الثقة بشبكة Docker كلها، مثل `172.18.0.0/16`، تشمل بوابة الشبكة Gateway `172.18.0.1`، وقد يرى ال Proxy بعض الزوار من هذا العنوان، مثل زوار IPv6 عندما تكون شبكة Docker بدون IPv6، حيث [يحولهم Docker إلى IPv4](https://docs.docker.com/engine/network/port-publishing/?ref=arabroot.io) عبر docker-proxy. وفي هذه الحالة يتخطى التطبيق البوابة لأنه يثق بها، فيأخذ العنوان الذي كتبه الزائر، لذلك الأدق أن تثق بعنوان ال Proxy وحده، أو بعدد المحطات، كلما استطعت.

## لماذا يظن التطبيق أن الطلب جاء عبر HTTP؟

ينهي ال Proxy اتصال TLS عنده، وهذا ما يسمى إنهاء TLS أو TLS Termination، ثم يكلم التطبيق عبر HTTP، فإذا سأل التطبيق نفسه هل هذا الطلب آمن فالإجابة لا، ومن هنا تأتي خمس مشكلات معروفة:

- **حلقة التحويل Redirect Loop**: إذا فعلت في التطبيق خيار «فرض HTTPS» فسوف يحول كل طلب إلى `https://`، فيعود الطلب عبر ال Proxy ويصل مرة أخرى عبر HTTP، فيحوله التطبيق من جديد، ويستمر ذلك حتى يظهر في المتصفح `ERR_TOO_MANY_REDIRECTS`.
- **ملفات تعريف الارتباط Cookies بدون `Secure`**: بعض الأطر لا تضع العلامة `Secure` على ملف الجلسة Session إلا إذا كان الطلب آمناً، وبعضها لا يرسل ملف الجلسة أبداً إذا رأى الطلب HTTP، فيخرج المستخدم بعد الدخول مباشرة.
- **روابط تبدأ بـ `http://`**: في رسائل البريد، وفي روابط إعادة تعيين كلمة المرور، وفي ترويسة `Location` عند التحويل.
- **المحتوى المختلط Mixed Content**: تطلب الصفحة ملفات CSS أو JavaScript بروابط `http://`، فيحظرها المتصفح وتظهر الصفحة بدون تنسيق.
- **رفض رابط العودة في OAuth**: يرسل التطبيق إلى مزود الدخول Identity Provider رابط عودة Redirect URI يبدأ بـ `http://`، فيرفضه المزود بخطأ مثل `redirect_uri_mismatch`.

والحل من جزأين، الأول أن يقرأ التطبيق `X-Forwarded-Proto` من ال Proxy الموثوق فقط، بنفس القاعدة التي طبقناها على العنوان، والثاني أن تعطي التطبيق عنوانه العام صراحة في متغير مثل `APP_URL` أو `SITE_URL` أو `BASE_URL`، فيبني منه كل رابط يرسله خارج الطلب الحالي، مثل رسائل البريد والمهام المجدولة Cron Jobs، وسوف تجد الإعدادين لكل إطار في القسم التالي.

## ما الذي ينبغي أن يرسله ال Proxy؟

قبل إعداد التطبيق تذكر أن أول Proxy يستقبل الطلب من الإنترنت هو حد الثقة، وبالتالي عليه أن يكتب هذه الترويسات من جديد، لا أن يمرر ما أرسله العميل، وإلا وصل إلى التطبيق ما كتبه المهاجم مهما كان إعداد التطبيق صحيحاً.

### Nginx

إذا كنت تكتب إعداد Nginx بنفسك، فهذه الأسطر هي ما تحتاجه في `location` الذي يمرر الطلب إلى التطبيق:

```nginx
location / {
    proxy_pass http://app:3000;

    proxy_set_header Host              $host;
    proxy_set_header X-Real-IP         $remote_addr;
    proxy_set_header X-Forwarded-For   $remote_addr;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-Host  $host;
    proxy_set_header Forwarded         "";
}
```

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

- كل ترويسة تكتب من جديد، فلا يصل إلى التطبيق شيء كتبه العميل.
- استخدمنا `$remote_addr` لـ `X-Forwarded-For` لأن Nginx هنا أول محطة، أما المتغير `$proxy_add_x_forwarded_for` الشائع في الأمثلة فيضيف العنوان إلى ما أرسله العميل ولا يحذفه، وعندها يجب على التطبيق أن يقرأ القائمة من اليمين.
- السطر الأخير يحذف الترويسة `Forwarded`، والسبب أن [Nginx لا يمرر الترويسة إذا كانت قيمتها فارغة](https://nginx.org/en/docs/http/ngx%5Fhttp%5Fproxy%5Fmodule.html?ref=arabroot.io#proxy%5Fset%5Fheader)، وبدونه تصل ترويسة `Forwarded` من العميل كما هي إلى إطار قد يقرؤها.

### Nginx Proxy Manager

يكتب Nginx Proxy Manager الترويسة `X-Real-IP` من جديد، ويضيف عنوان الزائر إلى `X-Forwarded-For`، ويرسل `X-Forwarded-Proto`، ولكنه في الإصدار 2.16.0 يمرر قيمة `X-Forwarded-Proto` التي يرسلها العميل بنفسه إذا كانت `http` أو `https`، لذلك فعل الخيار Force SSL في كل Proxy Host، حتى لا يصل إلى التطبيق إلا طلب جاء فعلاً عبر HTTPS. وتجد بقية الإعداد في دليل [Nginx Proxy Manager](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/).

### Traefik وCaddy

يرسل [Traefik](https://doc.traefik.io/traefik/reference/routing-configuration/http/middlewares/headers/?ref=arabroot.io) الترويسات `X-Forwarded-For` و`X-Real-Ip` و`X-Forwarded-Proto` و`X-Forwarded-Host` تلقائياً، ولا يصدق ما أرسله العميل منها إلا إذا جاء الطلب من عنوان في [forwardedHeaders.trustedIPs](https://doc.traefik.io/traefik/reference/install-configuration/entrypoints/?ref=arabroot.io) الخاص بنقطة الدخول EntryPoint، لذلك اترك هذا الخيار فارغاً ما لم تكن أمام Traefik محطة أخرى، ولا تستخدم `forwardedHeaders.insecure` في بيئة الإنتاج Production.

أما [Caddy](https://caddyserver.com/docs/caddyfile/directives/reverse%5Fproxy?ref=arabroot.io) فيضبط الترويسات `X-Forwarded-For` و`X-Forwarded-Proto` و`X-Forwarded-Host`، ويتجاهل ما أرسله العميل منها إلا إذا كان عنوان العميل في [trusted\_proxies](https://caddyserver.com/docs/caddyfile/options?ref=arabroot.io)، ولكنه لا يضيف `X-Real-IP`، بل يمررها إلى التطبيق كما أرسلها العميل، فإذا كان تطبيقك يقرأ `X-Real-IP` فاكتبها في Caddy صراحة:

```text
app.example.com {
	reverse_proxy app:3000 {
		header_up X-Real-IP {client_ip}
	}
}
```

## الإعداد في إطار عملك

سوف نفترض في الأمثلة التالية أن ال Proxy والتطبيق على شبكة Docker عنوانها `172.18.0.0/16`، وأن النطاق العام `app.example.com`، فضع شبكتك مكان هذه الشبكة، وتعرفها بالأمر التالي:

```bash
docker network inspect proxy --format '{{range .IPAM.Config}}{{.Subnet}}{{end}}'
```

وإذا أردت أن تثق بعنوان ال Proxy وحده، فثبت له عنواناً في الشبكة بالخيار `ipv4_address` في ملف Compose، واكتب هذا العنوان بدلاً من الشبكة كلها.

### Node.js مع Express

يعتمد [Express](https://expressjs.com/en/guide/behind-proxies.html?ref=arabroot.io) على الإعداد `trust proxy`، وقيمته الافتراضية `false`، وعندما تفعله تأخذ `req.ip` و`req.ips` قيمتهما من `X-Forwarded-For`، و`req.protocol` من `X-Forwarded-Proto`، و`req.hostname` من `X-Forwarded-Host`.

```javascript
const express = require('express');
const app = express();

// Proxy واحد أمام التطبيق، ومنفذ التطبيق غير منشور على الخادم
app.set('trust proxy', 1);

// أو: الثقة بشبكة الـ Proxy وحدها
// app.set('trust proxy', ['loopback', '172.18.0.0/16']);
```

الرقم `1` يعني محطة واحدة، فيأخذ Express أول عنوان من يمين `X-Forwarded-For`. وإذا كان أمامك Cloudflare ثم ال Proxy فاكتب `2`، إلا إذا كان ال Proxy يستعيد عنوان الزائر من `CF-Connecting-IP` كما في [قسم Cloudflare](#cloudflare)، فعندها يرى التطبيق محطة واحدة وتبقى القيمة `1`. ولا تستخدم القيمة `true` أبداً، والسبب أنها تأخذ العنوان الأول من اليسار، أي العنوان الذي يكتبه العميل.

⚠️

إذا قرأت القيمة من متغير بيئة Environment Variable فحولها إلى رقم: `Number(process.env.TRUST_PROXY_HOPS)`، والسبب أن النص `'1'` ليس الرقم `1` عند Express، بل يفهمه عنواناً، فيبقى `req.ip` عنوان ال Proxy دون أي رسالة خطأ.

وبعد هذا الإعداد يصبح `req.secure` صحيحاً للطلبات القادمة عبر HTTPS، وهذا شرط ليعمل الخيار `cookie.secure` في `express-session`، لأنه لا يرسل ملف الجلسة إلا على طلب آمن.

### Django

لا يوجد في Django إعداد يقرأ عنوان الزائر من `X-Forwarded-For`، فيبقى `request.META["REMOTE_ADDR"]` عنوان ال Proxy، أما البروتوكول واسم النطاق فلهما إعدادان رسميان في `settings.py`:

```python
ALLOWED_HOSTS = ["app.example.com"]

SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
USE_X_FORWARDED_HOST = True

SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True

TRUSTED_PROXY_NETWORKS = ["172.18.0.0/16"]

MIDDLEWARE = [
    "myproject.realip.RealIPMiddleware",
    "django.middleware.security.SecurityMiddleware",
    # ... بقية الـ Middleware كما هي
]
```

مع [SECURE\_PROXY\_SSL\_HEADER](https://docs.djangoproject.com/en/6.1/ref/settings/?ref=arabroot.io#secure-proxy-ssl-header) تعيد الدالة `request.is_secure()` القيمة `True` إذا وصلت الترويسة بالقيمة `https`، ومع [USE\_X\_FORWARDED\_HOST](https://docs.djangoproject.com/en/6.1/ref/settings/?ref=arabroot.io#use-x-forwarded-host) تبنى الروابط من `X-Forwarded-Host`. ولاحظ أن الإعدادين لا يتحققان من مصدر الطلب، بل يصدقان الترويسة أياً كان مرسلها، لذلك لا تفعلهما إلا إذا كان ال Proxy يكتب الترويسة من جديد، وكان التطبيق لا يقبل اتصالاً إلا منه.

أما عنوان الزائر فيكفيه Middleware صغير يقرأ `X-Forwarded-For` فقط إذا جاء الاتصال من شبكة موثوقة، ويمشي في القائمة من اليمين، فاحفظه في الملف `myproject/realip.py`:

```python
import ipaddress

from django.conf import settings

TRUSTED = [ipaddress.ip_network(n) for n in settings.TRUSTED_PROXY_NETWORKS]

def is_trusted(addr):
    return any(addr in net for net in TRUSTED)

class RealIPMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        try:
            peer = ipaddress.ip_address(request.META.get("REMOTE_ADDR", ""))
        except ValueError:
            return self.get_response(request)
        if is_trusted(peer):
            chain = request.META.get("HTTP_X_FORWARDED_FOR", "").split(",")
            for item in reversed(chain):
                try:
                    addr = ipaddress.ip_address(item.strip())
                except ValueError:
                    break
                if not is_trusted(addr):
                    request.META["REMOTE_ADDR"] = str(addr)
                    break
        return self.get_response(request)
```

ضع هذا ال Middleware أول القائمة حتى يرى كل ما بعده العنوان الصحيح، وإذا وجد في القائمة قيمة ليست عنواناً صالحاً فإنه يتوقف ويبقي عنوان ال Proxy. وإذا فضلت مكتبة جاهزة فهناك [django-ipware](https://github.com/un33k/django-ipware?ref=arabroot.io)، وعليك فيها أن تمرر عناوين ال Proxy أو عددها بنفسك.

### Laravel

في Laravel يأتي ال Middleware المسمى `TrustProxies` جاهزاً، ومنذ الإصدار 11 تضبطه في الملف `bootstrap/app.php` كما في [توثيق Laravel 13](https://laravel.com/docs/13.x/requests?ref=arabroot.io#configuring-trusted-proxies):

```php
->withMiddleware(function (Middleware $middleware): void {
    $middleware->trustProxies(at: [
        '172.18.0.0/16',
    ]);
})
```

وبعدها تعيد `$request->ip()` عنوان الزائر، ويبني ال Helper `url()` روابط `https://`، أما الروابط التي تبنى خارج الطلب، مثل رسائل البريد من ال Queue، فثبت لها العنوان العام في `.env`:

```ini
APP_URL=https://app.example.com
```

ويذكر التوثيق القيمة `at: '*'` للثقة بكل عنوان، وهذه القيمة مقصودة لل Load Balancer السحابي الذي لا تعرف عناوينه، فلا تستخدمها على سيرفرك. ولا تلجأ إلى `URL::forceScheme('https')` بديلاً عن الإعداد الصحيح، والسبب أنها تصلح الروابط فقط وتترك العنوان والجلسات على حالها.

### ASP.NET Core

يعالج ASP.NET Core الترويسات بال Middleware المسمى Forwarded Headers، ولا يثق افتراضياً إلا بعنوان الجهاز المحلي Loopback، وفي .NET 10 أصبحت القائمة `KnownNetworks` [مهملة Obsolete](https://learn.microsoft.com/en-us/aspnet/core/breaking-changes/10/ipnetwork-knownnetworks-obsolete?view=aspnetcore-10.0&ref=arabroot.io) وحلت محلها `KnownIPNetworks` التي تستخدم النوع `System.Net.IPNetwork`، والإعداد في `Program.cs` كما يلي:

```csharp
using Microsoft.AspNetCore.HttpOverrides;

var builder = WebApplication.CreateBuilder(args);

builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
    options.ForwardedHeaders = ForwardedHeaders.XForwardedFor
                             | ForwardedHeaders.XForwardedProto
                             | ForwardedHeaders.XForwardedHost;
    options.KnownIPNetworks.Clear();
    options.KnownProxies.Clear();
    options.KnownIPNetworks.Add(System.Net.IPNetwork.Parse("172.18.0.0/16"));
    options.AllowedHosts.Add("app.example.com");
});

var app = builder.Build();

app.UseForwardedHeaders();
// بعده: UseHsts وUseHttpsRedirection وUseAuthentication وبقية الـ Middleware
```

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

- اكتب `System.Net.IPNetwork` باسمه الكامل، والسبب أن النوع القديم بنفس الاسم ما زال موجوداً في `Microsoft.AspNetCore.HttpOverrides`.
- ضع `UseForwardedHeaders` قبل كل Middleware يعتمد على العنوان أو البروتوكول.
- `AllowedHosts` يحصر قيم `X-Forwarded-Host` المقبولة، وإذا وصلت قيمة غيرها بقي اسم المضيف الأصلي.

ويعالج الإطار افتراضياً عنواناً واحداً من يمين القائمة، لأن قيمة [ForwardLimit](https://learn.microsoft.com/en-us/aspnet/core/host-and-deploy/proxy-load-balancer?view=aspnetcore-10.0&ref=arabroot.io) هي `1`، فإذا كان أمامك Cloudflare فاجعلها `2` وأضف نطاقات Cloudflare إلى `KnownIPNetworks`. أما المتغير `ASPNETCORE_FORWARDEDHEADERS_ENABLED=true` فيفعل المعالجة دون أي قائمة ثقة، لذلك لا يناسب سيرفراً يصل إليه الإنترنت مباشرة.

### Spring Boot

الخيار [server.forward-headers-strategy](https://docs.spring.io/spring-boot/how-to/webserver.html?ref=arabroot.io#howto.webserver.use-behind-a-proxy-server) يحدد من يعالج الترويسات، وقيمته الافتراضية `none` خارج المنصات السحابية المعروفة، فاختر `native` مع Tomcat، وعندها يقرأ سيرفر الويب نفسه `X-Forwarded-For` و`X-Forwarded-Proto`، ويثق بالعناوين التي يحددها `internal-proxies`، والإعداد في `application.yml`:

```yaml
server:
  forward-headers-strategy: native
  tomcat:
    remoteip:
      internal-proxies: "172\\.18\\.\\d{1,3}\\.\\d{1,3}"
```

والقيمة الافتراضية لـ `internal-proxies` تعبير نمطي Regular Expression يشمل عناوين الجهاز المحلي والعناوين الخاصة، ومنها `10.0.0.0/8` و`192.168.0.0/16` و`172.16.0.0/12`، لذلك يعمل الإعداد داخل Docker بالسطر الأول وحده، ولكن حصره في شبكتك أدق. وبعدها تعيد `request.getRemoteAddr()` عنوان الزائر، وتعيد `request.isSecure()` القيمة `true` لطلبات HTTPS.

أما القيمة `framework` فتستخدم الفلتر `ForwardedHeaderFilter` من Spring، وهو يدعم `Forwarded` و`X-Forwarded-Prefix`، ولكن [توثيق Spring](https://docs.spring.io/spring-framework/reference/web/webmvc/filters.html?ref=arabroot.io) يوضح أنه لا يتحقق من مصدر الترويسات، ويترك لل Proxy أن يحذف ما يرسله العميل، لذلك ابق على `native` ما لم تحتج إلى هاتين الترويستين.

### Go

مكتبة `net/http` لا تقرأ أياً من هذه الترويسات، فيبقى `r.RemoteAddr` عنوان ال Proxy ويبقى `r.TLS` فارغاً، ويكتب كثيرون دالة تأخذ أول قيمة من `X-Forwarded-For`، وهذا هو الخطأ الذي شرحناه. وكانت الحزمة chi تقدم `middleware.RealIP` لهذا الغرض، ولكن [وثائقها الحالية](https://pkg.go.dev/github.com/go-chi/chi/v5/middleware?ref=arabroot.io) تصفها بأنها مهملة Deprecated ومعرضة للانتحال، والسبب أنها تصدق الترويسات دون أن تنظر إلى مصدر الاتصال.

والمثال التالي Middleware بالمكتبة القياسية وحدها، يقرأ القائمة فقط إذا جاء الاتصال من شبكة موثوقة:

```go
package main

import (
	"context"
	"net"
	"net/http"
	"net/netip"
	"strings"
)

var trustedProxies = []netip.Prefix{
	netip.MustParsePrefix("172.18.0.0/16"),
}

type clientIPKey struct{}

func isTrusted(a netip.Addr) bool {
	for _, p := range trustedProxies {
		if p.Contains(a) {
			return true
		}
	}
	return false
}

func RealIP(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		host, _, _ := net.SplitHostPort(r.RemoteAddr)
		client, err := netip.ParseAddr(host)
		if err != nil {
			http.Error(w, "bad remote address", http.StatusBadRequest)
			return
		}
		client = client.Unmap()
		if isTrusted(client) {
			hops := strings.Split(strings.Join(r.Header.Values("X-Forwarded-For"), ","), ",")
			for i := len(hops) - 1; i >= 0; i-- {
				a, err := netip.ParseAddr(strings.TrimSpace(hops[i]))
				if err != nil {
					break
				}
				client = a.Unmap()
				if !isTrusted(client) {
					break
				}
			}
		}
		ctx := context.WithValue(r.Context(), clientIPKey{}, client)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

func ClientIP(r *http.Request) netip.Addr {
	a, _ := r.Context().Value(clientIPKey{}).(netip.Addr)
	return a
}
```

في الكود أعلاه لاحظ أن الدالة `Unmap` تحول العناوين بصيغة `::ffff:172.18.0.2` إلى IPv4، وبدونها لن تطابق الشبكة الموثوقة. وإذا كنت تستخدم chi فإصداراتها الحديثة فيها بدائل مثل `ClientIPFromXFF("172.18.0.0/16")`، ولكن هذه البدائل لا تنظر في `r.RemoteAddr`، فهي تفترض أن أحداً لا يصل إلى التطبيق إلا عبر ال Proxy. أما البروتوكول فلا تعتمد على `r.TLS` في بناء الروابط، بل خذ العنوان العام من متغير مثل `BASE_URL`.

### PHP مع Nginx وPHP-FPM

إذا كان Container التطبيق يجمع Nginx مع PHP-FPM، كما في كثير من ال Images الخاصة بـ WordPress وLaravel، فالأسهل أن يصحح Nginx الداخلي العنوان قبل أن يصل إلى PHP، وهذا عمل [الوحدة Module المسماة realip](https://nginx.org/en/docs/http/ngx%5Fhttp%5Frealip%5Fmodule.html?ref=arabroot.io)، وهي موجودة في ال Image الرسمي لـ Nginx:

```nginx
set_real_ip_from  172.18.0.0/16;
real_ip_header    X-Forwarded-For;
real_ip_recursive on;

map $http_x_forwarded_proto $fastcgi_https {
    default "";
    https   on;
}

server {
    listen 80;
    # ...
    location ~ \.php$ {
        include       fastcgi_params;
        fastcgi_param HTTPS $fastcgi_https;
        fastcgi_pass  127.0.0.1:9000;
    }
}
```

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

- `set_real_ip_from` لا يقبل الترويسة إلا من الشبكة الموثوقة.
- `real_ip_recursive on` يجعل الوحدة تمشي في القائمة من اليمين وتتخطى العناوين الموثوقة، ولولاه لأخذت العنوان الأخير فقط، فيصل إلى PHP عنوان الزائر في `$_SERVER['REMOTE_ADDR']`، وتصل القيمة `on` في [$\_SERVER\['HTTPS'\]](https://www.php.net/manual/en/reserved.variables.server.php?ref=arabroot.io) للطلبات الآمنة.
- `map` لا تتحقق من المصدر، وتصح هنا لأن ال Container لا يصل إليه إلا ال Proxy.

## إذا كان Cloudflare أمام ال Proxy

Cloudflare محطة إضافية، فيرى ال Proxy على سيرفرك أن كل الطلبات جاءت من عناوين Cloudflare، ويرسل Cloudflare عنوان الزائر في `CF-Connecting-IP` ويضيفه إلى `X-Forwarded-For`. ونفس القاعدة تنطبق هنا، فلا تصدق `CF-Connecting-IP` إلا من [نطاقات Cloudflare المنشورة](https://www.cloudflare.com/ips/?ref=arabroot.io)، وإلا كتبها أي أحد يصل إلى سيرفرك مباشرة. والأفضل أن تستعيد العنوان في ال Proxy نفسه، فيرى التطبيق ما كان سيراه دون Cloudflare، كما يشرح [توثيق Cloudflare](https://developers.cloudflare.com/support/troubleshooting/restoring-visitor-ips/restoring-original-visitor-ips/?ref=arabroot.io).

### Nginx

الأمر التالي يولد ملفاً بنطاقات Cloudflare الحالية، ثم يتحقق من الإعداد ويعيد التحميل:

```bash
{
  for range in $(curl -fsS https://www.cloudflare.com/ips-v4) $(curl -fsS https://www.cloudflare.com/ips-v6); do
    echo "set_real_ip_from $range;"
  done
  echo "real_ip_header CF-Connecting-IP;"
} | sudo tee /etc/nginx/conf.d/cloudflare-realip.conf
sudo nginx -t && sudo systemctl reload nginx
```

وبعدها يصبح `$remote_addr` عنوان الزائر، فتصل القيمة الصحيحة في `X-Real-IP` و`X-Forwarded-For` من إعداد `location` السابق، ولاحظ أن نطاقات Cloudflare تتغير أحياناً، لذلك أعد توليد الملف دورياً بمهمة مجدولة.

### Nginx Proxy Manager

يجلب Nginx Proxy Manager نطاقات Cloudflare وCloudFront عند الإقلاع ويثق بها، ما لم تعطل ذلك بالمتغير [IP\_RANGES\_FETCH\_ENABLED](https://nginxproxymanager.com/advanced-config/?ref=arabroot.io)، ولكنه في الإصدار 2.16.0 يقرأ العنوان من `X-Real-IP`، وCloudflare لا يرسل هذه الترويسة، وبالتالي يبقى عنوان Cloudflare هو ما يصل إلى تطبيقك. والحل سطر واحد في تبويب Advanced الخاص بال Proxy Host:

```nginx
real_ip_header CF-Connecting-IP;
```

هذا السطر يغير الترويسة التي يقرأ منها NPM العنوان في هذا المضيف وحده، ويبقي قائمة الثقة كما هي. ولاحظ أن NPM يثق افتراضياً بالشبكات الخاصة أيضاً، أي `10.0.0.0/8` و`172.16.0.0/12` و`192.168.0.0/16`، فإذا كان NPM في شبكة منزلية فأي جهاز فيها يستطيع أن يكتب الترويسة.

### Traefik وCaddy

في Traefik أضف نطاقات Cloudflare إلى `forwardedHeaders.trustedIPs` في نقطة الدخول، وعندها يحتفظ بقائمة `X-Forwarded-For` القادمة من Cloudflare ويضيف إليها، وفي هذه الحالة أمام التطبيق محطتان، فاجعل الإطار يثق بعدد `2` أو بنطاقات Cloudflare مع شبكة Docker.

أما في Caddy فتستطيع أن تجعل ال Proxy يستعيد العنوان بنفسه، ثم يكتبه للتطبيق في `X-Real-IP`:

```text
{
	servers {
		trusted_proxies static 173.245.48.0/20 103.21.244.0/22
		client_ip_headers CF-Connecting-IP
	}
}

app.example.com {
	reverse_proxy app:3000 {
		header_up X-Real-IP {client_ip}
	}
}
```

والنطاقان في المثال من قائمة Cloudflare للتوضيح فقط، فاكتب القائمة كاملة من `ips-v4` و`ips-v6`، وعندها يقبل Caddy `CF-Connecting-IP` من هذه النطاقات وحدها، ويضع العنوان في `{client_ip}`.

### مع Cloudflare Tunnel

إذا كنت تستخدم Cloudflare 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 العامة، وإنما من ال Container الذي يشغل `cloudflared` داخل شبكة Docker، لذلك المحطة الموثوقة هنا هي عنوان `cloudflared`، وما زال Cloudflare يرسل `CF-Connecting-IP` بعنوان الزائر.

## العنوان العام واسم المضيف والمسار الفرعي

### اسم المضيف Host

أغلب أدوات ال Proxy، ومنها Traefik وCaddy وNPM، تمرر الترويسة `Host` كما طلبها الزائر، وفي Nginx تحتاج إلى السطر `proxy_set_header Host $host`، فإذا كانت `Host` صحيحة فغالباً لا حاجة إلى `X-Forwarded-Host`. وإذا فعلت قراءتها في الإطار فاقرنها بقائمة الأسماء المسموحة: `ALLOWED_HOSTS` في Django، و`AllowedHosts` في ASP.NET Core، و`trustHosts` في Laravel، والسبب أنه بدون هذه القائمة يستطيع مهاجم أن يجعل التطبيق يرسل رابط إعادة تعيين كلمة المرور إلى نطاقه هو.

ولا تبن الروابط من الطلب وحده، فالمهمة المجدولة وال Queue لا يوجد لهما طلب أصلاً، لذلك عرف العنوان العام في متغير ثابت، ومن أمثلته `APP_URL` في Laravel، و`url` في Ghost، و`ROOT_URL` في Gitea، و`SITE_URL` أو `BASE_URL` في كثير من التطبيقات.

### التشغيل تحت مسار فرعي مثل /app

قد تريد تشغيل التطبيق على `https://example.com/app` بدلاً من نطاق فرعي Subdomain، ولذلك طريقتان، الأولى أن يحذف ال Proxy البادئة قبل التمرير، مثل [StripPrefix](https://doc.traefik.io/traefik/reference/routing-configuration/http/middlewares/stripprefix/?ref=arabroot.io) في Traefik أو [handle\_path](https://caddyserver.com/docs/caddyfile/directives/handle%5Fpath?ref=arabroot.io) في Caddy، فيظن التطبيق أنه على `/`، ولكنه سوف يولد عندها روابط مثل `/login` و`/static/app.css` بدون البادئة، فتفتح صفحة خاطئة. والثانية أن يعرف التطبيق مساره الأساسي Base Path، مثل [FORCE\_SCRIPT\_NAME](https://docs.djangoproject.com/en/6.1/ref/settings/?ref=arabroot.io#force-script-name) في Django، و`UsePathBase` في ASP.NET Core، و`server.servlet.context-path` في Spring Boot، ولاحظ أن StripPrefix في Traefik يرسل البادئة المحذوفة في `X-Forwarded-Prefix`، وبعض الأطر تقرؤها.

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

### روابط العودة في OAuth وOIDC

يقارن مزود الدخول رابط العودة الذي يرسله التطبيق بالرابط المسجل لديه حرفاً بحرف، كما توصي [أفضل ممارسات أمان OAuth](https://www.rfc-editor.org/rfc/rfc9700?ref=arabroot.io)، ويكفي اختلاف بين `http` و`https`، أو منفذ زائد مثل `:3000`، أو شرطة مائلة في الآخر، حتى يرفض المزود الطلب. لذلك سجل الرابط العام عند المزود، مثل `https://app.example.com/auth/callback`، وتأكد أن التطبيق يولد نفس الرابط بعد إعداد ال Proxy، والأضمن أن تكتب رابط العودة صراحة في إعداد التطبيق إن كان يسمح بذلك.

## WebSocket وSSE

### WebSocket

يبدأ اتصال WebSocket بطلب HTTP عادي يحمل الترويستين `Upgrade: websocket` و`Connection: Upgrade`، وال Proxy لا يمرر هاتين الترويستين إلى المحطة التالية إلا إذا طلبت منه ذلك، ففي Nginx تحتاج إلى [إعداد صريح](https://nginx.org/en/docs/http/websocket.html?ref=arabroot.io):

```nginx
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

server {
    # ...
    location /ws/ {
        proxy_pass http://app:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade    $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_read_timeout 1h;
    }
}
```

وفي Nginx Proxy Manager يضيف الخيار Websockets Support هذه الترويسات، أما [Traefik](https://doc.traefik.io/traefik/expose/overview/?ref=arabroot.io) و[Caddy](https://caddyserver.com/docs/caddyfile/directives/reverse%5Fproxy?ref=arabroot.io) فيدعمان WebSocket تلقائياً دون إعداد، ولكن Caddy يغلق اتصالات WebSocket القائمة مع كل إعادة تحميل للإعداد، ما لم تضبط `stream_close_delay`.

والمشكلة الثانية هي المهلة Timeout، فالقيمة الافتراضية لـ [proxy\_read\_timeout](https://nginx.org/en/docs/http/ngx%5Fhttp%5Fproxy%5Fmodule.html?ref=arabroot.io#proxy%5Fread%5Ftimeout) في Nginx هي 60 ثانية، ويرفعها NPM إلى 90 ثانية، فإذا لم يرسل الطرفان شيئاً خلالها أغلق Nginx الاتصال. والحل الأفضل أن يرسل التطبيق نبضة Heartbeat كل 30 ثانية مثلاً، فلا يبقى الاتصال صامتاً. ويدعم Cloudflare اتصالات WebSocket [في كل الخطط](https://developers.cloudflare.com/network/websockets/?ref=arabroot.io)، ولكنه يغلق الاتصال الخامل، وقد يقطع الاتصالات عندما يحدث سيرفراته، لذلك اجعل العميل يعيد الاتصال تلقائياً إذا انقطع.

### أحداث الخادم (SSE)

في [أحداث الخادم Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent%5Fevents?ref=arabroot.io) يبقى الرد مفتوحاً، ويكتب فيه التطبيق حدثاً بعد حدث، فإذا جمع ال Proxy الرد في ذاكرة مؤقتة Buffering أو ضغطه Compression قبل إرساله، وصلت الأحداث إلى المتصفح دفعة واحدة في النهاية، ويحدث هذا مثلاً في Nginx إذا فعلت `gzip` للنوع `text/event-stream`. ولذلك حلان، الأول من التطبيق، حيث يرسل الترويسة `X-Accel-Buffering: no` مع الرد فيلغي Nginx التجميع لهذا الرد وحده، والثاني من Nginx:

```nginx
location /events {
    proxy_pass http://app:3000;
    proxy_buffering off;
    proxy_read_timeout 1h;
}
```

الخيار [proxy\_buffering off](https://nginx.org/en/docs/http/ngx%5Fhttp%5Fproxy%5Fmodule.html?ref=arabroot.io#proxy%5Fbuffering) يعطل التجميع في هذا المسار، و`proxy_read_timeout` يمنع إغلاق الاتصال بعد دقيقة، أما Caddy فيرسل ردود `text/event-stream` فور وصولها دون إعداد.

## الطلبات البطيئة وخطأ 504

إذا تأخر رد التطبيق أكثر من مهلة ال Proxy، توقف ال Proxy عن الانتظار وأعاد للزائر `504 Gateway Timeout`، مع أن التطبيق قد يكمل العمل في الخلفية، ومهلة Nginx الافتراضية 60 ثانية كما ذكرنا. وإذا كان Cloudflare أمامك فهو ينتظر رد سيرفرك [125 ثانية](https://developers.cloudflare.com/fundamentals/reference/connection-limits/?ref=arabroot.io) ثم يعيد [الخطأ 524](https://developers.cloudflare.com/support/troubleshooting/http-status-codes/cloudflare-5xx-errors/error-524/?ref=arabroot.io)، ولا تستطيع رفع هذه المهلة إلا في خطة Enterprise.

ورفع المهلة حل مؤقت، والحل الأنسب أن ينقل التطبيق العمل الطويل، مثل تقرير كبير أو تصدير بيانات، إلى مهمة في الخلفية Background Job، فيرد فوراً برقم المهمة، ثم يسأل المتصفح عن حالتها. أما خطأ `413` ورفع الملفات الكبيرة فتجد تفاصيلهما في مقال [حدود حجم رفع الملفات](https://arabroot.io/articles/%D8%AD%D8%AF%D9%88%D8%AF-%D8%AD%D8%AC%D9%85-%D8%B1%D9%81%D8%B9-%D8%A7%D9%84%D9%85%D9%84%D9%81%D8%A7%D8%AA/).

## كيف تتحقق مما يصل إلى تطبيقك؟

لا تخمن، وإنما انظر إلى ما يصل فعلاً، والأسهل أن تشغل Container صغيراً يعرض كل ترويسة يستلمها، مثل [traefik/whoami](https://github.com/traefik/whoami?ref=arabroot.io)، على نفس شبكة ال Proxy، وتربطه مؤقتاً بنطاق تجريبي:

```yaml
services:
  whoami:
    image: traefik/whoami:v1.12.0
    networks: [proxy]

networks:
  proxy:
    external: true
```

يريك whoami ما يرسله ال Proxy، ولكنه لا يريك ما يستنتجه إطار عملك منه، لذلك أضف إلى تطبيقك نقطة نهاية Endpoint مؤقتة تعرض القيم المحسوبة، وفي Express مثلاً:

```javascript
app.get('/debug/request', (req, res) => {
  res.json({
    peer: req.socket.remoteAddress,
    ip: req.ip,
    ips: req.ips,
    protocol: req.protocol,
    hostname: req.hostname,
    xForwardedFor: req.get('x-forwarded-for') ?? null,
  });
});
```

⚠️

احذف نقطة النهاية هذه بعد الاختبار، وأوقف whoami، والسبب أن عرض الترويسات للعموم قد يكشف ملفات تعريف الارتباط وترويسة `Authorization` وعناوين شبكتك الداخلية.

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

```bash
curl -s https://app.example.com/debug/request
curl -s -H 'X-Forwarded-For: 198.51.100.7' -H 'X-Real-IP: 198.51.100.7' -H 'X-Forwarded-Host: evil.example' https://app.example.com/debug/request
```

وفي الحالتين يجب أن يكون `ip` عنوانك العام، و`protocol` هو `https`، و`hostname` هو `app.example.com`. فإذا كان التطبيق خلف Nginx Proxy Manager مع `trust proxy` بالقيمة `1`، فالمخرج للطلب المزيف سوف يكون كما يلي:

```json
{
  "peer": "::ffff:172.18.0.2",
  "ip": "203.0.113.50",
  "ips": ["203.0.113.50"],
  "protocol": "https",
  "hostname": "app.example.com",
  "xForwardedFor": "198.51.100.7, 203.0.113.50"
}
```

ولاحظ أن NPM يضيف العنوان إلى القائمة، فيظهر العنوان المزيف في القائمة الخام، ولكن الإطار يتجاهله. فإذا ظهر `198.51.100.7` في `ip` فتطبيقك يصدق ما يكتبه العميل، فراجع قسم إطار عملك، وإذا ظهر `172.18.0.2` فالتطبيق لا يثق بال Proxy بعد. ولاحظ أيضاً أن Node.js يعرض عنوان الاتصال بصيغة `::ffff:`، وأن Express يطابقها مع `172.18.0.0/16` دون مشكلة.

## مشكلات شائعة وحلولها

### يسجل التطبيق 172.18.0.x لكل الزوار

التطبيق لا يثق بال Proxy بعد، أو يثق بعنوان آخر، فاعرف عنوان ال Proxy الفعلي بالأمر `docker network inspect proxy` وقارنه بالقيمة في إعداد إطارك. وتذكر أن بعض الأطر ترى العنوان بصيغة IPv6 مثل `::ffff:172.18.0.2`، فإذا كان إطارك لا يطابقها مع شبكة IPv4 فأضف الصيغتين أو حول العنوان قبل المقارنة.

### يظهر 172.18.0.1 حتى في سجلات ال Proxy

هذا عنوان بوابة شبكة Docker، ويظهر غالباً عندما يصل الزائر عبر IPv6 إلى منفذ منشور على شبكة Docker لا تدعم IPv6، فيمرر docker-proxy الاتصال ويضيع العنوان الأصلي، ويظهر أيضاً للطلبات التي ترسلها من السيرفر نفسه إلى `127.0.0.1`. والحل أن تفعل IPv6 على شبكة ال 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 بالشبكة `host`، ولا تضع البوابة في قائمة الثقة.

### يظهر في السجلات عنوان كتبه الزائر

جرب الطلب المزيف من قسم التحقق، فإذا نجح الانتحال فالسبب واحد من ثلاثة: الأول أن الإطار يأخذ العنوان الأول من اليسار، مثل `trust proxy: true` في Express، والثاني أن الثقة واسعة جداً، مثل `at: '*'` في Laravel، والثالث أن منفذ التطبيق منشور على السيرفر، فيصل إليه المهاجم دون المرور بال Proxy.

### ERR\_TOO\_MANY\_REDIRECTS بعد تفعيل HTTPS في التطبيق

التطبيق لا يقرأ `X-Forwarded-Proto`، فيظن أن كل طلب جاء عبر HTTP ويحوله من جديد، لذلك فعل الثقة بالبروتوكول في إطارك، مثل `SECURE_PROXY_SSL_HEADER` في Django أو `ForwardedHeaders.XForwardedProto` في ASP.NET Core. وإذا كان Cloudflare أمامك بوضع SSL المسمى Flexible فهو يكلم سيرفرك عبر HTTP، فغيره إلى Full (Strict).

### الروابط والتحويلات تبدأ بـ http://

السبب نفسه، أو أن الرابط يبنى خارج الطلب كما في رسائل البريد من ال Queue، فعل الثقة بالبروتوكول، وثبت العنوان العام في `APP_URL` أو ما يقابله في تطبيقك. وإذا ظهر في المتصفح تحذير المحتوى المختلط، فافتح أدوات المطور Developer Tools وابحث عن الملفات التي تحمل بـ `http://`.

### يرفض مزود الدخول الطلب بخطأ redirect\_uri\_mismatch

افتح رابط الدخول، وانسخ قيمة `redirect_uri` من شريط العنوان، وقارنها بالرابط المسجل عند المزود حرفاً بحرف، فإذا كانت تبدأ بـ `http://` أو فيها منفذ داخلي فالتطبيق لا يقرأ ترويسات ال Proxy، أو لا يعرف عنوانه العام.

### ينقطع اتصال WebSocket كل دقيقة تقريباً

هذه مهلة `proxy_read_timeout` في Nginx أو NPM، فاجعل التطبيق يرسل نبضة أقصر من المهلة، أو ارفع المهلة لمسار WebSocket وحده. وإذا كان الانقطاع يحدث مع كل تعديل في إعداد Caddy فالسبب إعادة التحميل، وعلاجه الخيار `stream_close_delay`.

### تصل أحداث SSE دفعة واحدة

هناك شيء يجمع الرد قبل إرساله، وغالباً هو الضغط أو التجميع في ال Proxy، أو Middleware للضغط في التطبيق نفسه، فأرسل `X-Accel-Buffering: no` من التطبيق، أو أضف `proxy_buffering off` للمسار، واستثن `text/event-stream` من الضغط.

### يظهر عنوان Cloudflare بدلاً من عنوان الزائر

ال Proxy لا يستعيد العنوان من `CF-Connecting-IP`، فاتبع قسم [Cloudflare](#cloudflare) للأداة التي تستخدمها، وتذكر أن NPM يحتاج إلى السطر `real_ip_header CF-Connecting-IP` في تبويب Advanced.

### 504 أو 524 للطلبات الطويلة

الرمز `504` يأتي من ال Proxy على سيرفرك، والرمز `524` من Cloudflare بعد 125 ثانية، فارفع مهلة ال Proxy للمسار البطيء وحده إن لزم، ولكن الحل الدائم أن تنقل العمل الطويل إلى مهمة في الخلفية.

## الخلاصة

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

- التطبيق خلف Reverse Proxy يرى في الاتصال عنوان ال Proxy دائماً، وعنوان الزائر والبروتوكول واسم النطاق يصله في ترويسات `X-Forwarded-*`.
- لا تثق بالترويسة إلا إذا جاء الاتصال من ال Proxy الذي تعرفه، واقرأ `X-Forwarded-For` من اليمين، ولا تستخدم `trust proxy: true` أو `at: '*'` على سيرفرك، والسبب أن العنوان الأول من اليسار يكتبه العميل.
- أول Proxy يستقبل الطلب من الإنترنت يكتب الترويسات من جديد، ولا تنشر منفذ التطبيق على السيرفر حتى لا يتجاوز أحد هذا ال Proxy.
- ثبت العنوان العام للتطبيق في متغير مثل `APP_URL`، فهو مصدر الروابط في البريد وال Queue وروابط العودة في OAuth.
- خلف Cloudflare استعد عنوان الزائر من `CF-Connecting-IP` في ال Proxy نفسه، وفي NPM يكفي سطر `real_ip_header CF-Connecting-IP` في تبويب Advanced.
- WebSocket وSSE يحتاجان إلى مهلة أطول ونبضة من التطبيق، والطلب الذي يتجاوز 125 ثانية خلف Cloudflare مكانه مهمة في الخلفية.

## سجل التحديثات (Changelog)

- أكتوبر 2026: كتابة المقال.