لماذا ينكسر CORS بين Next.js و NestJS: ملاحظاتنا الميدانية حول طلبات Preflight وملفات تعريف الارتباط والتمرير عبر وكيل
جدول المحتويات
عن الكاتب

Ahmed Mahmoud
كاتب ومطوّر
مهندس برمجيات شغوف بتطوير الويب وتصميم تجربة المستخدم.
مؤسّس ديفيا · eng-ahmed.com ↗الخلاصة: CORS (مشاركة الموارد عبر الأصول) قاعدة يطبّقها المتصفح لتحديد ما إذا كان يُسمح لكود JavaScript في الصفحة بقراءة استجابة قادمة من أصل (origin) آخر؛ وهي لا تمنع الطلب من الوصول إلى الخادم. عندما تستدعي واجهة Next.js واجهةً برمجية NestJS مع ملفات تعريف الارتباط (cookies)، فإما أن نُرجع الأصل الدقيق مع Access-Control-Allow-Credentials: true واستجابة preflight بحالة 2xx، أو نمرّر /api عبر rewrite في Next.js فلا يُجري المتصفح أي طلب عبر الأصول أصلًا.
في كل مشروع يفصل فيه فريقنا واجهة Next.js عن واجهة برمجية NestJS، نصطدم بأخطاء CORS الثلاثة نفسها وبالترتيب نفسه. أولًا يفشل طلب الـ preflight. ثم يُرفض الأصل العام * بمجرد دخول ملفات تعريف الارتباط. ثم تُرجع كل الطلبات 200 ويبقى المستخدم غير مسجّل الدخول، لأن cookie الجلسة لا تنتقل أبدًا. هذه ملاحظاتنا الميدانية حول معنى كل خطأ، والإعداد الذي اعتمدناه.
أهم النقاط
• المتصفح هو من يطبّق CORS لا الخادم: فهو يتحكم في قدرة JavaScript في الصفحة على قراءة استجابة من أصل آخر، ولا يمنع الطلب من الوصول إلى الواجهة البرمجية.
• ترويسة Content-Type بقيمة JSON، أو ترويسة Authorization، أو استخدام PUT أو PATCH أو DELETE تجعل الطلب غير بسيط، فيرسل المتصفح أولًا طلب OPTIONS تمهيديًا (preflight) دون cookies، ويحتاج إلى استجابة 2xx بترويسات سماح مطابقة.
• الطلبات التي تحمل بيانات اعتماد (credentials: 'include') تتطلب Access-Control-Allow-Credentials: true مع الأصل الطالب نفسه بدقة؛ أما Access-Control-Allow-Origin: * فمرفوضة.
• لا تنتقل cookies عبر الأصول إلا إذا طلب الطلبُ ذلك وسمحت قيمة SameSite به؛ وبين مواقع مختلفة يعني ذلك SameSite=None; Secure، وهو ما يُفشله حظر Safari الافتراضي لملفات تعريف الارتباط من جهات خارجية.
• تمرير /api/* عبر rewrite في Next.js يجعل الواجهة البرمجية من الأصل نفسه: لا طلبات preflight، وcookies من الطرف الأول، مقابل قفزة شبكية إضافية واحدة.
ما الذي يحميه CORS فعلًا؟
يحمي CORS بيانات المستخدم داخل المتصفح، لا الخادم. الأصل (origin) هو مزيج من البروتوكول والمضيف والمنفذ، لذا فإن https://app.example.com و https://api.example.com أصلان مختلفان. تمنع سياسة الأصل نفسه (same-origin policy) الصفحة في أصل ما من قراءة استجابات أصل آخر، وترويسات استجابة CORS هي الطريقة التي يعيد بها الخادم السماح لأصول محددة.
• المتصفحات وحدها تطبّق CORS. أدوات مثل curl و Postman، ومكوّن خادم (Server Component) في Next.js، وخدمة NestJS تستدعي خدمة أخرى، لا ترسل أبدًا طلبات preflight ولا تفحص ترويسات Access-Control-*.
• الطلب المحظور ربما نُفّذ بالفعل. في الطلب البسيط عبر الأصول، يرسله المتصفح وينفّذه الخادم، ولا يُحجب عن JavaScript سوى الاستجابة.
• CORS ليس حماية من CSRF. طلب POST مزوّر من نموذج في موقع آخر يصل إلى الواجهة البرمجية مع أي cookies يرفقها المتصفح؛ وما يوقفه هو cookies بقيمة SameSite، أو رموز CSRF، أو التحقق من ترويسة Origin.
الأصل نفسه (same-origin) والموقع نفسه (same-site) اختباران مختلفان، وملفات تعريف الارتباط تتبع اختبار الموقع. الموقع هو البروتوكول مع النطاق القابل للتسجيل (eTLD+1، مثل example.com)، ولا يُحتسب المنفذ:
• من https://app.example.com إلى https://api.example.com: أصل مختلف لأن المضيف مختلف، وموقع واحد لأن كليهما على example.com.
• من http://localhost:3000 إلى http://localhost:4000: أصل مختلف لأن المنفذ مختلف، وموقع واحد لأن المنفذ لا يُحتسب.
• من shop.vercel.app إلى shop-api.vercel.app: أصل مختلف وموقع مختلف، لأن vercel.app مُدرج في قائمة اللواحق العامة (Public Suffix List).
• من http://example.com إلى https://example.com: أصل مختلف وموقع مختلف، لأن Chrome يعتمد مفهوم الموقع المرتبط بالبروتوكول (schemeful same-site).
لماذا يُطلق استدعاء fetch طلب OPTIONS تمهيديًا (preflight)؟
يرسل المتصفح طلب OPTIONS تمهيديًا قبل أي طلب عبر الأصول لا يُعدّ بسيطًا. الطلب البسيط يستخدم GET أو HEAD أو POST، ولا يضبط إلا ترويسات مدرجة في قائمة CORS الآمنة، ويرسل Content-Type بإحدى القيم text/plain أو multipart/form-data أو application/x-www-form-urlencoded. أما جسم JSON، أو ترويسة Authorization، أو ترويسة مخصصة مثل Idempotency-Key، أو طريقة PUT أو PATCH أو DELETE، فكلٌّ منها يفرض preflight، ولذلك يحصل كل استدعاء حقيقي تقريبًا على واحد. والاستدعاء المعتاد من الواجهة — fetch() بطريقة POST مع credentials: 'include' و Content-Type: application/json — يمر بـ preflight في كل مرة.
يعلن طلب الـ preflight عن الطريقة والترويسات في Access-Control-Request-Method و Access-Control-Request-Headers. ويجب أن يرد الخادم بحالة 2xx مع قيم Access-Control-Allow-Origin و Access-Control-Allow-Methods و Access-Control-Allow-Headers تغطيها. طلبات الـ preflight لا تحمل أبدًا cookies ولا ترويسة Authorization. وقد جاء أول فشل واجهناه من هذه القاعدة تحديدًا: وسيط مصادقة (middleware) مسجّل عبر app.use() قبل app.enableCors() كان يرد على كل preflight بالحالة 401، فأبلغ المتصفح عنه كخطأ CORS.
تحدد ترويسة Access-Control-Max-Age مدة تخزين المتصفح لنتيجة الـ preflight. عند غياب الترويسة تكون القيمة الافتراضية في مواصفة Fetch خمس ثوانٍ؛ ويحدّ Chromium القيمة عند 7200 ثانية (ساعتان) و Firefox عند 86400 ثانية (24 ساعة).
لماذا لا يمكن استخدام Access-Control-Allow-Origin: * مع ملفات تعريف الارتباط؟
الطلب الذي يحمل بيانات اعتماد — fetch(url, { credentials: 'include' }) أو XMLHttpRequest.withCredentials = true — لا تكون استجابته مقروءة إلا إذا أرسلت Access-Control-Allow-Credentials: true وقيمة Access-Control-Allow-Origin مساوية للأصل الطالب. وفي الطلبات التي تحمل بيانات اعتماد، يُعامَل الرمز * في Access-Control-Allow-Headers أو Access-Control-Allow-Methods أو Access-Control-Expose-Headers كاسم حرفي لا كحرف بدل (wildcard).
الحل المغري هو عكس أي قيمة Origin تصل — أي origin: true في حزمة cors التي يستخدمها NestJS مع محوّل Express. ومع credentials: true يتيح هذا العكس لأي موقع قراءة الاستجابات الموثّقة لمستخدم مسجّل الدخول. لذلك نستخدم قائمة سماح دقيقة بدلًا من ذلك: في main.ts تحصل app.enableCors() على دالة origin تقرأ متغير البيئة CORS_ORIGIN المفصول بفواصل إلى Set، مع credentials: true، وقيم methods و allowedHeaders صريحة، و exposedHeaders لكل من ETag و X-Request-Id، و maxAge: 7200.
• استخدم callback(null, false) لا Error. إرجاع false يحذف ترويسات CORS ويترك للمتصفح حجب القراءة؛ أما تمرير Error فيحوّل الطلب إلى خطأ 500 في Express.
• سلاسل دقيقة فقط. تعبير نمطي يطابق اللاحقة مثل /example\.com$/ يطابق أيضًا https://evil-example.com، وإدراج null في قائمة السماح يقبل كل إطار iframe معزول (sandboxed) وكل صفحة file://، لأنها ترسل Origin: null.
• أرسل Vary: Origin مع كل استجابة تعكس الأصل. حزمة cors تضيفها تلقائيًا؛ أما الوسيط المكتوب يدويًا فعليه إضافتها أيضًا، وإلا قد تقدّم شبكة CDN قيمة Access-Control-Allow-Origin الخاصة بأصل ما إلى أصل آخر.
• ترويسات الاستجابة تحتاج إلى إذن صريح. لا يستطيع JavaScript قراءة إلا ترويسات الاستجابة المدرجة في قائمة CORS الآمنة مثل Content-Type و Cache-Control؛ وتبقى ETag و X-Request-Id غير مرئية حتى تُذكر في Access-Control-Expose-Headers.
لماذا لا تُرسَل ملفات تعريف الارتباط مع الطلبات عبر الأصول؟
لا تعبر الـ cookie الأصول إلا إذا اتفقت ثلاث طبقات: أن يطلب الطلبُ ذلك، وأن تسمح خاصية SameSite في الـ cookie بالسياق، وأن تسمح به سياسة المتصفح تجاه ملفات تعريف الارتباط من جهات خارجية. القيمة الافتراضية في fetch() هي credentials: 'same-origin'، لذا يحتاج الاستدعاء عبر الأصول إلى credentials: 'include' قبل أن يرسل المتصفح أي cookie أو يخزّن أي Set-Cookie من الاستجابة.
عندما تتشارك الواجهة والواجهة البرمجية الموقع نفسه — app.example.com و api.example.com — يكون الطلب من الموقع نفسه، وتُرسَل cookies ذات SameSite=Lax. ويعامل Chrome الـ cookie التي لا تحمل خاصية SameSite على أنها Lax. والـ cookie التي يضبطها api.example.com دون خاصية Domain تكون مقصورة على ذلك المضيف، فلا يستطيع وسيط Next.js على app.example.com قراءتها؛ أما Domain=example.com فيشاركها مع المضيفين كليهما.
عندما يكونان موقعين مختلفين — مثل نشرين على *.vercel.app، لأن vercel.app مُدرج في قائمة اللواحق العامة — تصبح cookie الجلسة cookie من جهة خارجية. وتحتاج حينها إلى SameSite=None; Secure، وحتى مع ذلك يحظر Safari ملفات تعريف الارتباط من الجهات الخارجية افتراضيًا ويحظرها Chrome في وضع التصفح المتخفي. في Devya لم نعد نعتمد مصادقة بملفات تعريف ارتباط عابرة للمواقع: إما أن تنتقل الواجهة البرمجية إلى نطاق فرعي من موقع الواجهة، أو تمرّرها الواجهة عبر وكيل (proxy).
متى نمرّر استدعاءات الواجهة البرمجية عبر rewrite في Next.js بدلًا من CORS؟
نمرّر الطلبات عبر rewrite في Next.js عندما تملك واجهة واحدة الواجهةَ البرمجية؛ ونحتفظ بـ CORS عندما تستدعي عدة أصول أو تطبيق جوال أو أطراف خارجية الواجهة البرمجية مباشرة. يتيح الـ rewrite للمتصفح استدعاء /api/... على أصله نفسه بينما يمرّر خادم Next.js الطلب إلى الخادم الخلفي، فلا يوجد preflight ولا ترويسات CORS، وكل cookie من الطرف الأول. في next.config.ts تُرجع الدالة rewrites() قاعدة واحدة: المصدر /api/:path* والوجهة هي قيمة متغير البيئة API_URL متبوعة بـ /api/:path*.
بعدها يستدعي المتصفح fetch('/api/me') بالقيمة الافتراضية credentials: 'same-origin'. وتُنفَّذ مصفوفة rewrites بعد أن يفحص Next.js مساراته الخاصة، لذا يبقى معالج مسار محلي في app/api بالمسار نفسه هو صاحب الأولوية. ويأتي مع هذا التحول تغييران في الخادم الخلفي: يجب أن تكون cookies الجلسة مقصورة على المضيف، لأن المتصفح يرفض Domain=api.example.com على مضيف الواجهة؛ ويجب أن يقرأ تحديد المعدل (rate limiting) والتسجيل عنوان IP الخاص بالعميل من X-Forwarded-For بدلًا من عنوان المقبس.
• طلب preflight — CORS المباشر: مع كل طلب غير بسيط، ويُخزَّن حتى مدة Access-Control-Max-Age. وكيل rewrite: لا يوجد، لأن الطلبات من الأصل نفسه.
• ملفات تعريف الارتباط — CORS المباشر: تحتاج إلى credentials: 'include'، وبين المواقع تحتاج إلى SameSite=None; Secure التي تفشل مع حظر ملفات الجهات الخارجية. وكيل rewrite: من الطرف الأول، وتعمل SameSite=Lax.
• مسار الشبكة — CORS المباشر: من المتصفح إلى الواجهة البرمجية. وكيل rewrite: من المتصفح إلى Next.js ثم إلى الواجهة البرمجية، بقفزة إضافية واحدة.
• الإعداد — CORS المباشر: قائمة سماح بالأصول على الواجهة البرمجية لكل بيئة. وكيل rewrite: متغير API_URL واحد على الواجهة.
• الأنسب لـ — CORS المباشر: الواجهات البرمجية العامة وتطبيقات الجوال وتعدد الواجهات. وكيل rewrite: واجهة واحدة تملك واجهتها البرمجية.
لماذا يظهر خطأ 500 على أنه خطأ CORS؟
الاستجابة الفاشلة التي لا تحمل Access-Control-Allow-Origin يُبلَّغ عنها كخطأ CORS، فيختفي رمز الحالة الحقيقي. ولا يتلقى JavaScript إلا خطأ TypeError عامًا — Failed to fetch في Chrome — دون أي حالة. والأسباب المعتادة هي معالج أخطاء أو بوابة (gateway) يرد قبل تشغيل وسيط CORS، وكذلك nginx: فالتوجيه add_header لا يُطبَّق إلا على استجابات 2xx و 3xx ما لم ينتهِ بالكلمة always.
عند ظهور خطأ CORS، نتحقق أولًا من الحالة الحقيقية في تبويب Network، ثم نعيد تشغيل الـ preflight باستخدام curl -i -X OPTIONS على نقطة النهاية مع إرسال Origin: https://app.example.com و Access-Control-Request-Method: POST و Access-Control-Request-Headers: content-type. الاستجابة الصحيحة تُرجع 204 مع Access-Control-Allow-Origin: https://app.example.com و Access-Control-Allow-Credentials: true وقيمة Access-Control-Allow-Headers تتضمن content-type. وإذا غابت هذه الترويسات فالإصلاح مكانه الخادم أو الوكيل، لا استدعاء fetch() في الواجهة.
الأسئلة الشائعة
س: لماذا تعمل الواجهة البرمجية في Postman أو curl وتفشل في المتصفح؟
ج: المتصفحات وحدها تطبّق CORS. لا يرسل curl ولا Postman طلبات preflight ولا يفحصان ترويسات Access-Control-*، لذا لا يظهر إعداد CORS المفقود أو الخاطئ إلا عندما يستدعي JavaScript في الصفحة الواجهة البرمجية.
س: هل يصلح mode: 'no-cors' خطأ CORS؟
ج: لا. يُرجع mode: 'no-cors' استجابة معتمة (opaque) بحالة 0 وجسم غير قابل للقراءة، ولا يسمح إلا بالطلبات البسيطة. إنه يُسكت الخطأ بإخفاء البيانات.
س: هل CORS حماية من CSRF؟
ج: لا. الطلبات البسيطة عبر الأصول، مثل POST من نموذج HTML، تصل إلى الخادم مع cookies مرفقة؛ وكل ما يفعله CORS هو منع صفحة المهاجم من قراءة الاستجابة. استخدم cookies بقيمة SameSite، أو رموز CSRF، أو التحقق من ترويسة Origin للحماية من CSRF.
قراءات إضافية
هندسة الأنظمة الخلفية
إدارة الموارد الصريحة في TypeScript وNode.js 24: كيف نستخدم using وawait using وDisposableStack
يأتي تسرب الاتصالات غالبًا من مسار خطأ واحد تخطّى استدعاء release(). يشغّل Node.js 24 التعليمتين using وawait using بشكل أصلي، ويترجمهما TypeScript منذ الإصدار 5.2. نشرح هنا كيف يجعل فريقنا عملاء قواعد البيانات والمعاملات والمجلدات المؤقتة وتجهيزات الاختبارات قابلة للتحرير، ومتى نستخدم DisposableStack، والفخين اللذين يجب تجنبهما: SuppressedError والبث الذي يعيش بعد نطاقه.
هندسة الأنظمة الخلفية
قراءة EXPLAIN ANALYZE في PostgreSQL: كيف نجد الفهرس الناقص وراء نقطة API بطيئة في Node.js
توقّفنا عن إضافة فهارس PostgreSQL بالتخمين. طريقتنا: نحدّد الاستعلام الأعلى تكلفة عبر pg_stat_statements، ونقرأ خطة EXPLAIN (ANALYZE, BUFFERS) بحثًا عن الصفوف التي تُقرأ ثم تُهمَل، ثم نضيف الفهرس المركّب أو الجزئي أو فهرس التغطية الذي تطلبه الخطة، ونبنيه باستخدام CREATE INDEX CONCURRENTLY.