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

# CORS: لماذا يرفض المتصفح طلبك، وكيف تسمح به بأمان
- URL: https://arabroot.io/articles/شرح-cors-وسياسة-المصدر-الواحد/
- Published: 2026-10-05T10:00:00.000Z
- Updated: 2026-10-09T09:42:02.000Z
- Description: لماذا يمنع المتصفح صفحتك من قراءة رد ال API، وكيف تفتح CORS هذا الباب لمصادر محددة دون أن تفتحه لكل موقع.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, DevOps والتشغيل, DevOps وCI/CD, الاستضافة الذاتية

تكتب الواجهة الأمامية Frontend على `app.example.com` وال API على `api.example.com`، وتختبر ال API بالأمر `curl` أو من Postman فيعمل كل شيء، ثم تفتح الصفحة في المتصفح فلا يظهر شيء، وتجد في لوحة Console سطراً أحمر يقول إن الطلب `has been blocked by CORS policy`. وأول ما يفعله أغلب المطورين هو البحث عن الخطأ ونسخ أول حل يجدونه، وغالباً يكون السطر `Access-Control-Allow-Origin: *`، فيختفي الخطأ يوماً ثم يعود عندما يضيفون تسجيل الدخول، أو يختفي ومعه حماية لم يعرفوا أنها كانت موجودة.

