الأدلة التقنية

CORS: لماذا يرفض المتصفح طلبك، وكيف تسمح به بأمان

لماذا يمنع المتصفح صفحتك من قراءة رد ال API، وكيف تفتح CORS هذا الباب لمصادر محددة دون أن تفتحه لكل موقع.

CORS: لماذا يرفض المتصفح طلبك، وكيف تسمح به بأمان

تكتب الواجهة الأمامية 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. وفي دليل ترويسات الأمان بالتفصيل شرحنا الترويسات التي تقيد المتصفح، أما هذا المقال فهو المرجع لترويسات 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 ومسرد MDN. أما المسار /orders وما بعد علامة الاستفهام فلا يدخلان فيه، لذلك تعد كل صفحات https://app.example.com مصدراً واحداً مهما اختلفت مساراتها، ويكفي أن يختلف جزء واحد من الثلاثة حتى يصبح الرابط مصدراً آخر.

مخطط يقسم الرابط https://app.example.com:443/orders?id=7 إلى البروتوكول واسم المضيف والمنفذ وهي المصدر، والمسار والاستعلام وهما خارج المصدر، ثم جدول يقارن ستة روابط بالمصدر https://app.example.com فيبين أن اختلاف المسار أو كتابة المنفذ 443 صراحة لا يغير المصدر، وأن اختلاف البروتوكول أو النطاق الفرعي أو المنفذ أو النطاق الرئيسي يجعله مصدراً مختلفاً
المصدر هو البروتوكول واسم المضيف والمنفذ، واختلاف أي منها يعني مصدراً آخر

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

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

مخطط متصفح بتبويبين، الأول موقع البنك مسجل الدخول والثاني موقع خبيث يشغل سكربت fetch إلى bank.example مع credentials include، وفي الخطوة الأولى يخرج الطلب ومعه ترويسة Origin وملف تعريف الارتباط session، وفي الثانية يرد البنك ببيانات الحساب دون Access-Control-Allow-Origin، وفي الثالثة يحتفظ المتصفح بالرد ويظهر للصفحة الخطأ TypeError: Failed to fetch
سياسة المصدر الواحد تمنع قراءة الرد، ولا تمنع إرسال الطلب

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

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

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

وقد يتساءل البعض: إذا كان الطلب يصل وينفذ، فما الذي يمنع موقعاً خبيثاً من أن يرسل باسمك طلب تحويل مبلغ أو حذف حساب؟ والإجابة أن هذا هجوم آخر اسمه تزوير الطلبات عبر المواقع Cross-Site Request Forgery واختصاراً CSRF، وسياسة المصدر الواحد لا تحمي منه، وإنما يحمي منه التوكن Token الخاص بكل نموذج في التطبيق، والخاصية SameSite على ملف تعريف الارتباط التي شرحناها في قسم ملفات تعريف الارتباط في دليل ترويسات الأمان، كما يفصل دليل OWASP للحماية من CSRF. لذلك لا تعتمد على 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 واختصاراً CORS، وهي اليوم جزء من معيار Fetch من WHATWG الذي تتبعه كل المتصفحات. والفكرة بسيطة: المتصفح يخبر السيرفر من أي مصدر جاء الطلب، والسيرفر يرد بترويسات تقول هل يسمح لهذا المصدر أن يقرأ الرد أم لا، والمتصفح هو الذي ينفذ القرار. لاحظ أن القرار في يد صاحب ال API، وليس في يد الصفحة التي تطلب، وهذا منطقي لأن البيانات بياناته.

كيف تعمل CORS؟

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

عندما يرسل السكربت طلباً إلى مصدر آخر، يضيف المتصفح من تلقاء نفسه الترويسة Origin وفيها مصدر الصفحة، ولا يستطيع السكربت أن يغيرها. فإذا كان الطلب بسيطاً أرسله المتصفح مباشرة، ثم قرأ في الرد الترويسة Access-Control-Allow-Origin، فإذا كانت قيمتها نفس المصدر أو النجمة * سلم الرد للسكربت، وإلا احتفظ به وأعطى السكربت الخطأ 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 من ثلاث قيم
في الطلب البسيط يرسل المتصفح الطلب مباشرة، ثم يقرر من ترويسة الرد هل يسلمه للصفحة