والسبب أن CORS ليست إعداداً يفتح ويغلق، وإنما هي طريقة يخبر بها السيرفر المتصفح Browser بالمواقع التي يسمح لها أن تقرأ ردوده، وهذا لا يفهم إلا إذا عرفت القاعدة التي تخففها CORS، وهي سياسة المصدر الواحد Same-Origin Policy. وفي دليل [ترويسات الأمان بالتفصيل](https://arabroot.io/articles/%D8%AA%D8%B1%D9%88%D9%8A%D8%B3%D8%A7%D8%AA-%D8%A7%D9%84%D8%A3%D9%85%D8%A7%D9%86-hsts-%D9%88-csp/) شرحنا الترويسات التي تقيد المتصفح، أما هذا المقال فهو المرجع لترويسات CORS التي تفتح له باباً محدداً.

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

- ما هو المصدر Origin، ولماذا يمنع المتصفح صفحة من قراءة رد موقع آخر، وما الذي لا يمنعه.
- المشكلة التي جاءت CORS لحلها، والحلول القديمة مثل JSONP ولماذا تركناها.
- كيف تعمل CORS: الطلب البسيط Simple Request، والطلب التمهيدي Preflight، وملفات تعريف الارتباط Cookies، و`Vary: Origin`.
- تجربة حقيقية في المتصفح على Containers محلية، مع رسائل Console والترويسات كما ظهرت.
- أين تضبط CORS: في التطبيق، أو في Nginx وNginx Proxy Manager وTraefik وCaddy، أو تتجنبها تماماً بمسار `/api` على نفس النطاق.
- الأخطاء الشائعة وأثرها على الأمان، والفرق بين CORS وCSP وCSRF وSameSite، وقائمة لتشخيص أي خطأ من أدوات المطور.

## قبل CORS: سياسة المصدر الواحد Same-Origin Policy

### ما هو المصدر Origin؟

المصدر هو ثلاثة أجزاء من الرابط معاً: البروتوكول Scheme مثل `https`، واسم المضيف Host مثل `app.example.com`، والمنفذ Port مثل `443`، كما يعرفه [معيار HTML](https://html.spec.whatwg.org/multipage/browsers.html?ref=arabroot.io#origin) و[مسرد MDN](https://developer.mozilla.org/en-US/docs/Glossary/Origin?ref=arabroot.io). أما المسار `/orders` وما بعد علامة الاستفهام فلا يدخلان فيه، لذلك تعد كل صفحات `https://app.example.com` مصدراً واحداً مهما اختلفت مساراتها، ويكفي أن يختلف جزء واحد من الثلاثة حتى يصبح الرابط مصدراً آخر.

![مخطط يقسم الرابط https://app.example.com:443/orders?id=7 إلى البروتوكول واسم المضيف والمنفذ وهي المصدر، والمسار والاستعلام وهما خارج المصدر، ثم جدول يقارن ستة روابط بالمصدر https://app.example.com فيبين أن اختلاف المسار أو كتابة المنفذ 443 صراحة لا يغير المصدر، وأن اختلاف البروتوكول أو النطاق الفرعي أو المنفذ أو النطاق الرئيسي يجعله مصدراً مختلفاً](https://arabroot.io/content/images/2026/10/cors-01-origin.webp)

المصدر هو البروتوكول واسم المضيف والمنفذ، واختلاف أي منها يعني مصدراً آخر

والجدول التالي يقارن عدة روابط بالمصدر `https://app.example.com`:

| الرابط                            | النتيجة    | والسبب                                  |
| --------------------------------- | ---------- | --------------------------------------- |
| https://app.example.com/settings  | نفس المصدر | اختلف المسار وحده                       |
| https://app.example.com:443/login | نفس المصدر | 443 هو المنفذ الافتراضي لـ HTTPS        |
| http://app.example.com            | مصدر مختلف | البروتوكول http وليس https              |
| https://api.example.com           | مصدر مختلف | النطاق الفرعي Subdomain اسم مضيف آخر    |
| https://app.example.com:8443      | مصدر مختلف | المنفذ 8443 وليس 443                    |
| https://example.com               | مصدر مختلف | النطاق الرئيسي Root Domain اسم مضيف آخر |

لاحظ السطر الرابع، فالواجهة على `app.example.com` وال API على `api.example.com` مصدران مختلفان في نظر المتصفح حتى لو كانا على نفس السيرفر وتحت نفس النطاق، وهذا بالضبط الإعداد الذي يقع فيه أغلب المطورين في أول خطأ CORS.

### لماذا يفرض المتصفح هذه السياسة؟

لنفرض أنك سجلت الدخول إلى موقع البنك في تبويب، ثم فتحت في تبويب آخر صفحة لا تعرفها، وفي هذه الصفحة سكربت يطلب `https://bank.example/api/account`. المتصفح يرسل مع هذا الطلب ملف تعريف الارتباط Cookie الخاص بالبنك من تلقاء نفسه، والسبب أن هذا سلوك الويب منذ بدايته، فأي طلب إلى نطاق يحمل ملفات تعريف الارتباط الخاصة بهذا النطاق. وبالتالي يرى سيرفر البنك طلباً من مستخدم مسجل الدخول ويرد ببيانات حسابه، فلو كان المتصفح يسلم هذا الرد إلى السكربت لقرأ أي موقع تزوره بريدك ورصيدك ولوحات الإدارة التي فتحتها في نفس المتصفح.

ولذلك وضعت المتصفحات [سياسة المصدر الواحد](https://developer.mozilla.org/en-US/docs/Web/Security/Defenses/Same-origin%5Fpolicy?ref=arabroot.io)، والقاعدة فيها أن السكربت الذي يعمل في صفحة من مصدر ما لا يستطيع أن يقرأ الردود القادمة من مصدر آخر، إلا إذا سمح له المصدر الآخر بذلك صراحة. ويمكن تشبيهها بموظف الاستقبال في مبنى شركات، فهو يقبل أي ظرف يوضع في صندوق أي شركة، ولكنه لا يسمح لموظف شركة أن يفتح بريد شركة أخرى.

![مخطط متصفح بتبويبين، الأول موقع البنك مسجل الدخول والثاني موقع خبيث يشغل سكربت fetch إلى bank.example مع credentials include، وفي الخطوة الأولى يخرج الطلب ومعه ترويسة Origin وملف تعريف الارتباط session، وفي الثانية يرد البنك ببيانات الحساب دون Access-Control-Allow-Origin، وفي الثالثة يحتفظ المتصفح بالرد ويظهر للصفحة الخطأ TypeError: Failed to fetch](https://arabroot.io/content/images/2026/10/cors-02-sop.webp)

سياسة المصدر الواحد تمنع قراءة الرد، ولا تمنع إرسال الطلب

### ما الذي لا تمنعه السياسة؟

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

- تضمين الموارد Embedding: الصفحة تستطيع أن تعرض صورة من موقع آخر عبر `<img>`، وتشغل سكربتاً عبر `<script src>`، وتحمل ملف CSS أو خطاً، ولكنها لا تستطيع أن تقرأ محتوى هذه الملفات بالكود.
- النماذج Forms: النموذج العادي يستطيع أن يرسل طلب `POST` إلى أي موقع، ويرسل المتصفح معه ملفات تعريف الارتباط بحسب الخاصية `SameSite`.
- الروابط والتحويلات: الانتقال إلى صفحة في موقع آخر مسموح دائماً.

وقد يتساءل البعض: إذا كان الطلب يصل وينفذ، فما الذي يمنع موقعاً خبيثاً من أن يرسل باسمك طلب تحويل مبلغ أو حذف حساب؟ والإجابة أن هذا هجوم آخر اسمه تزوير الطلبات عبر المواقع Cross-Site Request Forgery واختصاراً CSRF، وسياسة المصدر الواحد لا تحمي منه، وإنما يحمي منه التوكن Token الخاص بكل نموذج في التطبيق، والخاصية `SameSite` على ملف تعريف الارتباط التي شرحناها في قسم [ملفات تعريف الارتباط في دليل ترويسات الأمان](https://arabroot.io/articles/%D8%AA%D8%B1%D9%88%D9%8A%D8%B3%D8%A7%D8%AA-%D8%A7%D9%84%D8%A3%D9%85%D8%A7%D9%86-hsts-%D9%88-csp/#cookies)، كما يفصل [دليل OWASP للحماية من CSRF](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site%5FRequest%5FForgery%5FPrevention%5FCheat%5FSheet.html?ref=arabroot.io). لذلك لا تعتمد على CORS ولا على سياسة المصدر الواحد لحماية أي طلب يغير البيانات.

## المشكلة التي جاءت CORS لحلها

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

وقبل CORS لجأ المطورون إلى حلول التفافية، وأشهرها JSONP، وفيه تضع الصفحة وسم `<script src="https://api.example.com/data?callback=show">`، والسيرفر يرد بكود JavaScript يستدعي الدالة `show` ومعها البيانات، وبما أن تشغيل السكربت من موقع آخر مسموح فإن البيانات تصل. ولكن هذا الحل سيئ من كل جهة، فهو لا يدعم إلا `GET`، ولا يحمل ترويسات، والأخطر أن الصفحة تشغل أي كود يرسله السيرفر الآخر بكامل صلاحياتها، وأي موقع في العالم يستطيع أن يضع نفس الوسم فيقرأ نفس البيانات بملفات تعريف الارتباط الخاصة بالمستخدم. والحل الآخر كان Proxy على سيرفر الواجهة يطلب ال API نيابة عن المتصفح، وهو آمن ولكنه يضيف محطة وتكلفة لكل طلب، ولا يصلح لأداة تعمل على مواقع لا تملكها.

ومن هنا جاءت [مشاركة الموارد بين المصادر Cross-Origin Resource Sharing](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS?ref=arabroot.io) واختصاراً CORS، وهي اليوم جزء من [معيار Fetch من WHATWG](https://fetch.spec.whatwg.org/?ref=arabroot.io#http-cors-protocol) الذي تتبعه كل المتصفحات. والفكرة بسيطة: المتصفح يخبر السيرفر من أي مصدر جاء الطلب، والسيرفر يرد بترويسات تقول هل يسمح لهذا المصدر أن يقرأ الرد أم لا، والمتصفح هو الذي ينفذ القرار. لاحظ أن القرار في يد صاحب ال API، وليس في يد الصفحة التي تطلب، وهذا منطقي لأن البيانات بياناته.

## كيف تعمل CORS؟

### الطلب البسيط Simple Request

عندما يرسل السكربت طلباً إلى مصدر آخر، يضيف المتصفح من تلقاء نفسه الترويسة [Origin](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Origin?ref=arabroot.io) وفيها مصدر الصفحة، ولا يستطيع السكربت أن يغيرها. فإذا كان الطلب بسيطاً أرسله المتصفح مباشرة، ثم قرأ في الرد الترويسة [Access-Control-Allow-Origin](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Origin?ref=arabroot.io)، فإذا كانت قيمتها نفس المصدر أو النجمة `*` سلم الرد للسكربت، وإلا احتفظ به وأعطى السكربت الخطأ `TypeError: Failed to fetch`.

![مخطط طلب بسيط: الصفحة على app.example.com تطلب GET /api/profile من api.example.com، والمتصفح يضيف Origin: https://app.example.com، ثم يرد ال API بالرمز 200 ومعه Access-Control-Allow-Origin بنفس المصدر وVary: Origin، ثم يقارن المتصفح القيمتين ويسلم JSON للصفحة، وفي الأسفل الشروط الثلاثة للطلب البسيط: الطريقة GET أو HEAD أو POST، والترويسات المسموحة فقط، وContent-Type من ثلاث قيم](https://arabroot.io/content/images/2026/10/cors-03-simple.webp)

في الطلب البسيط يرسل المتصفح الطلب مباشرة، ثم يقرر من ترويسة الرد هل يسلمه للصفحة

والطلب البسيط بحسب [معيار Fetch](https://fetch.spec.whatwg.org/?ref=arabroot.io#cors-safelisted-method) هو الطلب الذي تتحقق فيه ثلاثة شروط معاً: الطريقة Method هي `GET` أو `HEAD` أو `POST`، ولا يضيف السكربت إلا [ترويسات من قائمة محددة](https://fetch.spec.whatwg.org/?ref=arabroot.io#cors-safelisted-request-header) مثل `Accept` و`Accept-Language`، وقيمة `Content-Type` إن وجدت هي `text/plain` أو `multipart/form-data` أو `application/x-www-form-urlencoded`. وقد يتساءل البعض: لماذا هذه القيم بالذات؟ والإجابة أنها الطلبات التي يستطيع أي نموذج HTML أن يرسلها منذ ما قبل CORS، فالسيرفرات تستقبلها من مواقع أخرى منذ عشرات السنين، ولا يضيف السماح بها خطراً جديداً.

### الطلب التمهيدي Preflight

أما الطلب الذي يخرج عن هذه الشروط، مثل `PUT` أو `DELETE`، أو طلب يحمل `Content-Type: application/json`، أو ترويسة `Authorization` التي تحمل التوكن، فلا يرسله المتصفح مباشرة، وإنما يرسل قبله [طلباً تمهيدياً Preflight](https://developer.mozilla.org/en-US/docs/Glossary/Preflight%5Frequest?ref=arabroot.io) بالطريقة `OPTIONS` يستأذن فيه السيرفر. والسبب أن هذه الطلبات لم يكن ممكناً إرسالها من موقع آخر قبل CORS، فقد يكون هناك سيرفر قديم يفترض أن أي `DELETE` يصله جاء من تطبيقه، لذلك يسأله المتصفح أولاً ولا يرسل الطلب الحقيقي إلا إذا وافق.

![مخطط تسلسلي بين المتصفح على app.example.com وال API على api.example.com: في الخطوة الأولى طلب OPTIONS ومعه Access-Control-Request-Method: PUT وAccess-Control-Request-Headers: content-type، وفي الثانية رد 204 ومعه Allow-Origin وAllow-Methods وAllow-Headers وMax-Age: 600 وVary: Origin، وفي الثالثة طلب PUT الحقيقي ومعه JSON، وفي الرابعة رد 200 ومعه Access-Control-Allow-Origin، مع ملاحظة أن فشل الخطوة الثانية يعني أن الخطوة الثالثة لا ترسل، وأن Max-Age يجعل الطلب التالي يتجاوز الخطوتين الأوليين](https://arabroot.io/content/images/2026/10/cors-04-preflight.webp)

الطلب التمهيدي يسأل السيرفر عن الطريقة والترويسات قبل إرسال الطلب الحقيقي

وفي الطلب التمهيدي يرسل المتصفح ترويستين بالإضافة إلى `Origin`، الأولى [Access-Control-Request-Method](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Request-Method?ref=arabroot.io) وفيها الطريقة التي يريدها، والثانية `Access-Control-Request-Headers` وفيها الترويسات غير البسيطة. ويرد السيرفر بالترويسات التالية:

| الترويسة                                                                                                                                                 | متى            | معناها                                                                |
| -------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- | --------------------------------------------------------------------- |
| Access-Control-Allow-Origin                                                                                                                              | كل رد          | المصدر المسموح له بالقراءة، قيمة واحدة فقط أو \*                      |
| [Access-Control-Allow-Methods](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Methods?ref=arabroot.io)         | الطلب التمهيدي | الطرق المسموحة مثل GET, POST, PUT, DELETE                             |
| [Access-Control-Allow-Headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Headers?ref=arabroot.io)         | الطلب التمهيدي | الترويسات التي يسمح للسكربت أن يرسلها مثل Content-Type, Authorization |
| [Access-Control-Max-Age](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Max-Age?ref=arabroot.io)                     | الطلب التمهيدي | عدد الثواني التي يحفظ فيها المتصفح الإذن فلا يكرر الطلب التمهيدي      |
| [Access-Control-Allow-Credentials](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Credentials?ref=arabroot.io) | كل رد          | القيمة true تسمح بقراءة الرد على طلب يحمل ملفات تعريف الارتباط        |
| [Access-Control-Expose-Headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Expose-Headers?ref=arabroot.io)       | الرد الحقيقي   | ترويسات الرد التي يسمح للسكربت أن يقرأها، مثل X-Request-Id            |

وفي القيمة `Max-Age` لاحظ أن المتصفحات تضع لها حداً أعلى، فبحسب [توثيق MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Max-Age?ref=arabroot.io) يحفظ Chrome الإذن ساعتين على الأكثر وFirefox أربعاً وعشرين ساعة، وإذا لم ترسل الترويسة فالمدة الافتراضية خمس ثوان فقط، أي أن كل طلب تقريباً سوف يسبقه طلب تمهيدي. ويجدر الانتباه إلى أن الطلب التمهيدي نفسه لا يحمل ملفات تعريف الارتباط ولا ترويسة `Authorization`، كما ينص [المعيار](https://fetch.spec.whatwg.org/?ref=arabroot.io#cors-preflight-fetch)، وهذا سوف يهمنا عندما نتكلم عن المصادقة Authentication.

وأما `Expose-Headers` فسببها أن السكربت لا يرى افتراضياً إلا ترويسات قليلة من الرد، مثل `Content-Type`، فإذا كان تطبيقك يرسل رقم الطلب في `X-Request-Id` أو عدد الصفحات في ترويسة خاصة فلن يقرأها السكربت حتى تذكرها في هذه الترويسة، وسوف ترى ذلك في التجربة.

### ملفات تعريف الارتباط والطلبات التي تحمل بيانات الدخول

لا يرسل `fetch` ملفات تعريف الارتباط إلى مصدر آخر إلا إذا طلب منه السكربت ذلك بالخيار [credentials: "include"](https://developer.mozilla.org/en-US/docs/Web/API/Request/credentials?ref=arabroot.io)، وعندها يشترط المتصفح على الرد شرطين: أن تكون `Access-Control-Allow-Origin` هي المصدر نفسه وليست النجمة، وأن يرسل السيرفر `Access-Control-Allow-Credentials: true`، كما يفصل [قسم بيانات الدخول في المعيار](https://fetch.spec.whatwg.org/?ref=arabroot.io#cors-protocol-and-credentials). والسبب أن النجمة تعني «أي موقع في العالم»، ولو سمح المتصفح بها مع ملفات تعريف الارتباط لعدنا إلى مشكلة البنك نفسها، لذلك يرفض هذه التركيبة حتى لو أرسلها السيرفر.

![مخطط من جزأين، في الأيمن طريقتان خاطئتان: الأولى أن يرد ال API على طلب يحمل ملف تعريف الارتباط بالقيمة Access-Control-Allow-Origin: * فيرفضه المتصفح برسالة must not be the wildcard، والثانية أن يعيد ال API قيمة Origin كما وصلت مع Allow-Credentials: true فيقرأ evil.example بيانات المستخدم، وفي الأيسر الطريقة الصحيحة: قائمة بالمصادر المسموحة، فيرد ال API على app.example.com بالمصدر نفسه مع Allow-Credentials وVary: Origin، ولا يرسل أي ترويسة إلى evil.example فيمنع المتصفح القراءة](https://arabroot.io/content/images/2026/10/cors-06-credentials.webp)

مع ملفات تعريف الارتباط يرفض المتصفح النجمة، والبديل الصحيح قائمة بالمصادر وليس نسخ Origin

وهنا يقع أخطر خطأ في هذا الموضوع، فالمطور الذي يرى رسالة الرفض يبحث عن طريقة يتجاوز بها الشرط، فيجد أن السيرفر يستطيع أن يأخذ قيمة `Origin` من الطلب ويعيدها كما هي في `Access-Control-Allow-Origin`، فيختفي الخطأ، ويصبح أي موقع في العالم قادراً على قراءة بيانات أي مستخدم مسجل الدخول، وسوف نرى ذلك بأعيننا في التجربة. والحل الصحيح أن يحتفظ السيرفر بقائمة صريحة بالمصادر المسموحة Allowlist، ويعيد المصدر في الترويسة فقط إذا كان في القائمة.

ويجدر الإشارة هنا إلى أن الواجهات التي ترسل التوكن في الترويسة `Authorization: Bearer …`، كما في تطبيقات الصفحة الواحدة التي تستخدم [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/)، لا تحتاج إلى `credentials: "include"` ولا إلى `Allow-Credentials`، لأن التوكن ليس ملف تعريف ارتباط يرسله المتصفح وحده، ولكنها تحتاج إلى أن يسمح الطلب التمهيدي بالترويسة `Authorization`.

### لماذا تحتاج Vary: Origin؟

عندما يختار السيرفر قيمة `Access-Control-Allow-Origin` بحسب الطلب، يصبح الرد مختلفاً من مصدر لآخر، فإذا كان بين المتصفح والسيرفر Cache، مثل Cloudflare أو Varnish أو Cache المتصفح نفسه، فقد يحفظ الرد الذي صدر لمصدر ما ويعيده لمصدر آخر، فإما أن تفشل صفحة مشروعة دون سبب ظاهر، وإما أن تصل ترويسة مسموحة إلى من لا يستحقها. والحل أن يرسل السيرفر [Vary: Origin](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Vary?ref=arabroot.io) مع كل رد تختلف فيه هذه الترويسة، وهذا ما يوصي به [قسم CORS وال Cache في المعيار](https://fetch.spec.whatwg.org/?ref=arabroot.io#cors-protocol-and-http-caches)، فيعرف أي Cache أن هذا الرد يحفظ لكل قيمة من `Origin` على حدة. ولاحظ أن السيرفر يرسلها حتى للمصدر غير المسموح، فالرد الذي ليس فيه ترويسة السماح هو أيضاً رد خاص بهذا المصدر.

## تجربة عملية: نرى CORS في المتصفح

الكلام السابق يتضح عندما تراه، لذلك سوف نبني مختبراً صغيراً من Containers على جهازك: صفحة على المنفذ `18600` تمثل الواجهة، وصفحة أخرى على `18609` تمثل الموقع الخبيث، ونفس ال API بأربعة أوضاع على المنافذ من `18601` إلى `18604`. والمنفذ وحده يكفي لصنع مصدر مختلف كما رأيت في الجدول، فالصفحة على `http://127.0.0.1:18600` وال API على `http://127.0.0.1:18601` مصدران مختلفان تماماً كما لو كانا نطاقين.

أولاً ال API، وهو سيرفر Node.js دون أي مكتبة، والمتغير `MODE` يحدد طريقة تعامله مع CORS: `none` لا يرسل أي ترويسة، و`allowlist` هو الطريقة الصحيحة، و`star` و`reflect` هما الخطآن الشائعان. احفظه في الملف `api/server.mjs`:

```js
// CORS lab API. MODE: none | allowlist | star | reflect
import http from 'node:http';
import { randomUUID } from 'node:crypto';

const MODE = process.env.MODE || 'none';
const ALLOWED_ORIGINS = new Set((process.env.ALLOWED_ORIGINS || '').split(',').filter(Boolean));

function applyCors(req, res) {
    const origin = req.headers.origin;
    if (MODE === 'star') {
        res.setHeader('Access-Control-Allow-Origin', '*');
        return true;
    }
    if (MODE === 'reflect') {             // the mistake: trust any Origin
        if (origin) {
            res.setHeader('Access-Control-Allow-Origin', origin);
            res.setHeader('Access-Control-Allow-Credentials', 'true');
        }
        return true;
    }
    if (MODE === 'allowlist') {
        res.setHeader('Vary', 'Origin');
        if (!origin || !ALLOWED_ORIGINS.has(origin)) return false;
        res.setHeader('Access-Control-Allow-Origin', origin);
        res.setHeader('Access-Control-Allow-Credentials', 'true');
        res.setHeader('Access-Control-Expose-Headers', 'X-Request-Id');
        return true;
    }
    return false;
}

http.createServer((req, res) => {
    console.log(`${req.method} ${req.url} origin=${req.headers.origin ?? '-'} cookie=${req.headers.cookie ?? '-'}`);
    const allowed = applyCors(req, res);

    if (req.method === 'OPTIONS') {
        if (allowed && MODE !== 'none' && req.headers['access-control-request-method']) {
            res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
            res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
            res.setHeader('Access-Control-Max-Age', '600');
        }
        res.writeHead(204);
        return res.end();
    }

    res.setHeader('X-Request-Id', randomUUID().slice(0, 8));
    if (req.url === '/api/login') {
        res.setHeader('Set-Cookie', 'session=abc123; Path=/; HttpOnly; SameSite=Lax');
        res.writeHead(200, { 'Content-Type': 'application/json' });
        return res.end('{"ok":true}');
    }
    if (req.url.startsWith('/api/')) {
        let body = '';
        req.on('data', (c) => body += c);
        req.on('end', () => {
            const session = /session=(\w+)/.exec(req.headers.cookie || '')?.[1] ?? null;
            res.writeHead(200, { 'Content-Type': 'application/json' });
            res.end(JSON.stringify({ user: session ? 'alice' : null, email: session ? 'alice@example.com' : null, method: req.method, received: body.length }));
        });
        return;
    }
    res.writeHead(404); res.end();
}).listen(8080);
```

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

- الدالة `applyCors` في الوضع `allowlist` ترسل `Vary: Origin` أولاً لكل طلب، ثم تبحث عن المصدر في القائمة بالمطابقة التامة عبر `Set`، ولا ترسل أي ترويسة سماح للمصدر غير المعروف.
- الطلب `OPTIONS` يرد عليه السيرفر بالرمز `204` قبل أي منطق آخر، ولا يضيف ترويسات الطرق والترويسات المسموحة إلا لمصدر مسموح.
- المسار `/api/login` يضع ملف تعريف ارتباط اسمه `session`، وبقية المسارات تعيد بيانات المستخدم `alice` إذا وصل هذا الملف، وكأنها صفحة الحساب في تطبيق حقيقي.
- كل طلب يطبع سطراً في السجلات Logs فيه الطريقة والمصدر وملف تعريف الارتباط، حتى نرى ما يصل إلى السيرفر فعلاً.

ثم صفحة الواجهة في الملف `web/index.html`، وفيها دالة واحدة `run` ترسل الطلب وتطبع النتيجة في الصفحة وفي Console:

```html
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>CORS lab</title></head>
<body>
<h1>Page origin: <span id="o"></span></h1>
<table><tbody id="r"></tbody></table>
<script>
document.getElementById('o').textContent = location.origin;
window.run = async (name, url, opts = {}) => {
    let out;
    try {
        const r = await fetch(url, opts);
        const body = await r.text();
        out = `${r.status} ${body} | X-Request-Id: ${r.headers.get('X-Request-Id')}`;
    } catch (e) { out = `${e.name}: ${e.message}`; }
    const tr = document.createElement('tr');
    tr.innerHTML = '<td></td><td></td>';
    tr.children[0].textContent = name; tr.children[1].textContent = out;
    document.getElementById('r').appendChild(tr);
    console.log(`[${name}] ${out}`);
    return out;
};
</script>
</body>
</html>
```

وأخيراً الملف `docker-compose.yml` الذي يشغل ال API بأوضاعه الأربعة، والصفحة نفسها مرتين على مصدرين:

```yaml
name: lab-cors

x-api: &api
  image: node:22-alpine
  working_dir: /srv
  command: ["node", "server.mjs"]
  volumes: ["./api/server.mjs:/srv/server.mjs:ro"]

services:
  api-none:
    <<: *api
    container_name: lab-cors-api-none
    environment: { MODE: none }
    ports: ["127.0.0.1:18601:8080"]
  api-allowlist:
    <<: *api
    container_name: lab-cors-api-allowlist
    environment: { MODE: allowlist, ALLOWED_ORIGINS: "https://app.example.com,http://127.0.0.1:18600" }
    ports: ["127.0.0.1:18602:8080"]
  api-star:
    <<: *api
    container_name: lab-cors-api-star
    environment: { MODE: star }
    ports: ["127.0.0.1:18603:8080"]
  api-reflect:
    <<: *api
    container_name: lab-cors-api-reflect
    environment: { MODE: reflect }
    ports: ["127.0.0.1:18604:8080"]

  web:
    image: nginx:1.27-alpine
    container_name: lab-cors-web
    volumes: ["./web:/usr/share/nginx/html:ro"]
    ports: ["127.0.0.1:18600:80"]
  evil:
    image: nginx:1.27-alpine
    container_name: lab-cors-evil
    volumes: ["./web:/usr/share/nginx/html:ro"]
    ports: ["127.0.0.1:18609:80"]
```

```bash
docker compose up -d
```

كل المنافذ مربوطة بالعنوان `127.0.0.1` فلا يصل إليها أحد من خارج جهازك. افتح الآن `http://127.0.0.1:18600/` في Chrome، ثم افتح أدوات المطور Developer Tools بالمفتاح F12، وانتقل إلى Console.

### الطلب الأول: ال API لا يرسل أي ترويسة

اكتب في Console:

```js
run('A no CORS headers', 'http://127.0.0.1:18601/api/profile')
```

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

```text
Access to fetch at 'http://127.0.0.1:18601/api/profile' from origin 'http://127.0.0.1:18600' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
Failed to load resource: net::ERR_FAILED
[A no CORS headers] TypeError: Failed to fetch
```

هذه هي الرسالة التي يراها كل مطور في أول يوم، والآن انظر إلى سجلات ال API:

```bash
docker compose logs --no-log-prefix api-none
```

```text
GET /api/profile origin=http://127.0.0.1:18600 cookie=-
```

الطلب وصل إلى السيرفر ونفذ ورد عليه بالرمز `200`، والمتصفح هو الذي منع الصفحة من قراءة الرد، وهذا ما قلناه في قسم سياسة المصدر الواحد: المنع على القراءة وليس على الإرسال. ولاحظ أيضاً أن الصفحة لا تعرف السبب، فكل ما يصل إلى الكود هو `TypeError: Failed to fetch`، أما التفاصيل فيكتبها المتصفح في Console للمطور فقط، والسبب أن إخبار السكربت بالتفاصيل يسرب معلومات عن السيرفر الآخر.

والآن جرب طلباً غير بسيط على نفس ال API:

```js
run('C2 no-CORS PUT json', 'http://127.0.0.1:18601/api/notes', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: '{"text":"hello"}' })
```

```text
Access to fetch at 'http://127.0.0.1:18601/api/notes' from origin 'http://127.0.0.1:18600' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.
Failed to load resource: net::ERR_FAILED
[C2 no-CORS PUT json] TypeError: Failed to fetch
```

الرسالة هذه المرة تبدأ بعبارة `Response to preflight request`، أي أن الطلب التمهيدي هو الذي فشل، وفي السجلات سوف تجد سطراً واحداً:

```text
OPTIONS /api/notes origin=http://127.0.0.1:18600 cookie=-
```

فطلب `PUT` نفسه لم يرسل أصلاً، وهذا هو الفرق العملي بين النوعين: الطلب البسيط يصل وينفذ ثم يحجب رده، والطلب الذي يحتاج إلى Preflight لا يصل إلا بعد موافقة السيرفر. ولاحظ أن الطلب التمهيدي لم يحمل ملف تعريف الارتباط.

### الطلب نفسه على ال API الذي يستخدم القائمة

قبل المتصفح، لنر بالأمر `curl` ما يحدث في الطلب التمهيدي، فنرسل نفس الترويسات التي يرسلها المتصفح:

```bash
curl -si -X OPTIONS http://127.0.0.1:18602/api/notes \
  -H "Origin: http://127.0.0.1:18600" \
  -H "Access-Control-Request-Method: PUT" \
  -H "Access-Control-Request-Headers: content-type"
```

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

```http
HTTP/1.1 204 No Content
Vary: Origin
Access-Control-Allow-Origin: http://127.0.0.1:18600
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: X-Request-Id
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 600
Date: Mon, 05 Oct 2026 20:25:05 GMT
Connection: keep-alive
Keep-Alive: timeout=5
```

ثم نفس الطلب من مصدر الموقع الخبيث:

```bash
curl -si -X OPTIONS http://127.0.0.1:18602/api/notes \
  -H "Origin: http://127.0.0.1:18609" \
  -H "Access-Control-Request-Method: PUT" \
  -H "Access-Control-Request-Headers: content-type"
```

```http
HTTP/1.1 204 No Content
Vary: Origin
Date: Mon, 05 Oct 2026 20:25:05 GMT
Connection: keep-alive
Keep-Alive: timeout=5
```

نفس الرمز `204`، ولكن دون أي ترويسة سماح، وهذا يكفي ليرفض المتصفح، فلا حاجة إلى رمز خطأ مثل `403`. والآن في Console الصفحة على `18600`:

```js
run('B allowlist GET', 'http://127.0.0.1:18602/api/profile')
run('C allowlist PUT json', 'http://127.0.0.1:18602/api/notes', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: '{"text":"hello"}' })
```

```text
[B allowlist GET] 200 {"user":null,"email":null,"method":"GET","received":0} | X-Request-Id: 1c8d7aad
[C allowlist PUT json] 200 {"user":null,"email":null,"method":"PUT","received":16} | X-Request-Id: 834c39a5
```

الطلبان نجحا، والسكربت قرأ الترويسة `X-Request-Id` لأن ال API ذكرها في `Access-Control-Expose-Headers`. وإذا فتحت تبويب Network فسوف ترى لطلب `PUT` صفين، الأول من نوع `preflight` بالطريقة `OPTIONS`، والثاني الطلب الحقيقي، وإذا كررت الطلب خلال عشر دقائق فلن ترى الصف الأول، لأن المتصفح حفظ الإذن بحسب `Max-Age: 600`.

### مع ملفات تعريف الارتباط: النجمة مرفوضة

سجل الدخول أولاً بطلب يضع ملف تعريف الارتباط، ثم اطلب صفحة الحساب، والخيار `credentials: 'include'` في الطلبين:

```js
run('D login', 'http://127.0.0.1:18602/api/login', { credentials: 'include' })
run('D2 profile with cookie', 'http://127.0.0.1:18602/api/profile', { credentials: 'include' })
```

```text
[D login] 200 {"ok":true} | X-Request-Id: 608b0aad
[D2 profile with cookie] 200 {"user":"alice","email":"alice@example.com","method":"GET","received":0} | X-Request-Id: eea6d3e6
```

والآن نفس الطلب على ال API الذي يرد بالنجمة:

```js
run('E star + credentials', 'http://127.0.0.1:18603/api/profile', { credentials: 'include' })
run('F star no credentials', 'http://127.0.0.1:18603/api/profile')
```

```text
Access to fetch at 'http://127.0.0.1:18603/api/profile' from origin 'http://127.0.0.1:18600' has been blocked by CORS policy: The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'.
Failed to load resource: net::ERR_FAILED
[E star + credentials] TypeError: Failed to fetch
[F star no credentials] 200 {"user":null,"email":null,"method":"GET","received":0} | X-Request-Id: null
```

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

- مع `credentials: 'include'` رفض المتصفح النجمة برسالة صريحة، مع أن السيرفر رد بالرمز `200`.
- دون ملفات تعريف الارتباط نجح الطلب، ولكن `user` فارغ لأن ملف تعريف الارتباط لم يرسل، فالنجمة تصلح للبيانات العامة فقط، مثل خطوط أو ملفات JSON مفتوحة للجميع.
- القيمة `X-Request-Id: null` سببها أن هذا الوضع لا يرسل `Access-Control-Expose-Headers`، فالترويسة موجودة في الرد ولكن السكربت لا يراها.

### الموقع الخبيث: القائمة تحمي، والانعكاس لا يحمي

افتح الآن الصفحة نفسها على مصدر الموقع الخبيث `http://127.0.0.1:18609/`، فملف تعريف الارتباط الذي وضعه ال API ما زال في المتصفح، ثم اطلب بيانات الحساب من ال API الذي يستخدم القائمة، ثم من ال API الذي يعكس أي مصدر:

```js
run('I evil to allowlist', 'http://127.0.0.1:18602/api/profile', { credentials: 'include' })
run('J evil to reflect', 'http://127.0.0.1:18604/api/profile', { credentials: 'include' })
```

```text
Access to fetch at 'http://127.0.0.1:18602/api/profile' from origin 'http://127.0.0.1:18609' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
Failed to load resource: net::ERR_FAILED
[I evil to allowlist] TypeError: Failed to fetch
[J evil to reflect] 200 {"user":"alice","email":"alice@example.com","method":"GET","received":0} | X-Request-Id: null
```

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

```text
GET /api/profile origin=http://127.0.0.1:18609 cookie=session=abc123
```

فالطلب وصل ومعه ملف تعريف الارتباط، والقائمة لم تمنع وصوله وإنما منعت الموقع الخبيث من قراءة الرد، لذلك يبقى أي طلب يغير البيانات بحاجة إلى حماية من CSRF كما ذكرنا. ويجدر الإشارة إلى أن ملف تعريف الارتباط بالخاصية `SameSite=Lax` أرسل هنا لأن المنفذين على نفس الموقع Site، فالموقع يحدده النطاق والبروتوكول دون المنفذ، وكذلك يعد `evil.example.com` و`app.example.com` موقعاً واحداً في نظر `SameSite`، أي أن نطاقاً فرعياً مخترقاً أو نطاقاً فرعياً تستضيف عليه محتوى المستخدمين يستطيع أن يستغل هذا الخطأ حتى مع `SameSite`.

### والأمر curl لا يعرف CORS أصلاً

جرب نفس الطلب من الطرفية Terminal على ال API الذي يستخدم القائمة، بمصدر غير مسموح وملف تعريف ارتباط:

```bash
curl -si http://127.0.0.1:18602/api/profile -H "Origin: http://127.0.0.1:18609" -H "Cookie: session=abc123"
```

```http
HTTP/1.1 200 OK
Vary: Origin
X-Request-Id: a0c3980b
Content-Type: application/json
Date: Mon, 05 Oct 2026 20:25:05 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked

{"user":"alice","email":"alice@example.com","method":"GET","received":0}
```

البيانات وصلت كاملة، والسبب أن CORS قاعدة ينفذها المتصفح لحماية مستخدمه، أما `curl` وPostman والسكربتات على السيرفرات فلا تطبقها، ولا تحتاج إلى ذلك لأنها لا تحمل ملفات تعريف الارتباط الخاصة بأحد. لذلك لا تعد CORS أبداً حماية لل API نفسه، فمن يملك التوكن أو ملف تعريف الارتباط يصل إلى البيانات من أي مكان، والحماية الحقيقية هي المصادقة والصلاحيات Authorization في التطبيق.

## أين تضبط CORS؟

### في التطبيق أولاً

المكان الأنسب لإعداد CORS هو التطبيق نفسه، والسبب أن التطبيق يعرف أي مسارات تحتاج إلى السماح وأيها لا يحتاج، ويعرف هل يستخدم ملفات تعريف الارتباط أم التوكن، ويستطيع أن يقرأ قائمة المصادر من متغير بيئة يختلف بين بيئة التطوير والإنتاج Production. والمنطق هو نفسه في كل إطار عمل Framework، كما في الدالة `applyCors` أعلاه:

1. أرسل `Vary: Origin` مع كل رد على المسارات التي تطبق عليها CORS.
2. قارن `Origin` بقائمة ثابتة مطابقة تامة، ولا تستخدم البحث عن جزء من النص ولا تعبيراً نمطياً Regex واسعاً.
3. إذا وجدته فأعده كما هو في `Access-Control-Allow-Origin`، وأضف `Allow-Credentials: true` فقط إذا كنت تستخدم ملفات تعريف الارتباط.
4. رد على `OPTIONS` قبل أي فحص للمصادقة، واذكر الطرق والترويسات التي تحتاجها فعلاً وليس كل شيء.
5. إذا لم تجده فلا ترسل أي ترويسة سماح، ولا ترفض الطلب برمز خطأ إلا إذا كان التطبيق يريد ذلك لسبب آخر.

ولا تكتب هذا المنطق بنفسك إذا كان إطارك يوفره، فتجده في الحزمة [cors](https://expressjs.com/en/resources/middleware/cors.html?ref=arabroot.io) في Express، و[django-cors-headers](https://github.com/adamchainz/django-cors-headers?ref=arabroot.io) في Django، و[سياسات CORS في ASP.NET Core](https://learn.microsoft.com/en-us/aspnet/core/security/cors?ref=arabroot.io)، و[إعداد CORS في Spring](https://docs.spring.io/spring-framework/reference/web/webmvc-cors.html?ref=arabroot.io)، والملف [config/cors.php في Laravel](https://laravel.com/docs/routing?ref=arabroot.io#cors). وفي كل هذه الأدوات اكتب المصادر صراحة، وانتبه إلى الخيارات التي تقبل `true` بمعنى «اعكس أي مصدر»، مثل الخيار `origin: true` في الحزمة `cors`، فهذا هو خطأ الانعكاس نفسه باسم آخر.

### الحل الذي يلغي CORS: الواجهة وال API تحت نطاق واحد

وقد يتساءل البعض: هل أحتاج إلى CORS أصلاً؟ والإجابة أنك لا تحتاج إليها إذا كانت الواجهة وال API على نفس المصدر، فإذا خدم ال Reverse Proxy الواجهة على `https://example.com/` ومرر المسار `/api/` إلى Container ال API، فإن الصفحة تطلب `/api/profile` من نفس مصدرها، ولا توجد ترويسات ولا طلبات تمهيدية ولا قائمة تحتاج إلى صيانة، وتبقى ملفات تعريف الارتباط خاصة بالنطاق الأول First-Party.

![مخطط من جزأين، في الأيمن الواجهة على app.example.com وال API على api.example.com فيصبح كل طلب من الواجهة إلى ال API بين مصدرين ويحتاج إلى ترويسات CORS وطلبات تمهيدية وقائمة مصادر، وفي الأيسر المتصفح يكلم ال Reverse Proxy على https://example.com الذي يمرر المسار / إلى Container الواجهة والمسار /api/ إلى Container ال API، فتطلب الصفحة fetch(/api/profile) من نفس مصدرها دون CORS](https://arabroot.io/content/images/2026/10/cors-05-same-origin.webp)

عندما يجمع ال Reverse Proxy الواجهة وال API تحت مصدر واحد يختفي سؤال CORS كله

والإعداد في Nginx كما يلي:

```nginx
server {
    listen 80;
    server_name _;

    # the frontend files
    location / {
        root /usr/share/nginx/html;
        try_files $uri /index.html;
    }

    # the API on the same origin: no CORS needed
    location /api/ {
        proxy_pass http://api-none:8080;
        proxy_set_header Host $host;
    }
}
```

لاحظ أن ال API هنا هو `api-none`، أي الوضع الذي لا يرسل أي ترويسة CORS. ومن صفحة هذا السيرفر نرسل طلب `PUT` مع JSON وملف تعريف الارتباط:

```js
run('K same origin /api', '/api/profile', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: '{"text":"hello"}', credentials: 'include' })
```

```text
[K same origin /api] 200 {"user":"alice","email":"alice@example.com","method":"PUT","received":16} | X-Request-Id: ad4d1392
```

نجح الطلب دون أي ترويسة CORS ودون طلب تمهيدي. والمقابل في Traefik أن تعرف للواجهة Router بالقاعدة `` Host(`example.com`) `` ولل API Router بالقاعدة `` Host(`example.com`) && PathPrefix(`/api/`) ``، وفي Caddy أن تكتب `handle /api/*` قبل خدمة الملفات. وإذا كان ال API لا يتوقع البادئة `/api` في مساراته فراجع قسم [التشغيل تحت مسار فرعي](https://arabroot.io/articles/%D8%AA%D8%B7%D8%A8%D9%8A%D9%82%D9%83-%D8%AE%D9%84%D9%81-reverse-proxy/#subpath) في دليل التطبيق خلف ال Reverse Proxy. لذلك إذا كنت تبني الواجهة وال API معاً فابدأ بهذا التصميم، واترك CORS للحالات التي لا يمكن فيها جمعهما، مثل API عام يستخدمه عملاء على نطاقاتهم، أو أداة تضعها على مواقع أخرى.

### في ال Reverse Proxy: للحالات البسيطة

إذا كان التطبيق جاهزاً لا تملك كوده ولا يدعم CORS، فتستطيع أن تضيفها في ال Reverse Proxy، وهذا مناسب لحالة واضحة: قائمة ثابتة من المصادر وطرق معروفة. أما إذا كان التطبيق يرسل ترويسات CORS بنفسه فلا تضفها في ال Proxy، والسبب أن المتصفح يرفض الرد الذي يحمل الترويسة مرتين، وهذه الرسالة تظهر إذا وضعت إعداد Nginx الذي في القسم التالي أمام ال API الذي يرد بالنجمة:

```text
Access to fetch at 'http://127.0.0.1:18626/api/profile' from origin 'http://127.0.0.1:18600' has been blocked by CORS policy: The 'Access-Control-Allow-Origin' header contains multiple values '*, http://127.0.0.1:18600', but only one is allowed. Have the server send the header with a valid value.
```

وفي كل الأمثلة التالية ال Proxy أمام `api-none` الذي لا يرسل أي ترويسة، والقائمة فيها `https://app.example.com` ومصدر صفحة التجربة، ونختبر كل إعداد بالأمر `curl` ثم من المتصفح بطلب `PUT` مع ملف تعريف الارتباط.

### في Nginx

في Nginx نحول القائمة إلى متغير عبر [التوجيه map](https://nginx.org/en/docs/http/ngx%5Fhttp%5Fmap%5Fmodule.html?ref=arabroot.io)، فإذا كان المصدر في القائمة أخذ المتغير قيمته، وإلا بقي فارغاً، و[add\_header](https://nginx.org/en/docs/http/ngx%5Fhttp%5Fheaders%5Fmodule.html?ref=arabroot.io#add%5Fheader) لا يرسل الترويسة إذا كانت قيمتها فارغة:

```nginx
map $http_origin $cors_origin {
    default                   "";
    "https://app.example.com" $http_origin;
    "http://127.0.0.1:18600"  $http_origin;
}

server {
    listen 80;
    server_name _;

    location /api/ {
        if ($request_method = OPTIONS) {
            add_header Access-Control-Allow-Origin $cors_origin always;
            add_header Access-Control-Allow-Credentials true always;
            add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE" always;
            add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
            add_header Access-Control-Max-Age 600 always;
            add_header Vary Origin always;
            return 204;
        }
        add_header Access-Control-Allow-Origin $cors_origin always;
        add_header Access-Control-Allow-Credentials true always;
        add_header Access-Control-Expose-Headers X-Request-Id always;
        add_header Vary Origin always;

        proxy_pass http://api-none:8080;
        proxy_set_header Host $host;
    }
}
```

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

- الكتلة `map` مكانها خارج `server` في سياق `http`، والمطابقة فيها تامة لأن القيم نصوص عادية وليست تعبيرات نمطية تبدأ بالعلامة `~`.
- الطلب `OPTIONS` يرد عليه Nginx بنفسه بالرمز `204` ولا يصل إلى التطبيق، وهذا يعني أيضاً أنه يمر قبل أي مصادقة في التطبيق.
- الكلمة `always` ترسل الترويسة مع ردود الأخطاء أيضاً، ودونها لا يضيفها Nginx إلا للرموز الناجحة، فيرى المطور خطأ CORS بدلاً من الخطأ الحقيقي.

والاختبار بمصدر مسموح ثم بمصدر غير مسموح:

```bash
curl -si -X OPTIONS http://127.0.0.1:18620/api/notes -H "Origin: https://app.example.com" -H "Access-Control-Request-Method: PUT" -H "Access-Control-Request-Headers: content-type"
curl -si http://127.0.0.1:18620/api/profile -H "Origin: https://evil-example.com"
```

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

```http
HTTP/1.1 204 No Content
Server: nginx/1.27.5
Date: Mon, 05 Oct 2026 20:26:49 GMT
Connection: keep-alive
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 600
Vary: Origin

HTTP/1.1 200 OK
Server: nginx/1.27.5
Date: Mon, 05 Oct 2026 20:26:50 GMT
Content-Type: application/json
Transfer-Encoding: chunked
Connection: keep-alive
X-Request-Id: 30200aaa
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: X-Request-Id
Vary: Origin

{"user":null,"email":null,"method":"GET","received":0}
```

المصدر غير المسموح لم يحصل على `Access-Control-Allow-Origin`، وبقيت الترويسات الأخرى، وهذا لا يضر لأن المتصفح لا يقرأ شيئاً منها دون ترويسة المصدر.

### في Nginx Proxy Manager

في [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/) يوجد قيدان: تبويب Advanced يضع السطور داخل كتلة `server` فلا يقبل `map`، والتوجيه `add_header` لا يصل إلى `location /` التي يولدها NPM كما شرحنا في [دليل ترويسات الأمان](https://arabroot.io/articles/%D8%AA%D8%B1%D9%88%D9%8A%D8%B3%D8%A7%D8%AA-%D8%A7%D9%84%D8%A3%D9%85%D8%A7%D9%86-hsts-%D9%88-csp/#config-npm). والحل أن تضع `map` في الملف `/data/nginx/custom/http_top.conf`، وهو ملف يضمنه NPM في أعلى سياق `http` كما يبين [توثيق الإعداد المتقدم](https://nginxproxymanager.com/advanced-config/?ref=arabroot.io)، فإذا كان مجلد البيانات في ملف Compose هو `./data:/data` فالملف على السيرفر هو `./data/nginx/custom/http_top.conf`:

```nginx
map $http_origin $cors_origin {
    default                   "";
    "https://app.example.com" $http_origin;
    "http://127.0.0.1:18600"  $http_origin;
}
```

ثم في تبويب Advanced الخاص بال Proxy Host نستخدم `more_set_headers` من وحدة [headers-more](https://github.com/openresty/headers-more-nginx-module?ref=arabroot.io) المدمجة في OpenResty:

```nginx
more_set_headers "Access-Control-Allow-Origin: $cors_origin";
more_set_headers "Access-Control-Allow-Credentials: true";
more_set_headers "Access-Control-Allow-Methods: GET, POST, PUT, DELETE";
more_set_headers "Access-Control-Allow-Headers: Content-Type, Authorization";
more_set_headers "Access-Control-Expose-Headers: X-Request-Id";
more_set_headers "Access-Control-Max-Age: 600";
more_set_headers "Vary: Origin";

if ($request_method = OPTIONS) {
    return 204;
}
```

أعد تشغيل NPM بعد إنشاء الملف الأول حتى يقرأه، ثم اختبر، والترويسة `Host` في الأمرين تختار ال Proxy Host الذي اسمه `api.example.com`، وNPM هنا على المنفذ `18623`:

```bash
curl -si -X OPTIONS http://127.0.0.1:18623/api/notes -H "Host: api.example.com" -H "Origin: https://app.example.com" -H "Access-Control-Request-Method: PUT" -H "Access-Control-Request-Headers: content-type"
curl -si http://127.0.0.1:18623/api/profile -H "Host: api.example.com" -H "Origin: https://evil-example.com"
```

```http
HTTP/1.1 204 No Content
Server: openresty
Date: Mon, 05 Oct 2026 20:32:22 GMT
Connection: keep-alive
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Expose-Headers: X-Request-Id
Access-Control-Max-Age: 600
Vary: Origin

HTTP/1.1 200 OK
Server: openresty
Date: Mon, 05 Oct 2026 20:32:22 GMT
Content-Type: application/json
Transfer-Encoding: chunked
Connection: keep-alive
X-Request-Id: ea7797f7
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Expose-Headers: X-Request-Id
Access-Control-Max-Age: 600
Vary: Origin
X-Served-By: api.example.com

{"user":null,"email":null,"method":"GET","received":0}
```

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

- `more_set_headers` بقيمة فارغة لا يرسل الترويسة، لذلك لم يحصل المصدر غير المسموح على `Access-Control-Allow-Origin`.
- الشرط `if` في سياق `server` يرد على كل طلب `OPTIONS` لهذا المضيف قبل أن يصل إلى التطبيق، فإذا كان التطبيق يحتاج إلى `OPTIONS` لغرض آخر، مثل WebDAV، فلا تستخدم هذا الإعداد على كل المضيف.
- `more_set_headers "Vary: Origin"` يستبدل أي `Vary` يرسله التطبيق، فإذا كان التطبيق يرسل `Vary: Accept-Encoding` فاكتب القيمتين معاً.

### في Traefik

في [Traefik](https://arabroot.io/articles/%D8%AA%D8%AB%D8%A8%D9%8A%D8%AA-traefik-reverse-proxy/) يوفر [ال Middleware من نوع headers](https://doc.traefik.io/traefik/reference/routing-configuration/http/middlewares/headers/?ref=arabroot.io) خيارات CORS جاهزة، ويرد على الطلب التمهيدي بنفسه دون أن يمرره إلى الخدمة. وفي وسوم Labels ال Container:

```yaml
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.api.rule=PathPrefix(`/api/`)"
      - "traefik.http.routers.api.entrypoints=web"
      - "traefik.http.routers.api.middlewares=api-cors"
      - "traefik.http.services.api.loadbalancer.server.port=8080"
      - "traefik.http.middlewares.api-cors.headers.accesscontrolalloworiginlist=https://app.example.com,http://127.0.0.1:18600"
      - "traefik.http.middlewares.api-cors.headers.accesscontrolallowmethods=GET,POST,PUT,DELETE"
      - "traefik.http.middlewares.api-cors.headers.accesscontrolallowheaders=Content-Type,Authorization"
      - "traefik.http.middlewares.api-cors.headers.accesscontrolallowcredentials=true"
      - "traefik.http.middlewares.api-cors.headers.accesscontrolexposeheaders=X-Request-Id"
      - "traefik.http.middlewares.api-cors.headers.accesscontrolmaxage=600"
      - "traefik.http.middlewares.api-cors.headers.addvaryheader=true"
```

والقاعدة `PathPrefix` وحدها هنا للتجربة، أما في الإنتاج فاكتب معها `Host` والمدخل `websecure` كما في دليل Traefik. والاختبار:

```bash
curl -si -X OPTIONS http://127.0.0.1:18621/api/notes -H "Origin: https://app.example.com" -H "Access-Control-Request-Method: PUT" -H "Access-Control-Request-Headers: content-type"
curl -si http://127.0.0.1:18621/api/profile -H "Origin: https://evil-example.com"
```

```http
HTTP/1.1 200 OK
Access-Control-Allow-Credentials: true
Access-Control-Allow-Headers: Content-Type,Authorization
Access-Control-Allow-Methods: GET,POST,PUT,DELETE
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Expose-Headers: X-Request-Id
Access-Control-Max-Age: 600
Content-Length: 0
Date: Mon, 05 Oct 2026 20:26:50 GMT

HTTP/1.1 200 OK
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: X-Request-Id
Content-Type: application/json
Date: Mon, 05 Oct 2026 20:26:50 GMT
Vary: Origin
X-Request-Id: e33ed7b9
Transfer-Encoding: chunked

{"user":null,"email":null,"method":"GET","received":0}
```

Traefik رد على الطلب التمهيدي بالرمز `200` بنفسه، ولم يصل إلى سجلات ال API أي طلب `OPTIONS`، والمصدر غير المسموح لم يحصل على ترويسة المصدر. وانتبه إلى الخيار `accessControlAllowOriginListRegex`، فهو يقبل تعبيرات نمطية، وسوف ترى في قسم الأخطاء كيف يفتح التعبير الواسع الباب لنطاق لا تملكه.

### في Caddy

لا يوجد في [Caddy](https://arabroot.io/articles/%D8%AA%D8%AB%D8%A8%D9%8A%D8%AA-caddy-reverse-proxy/) توجيه خاص بـ CORS، ولكن [المطابقات Matchers](https://caddyserver.com/docs/caddyfile/matchers?ref=arabroot.io) و[التوجيه header](https://caddyserver.com/docs/caddyfile/directives/header?ref=arabroot.io) تكفي، والمطابق `header` إذا تكرر لنفس الترويسة بقيم مختلفة يطابق أياً منها:

```caddyfile
:80 {
	@cors_preflight {
		method OPTIONS
		header Origin https://app.example.com
		header Origin http://127.0.0.1:18600
		header Access-Control-Request-Method *
	}
	@cors_allowed {
		header Origin https://app.example.com
		header Origin http://127.0.0.1:18600
	}

	handle @cors_preflight {
		header {
			Access-Control-Allow-Origin "{http.request.header.Origin}"
			Access-Control-Allow-Credentials true
			Access-Control-Allow-Methods "GET, POST, PUT, DELETE"
			Access-Control-Allow-Headers "Content-Type, Authorization"
			Access-Control-Max-Age 600
			Vary Origin
		}
		respond 204
	}

	header @cors_allowed {
		Access-Control-Allow-Origin "{http.request.header.Origin}"
		Access-Control-Allow-Credentials true
		Access-Control-Expose-Headers X-Request-Id
	}
	header Vary Origin

	reverse_proxy api-none:8080
}
```

لاحظ أن Caddy يعيد المصدر من الطلب في `{http.request.header.Origin}`، وهذا آمن هنا فقط لأن المطابق `@cors_allowed` لا يطابق إلا المصادر المكتوبة في القائمة. والاختبار:

```bash
curl -si -X OPTIONS http://127.0.0.1:18622/api/notes -H "Origin: https://app.example.com" -H "Access-Control-Request-Method: PUT" -H "Access-Control-Request-Headers: content-type"
curl -si http://127.0.0.1:18622/api/profile -H "Origin: https://evil-example.com"
```

```http
HTTP/1.1 204 No Content
Access-Control-Allow-Credentials: true
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Expose-Headers: X-Request-Id
Access-Control-Max-Age: 600
Server: Caddy
Vary: Origin
Date: Mon, 05 Oct 2026 20:26:50 GMT

HTTP/1.1 200 OK
Content-Type: application/json
Date: Mon, 05 Oct 2026 20:26:50 GMT
Vary: Origin
Via: 1.1 Caddy
X-Request-Id: 07f0daf4
Transfer-Encoding: chunked

{"user":null,"email":null,"method":"GET","received":0}
```

وفي الطلب التمهيدي من مصدر غير مسموح لا يطابق `@cors_preflight`، فيمرر Caddy الطلب إلى التطبيق كأي طلب آخر. والإعدادات الأربعة نجحت من المتصفح بنفس الطريقة، فطلب `PUT` مع ملف تعريف الارتباط من صفحة التجربة أعاد في كل منها:

```text
[H nginx PUT] 200 {"user":"alice","email":"alice@example.com","method":"PUT","received":16} | X-Request-Id: 98815bee
[H traefik PUT] 200 {"user":"alice","email":"alice@example.com","method":"PUT","received":16} | X-Request-Id: de9c4af4
[H caddy PUT] 200 {"user":"alice","email":"alice@example.com","method":"PUT","received":16} | X-Request-Id: 4a402d4a
[H npm PUT] 200 {"user":"alice","email":"alice@example.com","method":"PUT","received":16} | X-Request-Id: f3c86a25
```

## الأخطاء الشائعة وأثرها على الأمان

### إعادة أي Origin كما وصل

رأيت في التجربة أن الموقع الخبيث قرأ بيانات المستخدم من ال API الذي يعكس المصدر، وهذا أخطر خطأ في الموضوع كله، لأنه يلغي سياسة المصدر الواحد لهذا ال API بالكامل، ويحدث غالباً بسطر واحد كتبه مطور يريد أن يختفي خطأ النجمة مع ملفات تعريف الارتباط. ويصفه [دليل OWASP لاختبار CORS](https://owasp.org/www-project-web-security-testing-guide/latest/4-Web%5FApplication%5FSecurity%5FTesting/11-Client-side%5FTesting/07-Testing%5FCross%5FOrigin%5FResource%5FSharing?ref=arabroot.io) و[شرح PortSwigger لثغرات CORS](https://portswigger.net/web-security/cors?ref=arabroot.io) كأول ما يبحث عنه المختبر. لذلك لا تقم بإعادة `Origin` إلا بعد مطابقته بقائمة صريحة.

📌

في أكتوبر 2016 نشر James Kettle من شركة PortSwigger بحثاً بعنوان [Exploiting CORS misconfigurations for Bitcoins and bounties](https://portswigger.net/research/exploiting-cors-misconfigurations-for-bitcoins-and-bounties?ref=arabroot.io)، وجد فيه منصة لتداول البيتكوين، لم يذكر اسمها، تعيد أي `Origin` مع `Allow-Credentials: true`، فكتب صفحة تقرأ مفتاح ال API الخاص بالمستخدم الذي يزورها، وبهذا المفتاح كان يستطيع تعطيل التنبيهات وتحويل الرصيد إلى أي عنوان، وأصلحت المنصة الخطأ خلال عشرين دقيقة من إبلاغها. ووجد في نفس البحث مواقع تثق بالمصدر `null`، وأخرى تقبل أي نطاق ينتهي باسمها. والدرس أن خطأ CORS في ال API الذي يحمل ملفات تعريف الارتباط يساوي تسريب الحساب كله.

### الثقة بالمصدر null

يرسل المتصفح القيمة `Origin: null` في حالات منها الإطار المعزول Sandboxed iframe والملفات المحلية وبعض التحويلات، لذلك تضيفها بعض الشروحات إلى القائمة لتعمل الصفحة المفتوحة من ملف محلي. ولكن أي موقع يستطيع أن يضع إطاراً معزولاً في صفحته فيرسل طلباته بالمصدر `null`، ولاحظ ما يفعله ال API الذي يعكس المصدر:

```bash
curl -si http://127.0.0.1:18604/api/profile -H "Origin: null"
```

```http
HTTP/1.1 200 OK
Access-Control-Allow-Origin: null
Access-Control-Allow-Credentials: true
X-Request-Id: 2091067e
Content-Type: application/json
Date: Mon, 05 Oct 2026 21:03:48 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked

{"user":null,"email":null,"method":"GET","received":0}
```

لذلك لا تضع `null` في القائمة إطلاقاً، وللتطوير المحلي شغل الصفحة على سيرفر محلي مثل `http://localhost:5173` وأضف هذا المصدر في قائمة بيئة التطوير وحدها.

### التعبير النمطي الذي يطابق نطاقاً لا تملكه

عندما تكثر النطاقات الفرعية يكتب المطور تعبيراً نمطياً بدلاً من القائمة، مثل التالي في Nginx:

```nginx
map $http_origin $cors_origin {
    default                      "";
    "~^https://.*example\.com$"  $http_origin;
}
```

```bash
curl -si http://127.0.0.1:18625/api/profile -H "Origin: https://evil-example.com"
```

```http
HTTP/1.1 200 OK
Server: nginx/1.27.5
Date: Mon, 05 Oct 2026 21:04:00 GMT
Content-Type: application/json
Transfer-Encoding: chunked
Connection: keep-alive
X-Request-Id: 130f6c85
Access-Control-Allow-Origin: https://evil-example.com
Vary: Origin

{"user":null,"email":null,"method":"GET","received":0}
```

التعبير `.*example\.com` يطابق `evil-example.com`، وهو نطاق يستطيع أي شخص أن يشتريه. ونفس المشكلة في الشروط التي تبحث عن جزء من النص، مثل `origin.includes('example.com')` التي تقبل `https://example.com.attacker.net`، أو `endsWith('example.com')` التي تقبل `evil-example.com`. والحل الأنسب قائمة صريحة، وإذا اضطررت إلى تعبير نمطي فاجعل النقطة قبل النطاق إلزامية وثبت البداية والنهاية، مثل `^https://[a-z0-9-]+\.example\.com$`، وتذكر أن هذا يثق بكل نطاق فرعي، بما فيها نطاق قديم نسيت حذف سجله في ال DNS.

### النجمة مع ملفات تعريف الارتباط، ونسيان Vary

رأيت أن المتصفح يرفض `*` مع `credentials: 'include'`، فإذا ظهرت لك هذه الرسالة فالحل قائمة بالمصادر وليس الانعكاس. والنجمة مقبولة فقط لبيانات عامة لا تحتاج إلى دخول، مثل الخطوط وملفات JSON المفتوحة للجميع، وعندها لا تحتاج إلى `Vary: Origin` لأن الرد واحد لكل المصادر. أما الرد الذي يتغير بحسب المصدر فيحتاج إلى `Vary: Origin` دائماً، وإلا حفظ ال Cache رداً لمصدر وأعاده لمصدر آخر كما شرحنا.

### CORS ليست مصادقة ولا صلاحيات

رأيت أن `curl` قرأ البيانات من ال API الذي يستخدم القائمة دون أي مشكلة، فالقائمة لا تحمي البيانات ممن يملك التوكن، ولا تمنع سكربتاً على سيرفر من طلب ال API، ولا تمنع الطلب نفسه من الوصول والتنفيذ. لذلك لا تقم بإخفاء API داخلي خلف قائمة CORS ظناً أنها تحميه، فمكان الحماية هو المصادقة في التطبيق، وقوائم العناوين وطبقات الحماية التي شرحناها في دليل [حماية تطبيقات الويب على مستوى HTTP](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/).

### المصادقة قبل الطلب التمهيدي

الطلب التمهيدي لا يحمل ملفات تعريف الارتباط ولا ترويسة `Authorization`، فإذا كان أمام ال API Middleware يرفض كل طلب دون توكن، مثل Forward Auth في ال Reverse Proxy أو فحص التوكن في أول التطبيق، فسوف يرد على `OPTIONS` بالرمز `401` دون ترويسات CORS، ويرى المطور في Console رسالة `Response to preflight request doesn't pass access control check` ويظن المشكلة في CORS. والحل أن يمر `OPTIONS` قبل المصادقة، كما فعلنا في الدالة `applyCors`، وكما يفعل Traefik عندما يرد على الطلب التمهيدي بنفسه.

### أخطاء تبدو كأنها CORS وليست كذلك

عندما يرد السيرفر بخطأ من طبقة لا تضيف ترويسات CORS، مثل ال Reverse Proxy الذي يرفض ملفاً كبيراً، يرى المتصفح رداً دون `Access-Control-Allow-Origin` فيكتب رسالة CORS، ويخفي الرمز الحقيقي عن الكود. ولترى ذلك بنفسك ضع أمام ال API الذي يستخدم القائمة Nginx بحد رفع 1 KB:

```nginx
server {
    listen 80;
    server_name _;
    client_max_body_size 1k;

    location /api/ {
        proxy_pass http://api-allowlist:8080;
        proxy_set_header Host $host;
    }
}
```

ثم أرسل من الصفحة طلب `POST` بحجم 5 KB تقريباً:

```js
run('G upload 5KB via small-body proxy', 'http://127.0.0.1:18611/api/upload', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ data: 'x'.repeat(5000) }) })
```

```text
Access to fetch at 'http://127.0.0.1:18611/api/upload' from origin 'http://127.0.0.1:18600' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
Failed to load resource: net::ERR_FAILED
[G upload 5KB via small-body proxy] TypeError: Failed to fetch
```

والرسالة تقول CORS، أما سجل Nginx فيقول شيئاً آخر:

```text
"OPTIONS /api/upload HTTP/1.1" 204 0 "http://127.0.0.1:18600/"
[error] 22#22: *1 client intended to send too large body: 5011 bytes, client: 192.168.80.1, server: _, request: "POST /api/upload HTTP/1.1", host: "127.0.0.1:18611", referrer: "http://127.0.0.1:18600/"
"POST /api/upload HTTP/1.1" 413 585 "http://127.0.0.1:18600/"
```

فالطلب التمهيدي مر إلى التطبيق ونجح، ثم رفض Nginx الطلب الحقيقي بالرمز `413` من صفحة خطأ لا تحمل ترويسات CORS. ونفس الشيء يحدث مع `502` عندما يتوقف التطبيق، و`504` عندما يتأخر، و`401` من طبقة مصادقة أمامه، لذلك عندما ترى خطأ CORS ظهر فجأة على طلب كان يعمل، فافتح تبويب Network وانظر إلى الرمز الحقيقي قبل أن تعدل أي ترويسة. وتجد تشخيص `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/#diagnose)، و`504` في دليل [تطبيقك خلف 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/#troubleshooting)، وإذا كنت تضيف CORS في Nginx فالكلمة `always` تجعل صفحات الأخطاء التي يولدها التطبيق تحمل الترويسات أيضاً.

## CORS وCSP وCSRF وSameSite: ما الفرق؟

هذه أربعة أسماء تتكرر في نفس النقاش، وكل منها يحل مشكلة مختلفة:

| الآلية   | من يرسلها أو يطبقها                                 | ما الذي تفعله                                                                                                                                                                                                                                                       |
| -------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| CORS     | ال API يرسل الترويسات، والمتصفح ينفذ                | تخفف سياسة المصدر الواحد، فتسمح لمصادر محددة بقراءة ردود ال API                                                                                                                                                                                                     |
| CSP      | صفحتك ترسلها، والمتصفح ينفذ                         | تقيد ما تحمله صفحتك وتشغله من سكربتات وموارد، وأهم فائدتها الحماية من XSS، وتفصيلها في [دليل ترويسات الأمان](https://arabroot.io/articles/%D8%AA%D8%B1%D9%88%D9%8A%D8%B3%D8%A7%D8%AA-%D8%A7%D9%84%D8%A3%D9%85%D8%A7%D9%86-hsts-%D9%88-csp/#csp)                     |
| CSRF     | هجوم، وتمنعه التوكنات في التطبيق                    | موقع آخر يرسل باسم المستخدم طلباً يغير البيانات، وCORS لا تمنعه لأن الطلب يصل وينفذ                                                                                                                                                                                 |
| SameSite | التطبيق يضعها على ملف تعريف الارتباط، والمتصفح ينفذ | تحدد هل يرسل ملف تعريف الارتباط مع الطلبات القادمة من موقع آخر، وهي أهم طبقة ضد CSRF، وتفصيلها في [قسم ملفات تعريف الارتباط](https://arabroot.io/articles/%D8%AA%D8%B1%D9%88%D9%8A%D8%B3%D8%A7%D8%AA-%D8%A7%D9%84%D8%A3%D9%85%D8%A7%D9%86-hsts-%D9%88-csp/#cookies) |

## كيف تشخص خطأ CORS من أدوات المطور؟

افتح أدوات المطور، ثم تبويب [Network](https://developer.chrome.com/docs/devtools/network/reference?ref=arabroot.io)، وأعد الطلب، ثم امش مع القائمة التالية بالترتيب:

1. ابحث عن صف الطلب التمهيدي، ويظهر في Chrome بالنوع `preflight` والطريقة `OPTIONS` قبل الطلب الحقيقي، فإذا لم يكن موجوداً فالطلب بسيط أو أن المتصفح حفظ الإذن سابقاً.
2. انظر إلى رمز الرد في كل صف، فإذا كان `401` أو `403` فالمصادقة أمام `OPTIONS`، وإذا كان `413` أو `502` أو `504` فالمشكلة ليست في CORS أصلاً.
3. افتح ترويسات الطلب Request Headers وتأكد من قيمة `Origin` بالضبط، بالبروتوكول والمنفذ، ثم قارنها بالقائمة في السيرفر حرفاً بحرف، فالشرطة المائلة في الآخر أو `http` بدلاً من `https` تكفي لفشل المطابقة.
4. افتح ترويسات الرد Response Headers وتأكد أن `Access-Control-Allow-Origin` موجودة مرة واحدة فقط، وأن قيمتها هي المصدر وليست النجمة إذا كان الطلب يحمل ملفات تعريف الارتباط.
5. في الطلب التمهيدي تأكد أن `Access-Control-Allow-Methods` فيها الطريقة، وأن `Access-Control-Allow-Headers` فيها كل ترويسة في `Access-Control-Request-Headers`.
6. أعد نفس الطلب بالأمر `curl -si` مع الترويسة `Origin`، فإذا ظهرت الترويسات الصحيحة في `curl` وغابت في المتصفح، فابحث عن Cache في الطريق يحتاج إلى `Vary: Origin`.

والرسائل الأكثر تكراراً في Console، كما ظهرت في Chrome، ومعناها:

| الرسالة                                                                        | معناها                                                                                                  |
| ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------- |
| No 'Access-Control-Allow-Origin' header is present on the requested resource   | الرد ليس فيه ترويسة المصدر، إما لأن المصدر خارج القائمة، أو لأن الرد صفحة خطأ من طبقة لا تضيف الترويسات |
| Response to preflight request doesn't pass access control check                | فشل الطلب التمهيدي، والطلب الحقيقي لم يرسل، فافحص رد OPTIONS ورمزه                                      |
| must not be the wildcard '\*' when the request's credentials mode is 'include' | النجمة مع ملفات تعريف الارتباط، والحل قائمة بالمصادر                                                    |
| The 'Access-Control-Allow-Origin' header contains multiple values              | التطبيق وال Reverse Proxy يضيفان الترويسة معاً، فاحذفها من أحدهما                                       |

وتجد بقية الرسائل وأسبابها في [صفحة أخطاء CORS في MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS/Errors?ref=arabroot.io). وبعد أن تنتهي من التجربة احذف المختبر بالأمر:

```bash
docker compose down -v
```

## الخلاصة

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

- المصدر هو البروتوكول واسم المضيف والمنفذ معاً، والنطاق الفرعي أو المنفذ المختلف مصدر آخر، وسياسة المصدر الواحد تمنع الصفحة من قراءة ردود مصدر آخر ولا تمنعها من إرسال الطلب.
- CORS طريقة يخبر بها السيرفر المتصفح بالمصادر التي يسمح لها بالقراءة، فهي تفتح باباً ولا تضيف حماية، والمتصفح وحده هو الذي ينفذها، أما `curl` والسيرفرات فلا تعرفها.
- الطلب البسيط يصل وينفذ ثم يحجب رده، والطلب الذي يحتاج إلى Preflight لا يرسل إلا بعد أن يوافق السيرفر على `OPTIONS`، و`Max-Age` يقلل تكرار هذا الطلب.
- مع ملفات تعريف الارتباط لا تقبل النجمة، والحل قائمة صريحة بالمصادر مع `Allow-Credentials: true` و`Vary: Origin`، أما إعادة أي `Origin` أو قبول `null` أو تعبير نمطي واسع فكلها تفتح بيانات المستخدم لأي موقع.
- أفضل إعداد هو ألا تحتاج إلى CORS، بجمع الواجهة وال API تحت نطاق واحد عبر المسار `/api`، ثم التطبيق نفسه، ثم ال Reverse Proxy للحالات البسيطة، ولا تضف الترويسات في المكانين معاً.
- مرر `OPTIONS` قبل المصادقة، وعندما ترى خطأ CORS فجأة فانظر في تبويب Network إلى الرمز الحقيقي، فقد يكون `401` أو `413` أو `502`.

[![](https://arabroot.io/content/images/2026/10/security-headers-cover-cover.webp)دليلHSTS وCSP: ترويسات الأمان التي يحتاجها كل تطبيقما الذي تطلبه كل ترويسة أمان من المتصفح، ولماذا يصعب التراجع عن HSTS، وكيف تبني سياسة CSP لا تكسر تطبيقك.](https://arabroot.io/articles/%D8%AA%D8%B1%D9%88%D9%8A%D8%B3%D8%A7%D8%AA-%D8%A7%D9%84%D8%A3%D9%85%D8%A7%D9%86-hsts-%D9%88-csp/)[![](https://arabroot.io/content/images/2026/10/behind-proxy-cover-cover.webp)دليلكيف يرى تطبيقك عنوان الزائر الحقيقي خلف ال Reverse Proxyإذا كان تطبيقك يرى عنوان ال Proxy بدلاً من الزائر أو يولد روابط http، فهذا هو السبب والإعداد الصحيح لإطار عملك.](https://arabroot.io/articles/%D8%AA%D8%B7%D8%A8%D9%8A%D9%82%D9%83-%D8%AE%D9%84%D9%81-reverse-proxy/)[![](https://arabroot.io/content/images/2026/10/dev-checklist-cover-cover.webp)دليلقبل أن تنشر تطبيقك على خادمك: قائمة تحقق للمطورالأخطاء التي لا تظهر إلا بعد نشر تطبيقك في Docker خلف Reverse Proxy، وكيف تعالج كل واحد منها قبل أن يصل إلى المستخدمين.](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/)[![](https://arabroot.io/content/images/2026/10/oauth-oidc-cover-cover.webp)دليلشرح OAuth وOpenID Connect للمبتدئينلماذا لا يجب أن يعرف أي تطبيق كلمة مرورك، وكيف يحل 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/)[![](https://arabroot.io/content/images/2026/10/upload-limits-cover-cover.webp)دليللماذا يفشل رفع الملفات الكبيرة؟ وأين تعدل حد الحجميمر الملف المرفوع بعدة طبقات لكل منها حد للحجم والزمن، فكيف تعرف الطبقة التي رفضته من شكل الخطأ وأين تعدل حدها.](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/)

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

- أكتوبر 2026: كتابة المقال، واختبار CORS في Chrome 154 على API بـ Node.js 22، وإعدادات Nginx 1.27 وNginx Proxy Manager 2.16.0 وTraefik 3.7.13 وCaddy 2.11.4.