والطلب البسيط بحسب معيار Fetch هو الطلب الذي تتحقق فيه ثلاثة شروط معاً: الطريقة Method هي GET أو HEAD أو POST، ولا يضيف السكربت إلا ترويسات من قائمة محددة مثل 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 بالطريقة 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 يجعل الطلب التالي يتجاوز الخطوتين الأوليين
الطلب التمهيدي يسأل السيرفر عن الطريقة والترويسات قبل إرسال الطلب الحقيقي

وفي الطلب التمهيدي يرسل المتصفح ترويستين بالإضافة إلى Origin، الأولى Access-Control-Request-Method وفيها الطريقة التي يريدها، والثانية Access-Control-Request-Headers وفيها الترويسات غير البسيطة. ويرد السيرفر بالترويسات التالية:

الترويسةمتىمعناها
Access-Control-Allow-Originكل ردالمصدر المسموح له بالقراءة، قيمة واحدة فقط أو *
Access-Control-Allow-Methodsالطلب التمهيديالطرق المسموحة مثل GET, POST, PUT, DELETE
Access-Control-Allow-Headersالطلب التمهيديالترويسات التي يسمح للسكربت أن يرسلها مثل Content-Type, Authorization
Access-Control-Max-Ageالطلب التمهيديعدد الثواني التي يحفظ فيها المتصفح الإذن فلا يكرر الطلب التمهيدي
Access-Control-Allow-Credentialsكل ردالقيمة true تسمح بقراءة الرد على طلب يحمل ملفات تعريف الارتباط
Access-Control-Expose-Headersالرد الحقيقيترويسات الرد التي يسمح للسكربت أن يقرأها، مثل X-Request-Id

وفي القيمة Max-Age لاحظ أن المتصفحات تضع لها حداً أعلى، فبحسب توثيق MDN يحفظ Chrome الإذن ساعتين على الأكثر وFirefox أربعاً وعشرين ساعة، وإذا لم ترسل الترويسة فالمدة الافتراضية خمس ثوان فقط، أي أن كل طلب تقريباً سوف يسبقه طلب تمهيدي. ويجدر الانتباه إلى أن الطلب التمهيدي نفسه لا يحمل ملفات تعريف الارتباط ولا ترويسة Authorization، كما ينص المعيار، وهذا سوف يهمنا عندما نتكلم عن المصادقة Authentication.

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

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

لا يرسل fetch ملفات تعريف الارتباط إلى مصدر آخر إلا إذا طلب منه السكربت ذلك بالخيار credentials: "include"، وعندها يشترط المتصفح على الرد شرطين: أن تكون Access-Control-Allow-Origin هي المصدر نفسه وليست النجمة، وأن يرسل السيرفر Access-Control-Allow-Credentials: true، كما يفصل قسم بيانات الدخول في المعيار. والسبب أن النجمة تعني «أي موقع في العالم»، ولو سمح المتصفح بها مع ملفات تعريف الارتباط لعدنا إلى مشكلة البنك نفسها، لذلك يرفض هذه التركيبة حتى لو أرسلها السيرفر.

مخطط من جزأين، في الأيمن طريقتان خاطئتان: الأولى أن يرد ال 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 فيمنع المتصفح القراءة
مع ملفات تعريف الارتباط يرفض المتصفح النجمة، والبديل الصحيح قائمة بالمصادر وليس نسخ Origin

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

ويجدر الإشارة هنا إلى أن الواجهات التي ترسل التوكن في الترويسة Authorization: Bearer …، كما في تطبيقات الصفحة الواحدة التي تستخدم OAuth وOpenID Connect، لا تحتاج إلى credentials: "include" ولا إلى Allow-Credentials، لأن التوكن ليس ملف تعريف ارتباط يرسله المتصفح وحده، ولكنها تحتاج إلى أن يسمح الطلب التمهيدي بالترويسة Authorization.

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

عندما يختار السيرفر قيمة Access-Control-Allow-Origin بحسب الطلب، يصبح الرد مختلفاً من مصدر لآخر، فإذا كان بين المتصفح والسيرفر Cache، مثل Cloudflare أو Varnish أو Cache المتصفح نفسه، فقد يحفظ الرد الذي صدر لمصدر ما ويعيده لمصدر آخر، فإما أن تفشل صفحة مشروعة دون سبب ظاهر، وإما أن تصل ترويسة مسموحة إلى من لا يستحقها. والحل أن يرسل السيرفر Vary: Origin مع كل رد تختلف فيه هذه الترويسة، وهذا ما يوصي به قسم CORS وال Cache في المعيار، فيعرف أي 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:

// 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 ? '[email protected]' : 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:

<!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 بأوضاعه الأربعة، والصفحة نفسها مرتين على مصدرين:

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"]
docker compose up -d

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

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

اكتب في Console:

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

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

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:

docker compose logs --no-log-prefix api-none
GET /api/profile origin=http://127.0.0.1:18600 cookie=-

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

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

run('C2 no-CORS PUT json', 'http://127.0.0.1:18601/api/notes', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: '{"text":"hello"}' })
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، أي أن الطلب التمهيدي هو الذي فشل، وفي السجلات سوف تجد سطراً واحداً:

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

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

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

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

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/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

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

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/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:

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"}' })
[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' في الطلبين:

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' })
[D login] 200 {"ok":true} | X-Request-Id: 608b0aad
[D2 profile with cookie] 200 {"user":"alice","email":"[email protected]","method":"GET","received":0} | X-Request-Id: eea6d3e6

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

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')
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 الذي يعكس أي مصدر:

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' })
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":"[email protected]","method":"GET","received":0} | X-Request-Id: null

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

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 الذي يستخدم القائمة، بمصدر غير مسموح وملف تعريف ارتباط:

curl -si http://127.0.0.1:18602/api/profile -H "Origin: http://127.0.0.1:18609" -H "Cookie: session=abc123"
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":"[email protected]","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 في Express، وdjango-cors-headers في Django، وسياسات CORS في ASP.NET Core، وإعداد CORS في Spring، والملف config/cors.php في Laravel. وفي كل هذه الأدوات اكتب المصادر صراحة، وانتبه إلى الخيارات التي تقبل 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
عندما يجمع ال Reverse Proxy الواجهة وال API تحت مصدر واحد يختفي سؤال CORS كله

والإعداد في 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 وملف تعريف الارتباط:

run('K same origin /api', '/api/profile', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: '{"text":"hello"}', credentials: 'include' })
[K same origin /api] 200 {"user":"alice","email":"[email protected]","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 في مساراته فراجع قسم التشغيل تحت مسار فرعي في دليل التطبيق خلف ال Reverse Proxy. لذلك إذا كنت تبني الواجهة وال API معاً فابدأ بهذا التصميم، واترك CORS للحالات التي لا يمكن فيها جمعهما، مثل API عام يستخدمه عملاء على نطاقاتهم، أو أداة تضعها على مواقع أخرى.

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

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

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، فإذا كان المصدر في القائمة أخذ المتغير قيمته، وإلا بقي فارغاً، وadd_header لا يرسل الترويسة إذا كانت قيمتها فارغة:

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 بدلاً من الخطأ الحقيقي.

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

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/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 يوجد قيدان: تبويب Advanced يضع السطور داخل كتلة server فلا يقبل map، والتوجيه add_header لا يصل إلى location / التي يولدها NPM كما شرحنا في دليل ترويسات الأمان. والحل أن تضع map في الملف /data/nginx/custom/http_top.conf، وهو ملف يضمنه NPM في أعلى سياق http كما يبين توثيق الإعداد المتقدم، فإذا كان مجلد البيانات في ملف Compose هو ./data:/data فالملف على السيرفر هو ./data/nginx/custom/http_top.conf:

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 المدمجة في OpenResty:

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:

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/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 يوفر ال Middleware من نوع headers خيارات CORS جاهزة، ويرد على الطلب التمهيدي بنفسه دون أن يمرره إلى الخدمة. وفي وسوم Labels ال Container:

    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. والاختبار:

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/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 توجيه خاص بـ CORS، ولكن المطابقات Matchers والتوجيه header تكفي، والمطابق header إذا تكرر لنفس الترويسة بقيم مختلفة يطابق أياً منها:

: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 لا يطابق إلا المصادر المكتوبة في القائمة. والاختبار:

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/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 مع ملف تعريف الارتباط من صفحة التجربة أعاد في كل منها:

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

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

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

رأيت في التجربة أن الموقع الخبيث قرأ بيانات المستخدم من ال API الذي يعكس المصدر، وهذا أخطر خطأ في الموضوع كله، لأنه يلغي سياسة المصدر الواحد لهذا ال API بالكامل، ويحدث غالباً بسطر واحد كتبه مطور يريد أن يختفي خطأ النجمة مع ملفات تعريف الارتباط. ويصفه دليل OWASP لاختبار CORS وشرح PortSwigger لثغرات CORS كأول ما يبحث عنه المختبر. لذلك لا تقم بإعادة Origin إلا بعد مطابقته بقائمة صريحة.

📌
في أكتوبر 2016 نشر James Kettle من شركة PortSwigger بحثاً بعنوان Exploiting CORS misconfigurations for Bitcoins and bounties، وجد فيه منصة لتداول البيتكوين، لم يذكر اسمها، تعيد أي Origin مع Allow-Credentials: true، فكتب صفحة تقرأ مفتاح ال API الخاص بالمستخدم الذي يزورها، وبهذا المفتاح كان يستطيع تعطيل التنبيهات وتحويل الرصيد إلى أي عنوان، وأصلحت المنصة الخطأ خلال عشرين دقيقة من إبلاغها. ووجد في نفس البحث مواقع تثق بالمصدر null، وأخرى تقبل أي نطاق ينتهي باسمها. والدرس أن خطأ CORS في ال API الذي يحمل ملفات تعريف الارتباط يساوي تسريب الحساب كله.

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

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

curl -si http://127.0.0.1:18604/api/profile -H "Origin: null"
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:

map $http_origin $cors_origin {
    default                      "";
    "~^https://.*example\.com$"  $http_origin;
}
curl -si http://127.0.0.1:18625/api/profile -H "Origin: https://evil-example.com"
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.

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

الطلب التمهيدي لا يحمل ملفات تعريف الارتباط ولا ترويسة 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:

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 تقريباً:

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) }) })
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 فيقول شيئاً آخر:

"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 طبقة طبقة في دليل حدود حجم رفع الملفات، و504 في دليل تطبيقك خلف Reverse Proxy، وإذا كنت تضيف CORS في Nginx فالكلمة always تجعل صفحات الأخطاء التي يولدها التطبيق تحمل الترويسات أيضاً.

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

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

الآليةمن يرسلها أو يطبقهاما الذي تفعله
CORSال API يرسل الترويسات، والمتصفح ينفذتخفف سياسة المصدر الواحد، فتسمح لمصادر محددة بقراءة ردود ال API
CSPصفحتك ترسلها، والمتصفح ينفذتقيد ما تحمله صفحتك وتشغله من سكربتات وموارد، وأهم فائدتها الحماية من XSS، وتفصيلها في دليل ترويسات الأمان
CSRFهجوم، وتمنعه التوكنات في التطبيقموقع آخر يرسل باسم المستخدم طلباً يغير البيانات، وCORS لا تمنعه لأن الطلب يصل وينفذ
SameSiteالتطبيق يضعها على ملف تعريف الارتباط، والمتصفح ينفذتحدد هل يرسل ملف تعريف الارتباط مع الطلبات القادمة من موقع آخر، وهي أهم طبقة ضد CSRF، وتفصيلها في قسم ملفات تعريف الارتباط

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

افتح أدوات المطور، ثم تبويب Network، وأعد الطلب، ثم امش مع القائمة التالية بالترتيب:

  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. وبعد أن تنتهي من التجربة احذف المختبر بالأمر:

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.
دليلHSTS وCSP: ترويسات الأمان التي يحتاجها كل تطبيقما الذي تطلبه كل ترويسة أمان من المتصفح، ولماذا يصعب التراجع عن HSTS، وكيف تبني سياسة CSP لا تكسر تطبيقك.دليلكيف يرى تطبيقك عنوان الزائر الحقيقي خلف ال Reverse Proxyإذا كان تطبيقك يرى عنوان ال Proxy بدلاً من الزائر أو يولد روابط http، فهذا هو السبب والإعداد الصحيح لإطار عملك.دليلقبل أن تنشر تطبيقك على خادمك: قائمة تحقق للمطورالأخطاء التي لا تظهر إلا بعد نشر تطبيقك في Docker خلف Reverse Proxy، وكيف تعالج كل واحد منها قبل أن يصل إلى المستخدمين.دليلشرح OAuth وOpenID Connect للمبتدئينلماذا لا يجب أن يعرف أي تطبيق كلمة مرورك، وكيف يحل OAuth ذلك بتوكنات محدودة، وكيف يضيف OpenID Connect الدخول الموحد.دليللماذا يفشل رفع الملفات الكبيرة؟ وأين تعدل حد الحجميمر الملف المرفوع بعدة طبقات لكل منها حد للحجم والزمن، فكيف تعرف الطبقة التي رفضته من شكل الخطأ وأين تعدل حدها.

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

  • أكتوبر 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.
Could not load content
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.