سياسة أمان المحتوى في Next.js App Router: الـ nonce وstrict-dynamic ومقايضة العرض الثابت
جدول المحتويات
عن الكاتب

Ahmed Mahmoud
كاتب ومطوّر
مهندس برمجيات شغوف بتطوير الويب وتصميم تجربة المستخدم.
مؤسّس ديفيا · eng-ahmed.com ↗سياسة أمان المحتوى (CSP) هي ترويسة استجابة HTTP تُخبر المتصفح بمصادر السكربتات والأنماط والاتصالات المسموح بها للمستند. في Next.js App Router، سياسة السكربتات الوحيدة التي تصمد أمام تحميل الحزم وقت التشغيل هي nonce يُولَّد لكل طلب مع الكلمة المفتاحية 'strict-dynamic'. التكلفة التي لم نحسب حسابها: توليد هذا الـ nonce في middleware.ts وقراءته عبر headers() يُخرج كل مسار مطابق من العرض الثابت.
أهم النقاط
الـ nonce رمز عشوائي يُولَّد لكل طلب ويظهر داخل توجيه script-src وكذلك كخاصية nonce على كل وسم script مسموح به. ولأنه يجب ألا يتكرر أبدًا، فإن صفحة HTML تحمل nonce لا يمكن تخزينها مؤقتًا، وهذا بالضبط سبب تحويل Next.js للمسار إلى عرض ديناميكي.
قائمة النطاقات المسموح بها لا تستطيع تأمين تطبيق Next.js. يُصدر App Router حمولة تمهيدية مضمّنة inline ثم يُنشئ وسوم script إضافية وقت التشغيل، لذا فإن script-src 'self' يفشل في السماح بالحمولة المضمّنة، ويفشل أيضًا في التمييز بين حزم الإطار وأي ملف آخر من نفس الأصل.
تنقل 'strict-dynamic' الثقة من سكربت مسموح به عبر الـ nonce إلى أي عنصر script يُنشئه ذلك السكربت برمجيًا. هذا ما يجعل تحميل الحزم يعمل دون تعداد روابط الحزم.
إبقاء 'unsafe-inline' و https: داخل script-src هو خيار احتياطي متعمَّد وليس ثغرة. المتصفح الذي يفهم الـ nonce يتجاهل 'unsafe-inline'، والمتصفح الذي يفهم 'strict-dynamic' يتجاهل كل تعبير نطاق في التوجيه نفسه.
أطلق Content-Security-Policy-Report-Only أولًا. الترويسة المُنفِّذة تحوّل خطأ في السياسة إلى صفحة بيضاء، بينما ترويسة التقارير فقط تحوّل الخطأ نفسه إلى سطر في السجل.
ما الذي توقفه سياسة أمان المحتوى فعليًا؟
سياسة أمان المحتوى لا توقف الحقن، بل توقف التنفيذ. إذا نجح مهاجم في إدخال وسم script داخل حقل تعليقات نعرضه عبر dangerouslySetInnerHTML، فسيصل الكود إلى DOM فعلًا، لكن سياسة خالية من 'unsafe-inline' تعني أن المتصفح يرفض تشغيله ويُصدر تقرير مخالفة بدلًا من ذلك.
أربعة توجيهات أثبتت جدواها في تطبيقاتنا قبل أن نلمس script-src أصلًا، لأنها لا تحتاج nonce ولا تكسر شيئًا. التوجيه object-src 'none' يزيل ثغرة الإضافات القديمة. والتوجيه base-uri 'self' يمنع وسم base محقونًا من إعادة توجيه كل رابط نسبي في الصفحة بصمت. والتوجيه form-action 'self' يمنع نموذجًا محقونًا من إرسال بيانات الاعتماد إلى أصل آخر. والتوجيه frame-ancestors 'none' هو البديل الحديث لـ X-Frame-Options.
هذه الأربعة يمكن وضعها في دالة headers() داخل next.config.js وتبقى قابلة للتخزين المؤقت بالكامل. التوجيه المكلف هو script-src، وهو الذي يتطلب الـ nonce.
لماذا يحتاج Next.js إلى nonce بدل قائمة النطاقات؟
قائمة النطاقات لا تستطيع التعبير عمّا يفعله App Router وقت التشغيل. يقوم Next.js بتسلسل حمولة مكوّنات الخادم React داخل وسوم script مضمّنة في المستند، ثم ينشئ زمن التشغيل في المتصفح وسوم script إضافية لجلب حزم المسارات عند الطلب. التوجيه script-src 'self' يحجب الحمولة المضمّنة تمامًا، وحتى لو لم يفعل، فإن 'self' سيسمح بتنفيذ أي رابط من نفس الأصل، بما في ذلك ملف رفعه مستخدم ويُقدَّم من نطاقنا.
الكلمة المفتاحية 'strict-dynamic' من CSP المستوى الثالث تحل هذه المشكلة. تنص على أن أي عنصر script يُنشئه سكربت موثوق بالفعل يرث تلك الثقة. الحمولة التمهيدية تستمد ثقتها من الـ nonce، وكل حزمة تُحمّلها بعد ذلك ترث الثقة نفسها. لا حاجة لبصمات الحزم ولا لتعداد الروابط وقت البناء.
الجزء غير البديهي هو ما تُعطّله 'strict-dynamic'. في المتصفح الذي يطبّقها، تُتجاهَل كل تعبيرات النطاقات في التوجيه نفسه، سواء 'self' أو https: أو نطاق CDN صريح. لهذا السبب تبقى مذكورة في السياسة الموصى بها: هي مسار تدهور لطيف للمتصفحات الأقدم، وليست صلاحية إضافية للمتصفحات الحديثة.
كيف نولّد الـ nonce وننقله داخل App Router؟
يُولَّد الـ nonce مرة واحدة لكل طلب داخل middleware.ts، ويُكتب على ترويسات الطلب وترويسات الاستجابة معًا، ثم يُقرأ في مكوّن خادم عبر headers(). كتابته على الطلب أمر جوهري: يبحث Next.js عن ترويسة طلب باسم content-security-policy، ويستخرج منها الـ nonce، ويطبّقه على وسوم script التي يُصدرها بنفسه. تجاهل هذه الخطوة يعني أن سياستنا تحجب حمولة الإطار التمهيدية.
عمليًا، يبني الـ middleware الـ nonce عبر crypto.randomUUID() مُرمَّزًا بصيغة base64، ويجمّع قائمة التوجيهات في نص واحد، ويضبط x-nonce و content-security-policy على نسخة من كائن Headers تُمرَّر إلى NextResponse.next، ثم يضبط السياسة نفسها على الاستجابة.
قراءته في التخطيط الجذري تحتاج سطرين. تُعيد headers() وعدًا في Next.js 15 وما بعده، لذا يجب انتظارها بـ await، ويمرر next/script خاصية nonce مباشرة إلى الوسم الصادر.
لماذا تحوّلت صفحاتنا الثابتة إلى ديناميكية بعد إضافة CSP؟
لأن الـ nonce يظل إجراءً أمنيًا طالما بقي غير متوقَّع، واستجابة HTML المخزّنة مؤقتًا تمنح الـ nonce نفسه لكل زائر. الـ nonce المُعاد استخدامه يعادل عمليًا 'unsafe-inline': مهاجم يقرأ كود صفحة واحدة يعرف الرمز الذي يفتح تنفيذ السكربتات في كل نسخة أخرى منها. يفرض Next.js التفسير الآمن بمعاملة headers() كواجهة ديناميكية، فبمجرد استدعائها في التخطيط الجذري يُعرض كل مسار تحته لكل طلب على حدة.
هذه فاتورة حقيقية. الصفحات التسويقية التي كانت تُبنى مسبقًا وقت البناء صارت تُعرض على الخادم في كل زيارة، ولم يعد بإمكان العرض المسبق الجزئي معاملة الهيكل كثابت.
أي استراتيجية CSP نختار؟
الخيار الأول: nonce مع 'strict-dynamic' على كل المسارات. الأمان: الأقوى، لا ينفَّذ أي سكربت مضمّن محقون في أي مكان. التكلفة: كل مسار مطابق يُعرض ديناميكيًا.
الخيار الثاني: قصر الـ nonce على المسارات المصادَق عليها ومسارات النماذج، مع سياسة ترويسات ثابتة لبقية الموقع. الأمان: قوي حيث تُعرض مدخلات المستخدم. التكلفة: صفحات التسويق والتوثيق تبقى ثابتة.
الخيار الثالث: بلا nonce، أي script-src 'self' 'unsafe-inline'. الأمان: ضعيف، السكربت المضمّن المحقون ما زال يعمل. التكلفة: لا شيء، الموقع يبقى ثابتًا بالكامل.
نختار الخيار الثاني للمواقع الغنية بالمحتوى، والخيار الأول لأي شيء خلف تسجيل الدخول. يتم تحديد النطاق عبر matcher في الـ middleware، والمثال الموثّق في Next.js يستثني إضافيًا طلبات الجلب المسبق حتى لا تستهلك حمولة RSC مجلوبة مسبقًا رمز nonce لن تستخدمه.
ما الذي ينكسر في التطوير ومع CSS-in-JS؟
بيئة التطوير تحتاج 'unsafe-eval' داخل script-src. يقوم React Fast Refresh وخرائط المصدر القائمة على eval بترجمة نصوص وقت التشغيل، لذا فإن سياسة بمستوى الإنتاج تملأ الطرفية بأخطاء EvalError فور تشغيل خادم التطوير. اربطها بشرط process.env.NODE_ENV بدل شحنها في كل مكان.
التوجيه style-src هو المكان الذي توقفنا فيه عن المقاومة. يضمّن Next.js أنماط CSS الحرجة داخل وسوم style، وتحقن مكتبات CSS-in-JS المزيد وقت التشغيل من مسارات كود لا ترى الـ nonce أصلًا. نُبقي 'unsafe-inline' في style-src عن قصد: حقن الأنماط ثغرة أضعف بكثير من حقن السكربتات، والبديل سياسة تنكسر مع كل ترقية اعتمادية. إن استدعى نموذج التهديد nonce للأنماط، فإن styled-components يقرؤه من المتغير العام __webpack_nonce__ ويقبله Emotion عبر createCache.
ثلاثة أمور أخرى أوقعتنا. عناصر next/image الضبابية المؤقتة هي روابط data:، لذا يحتاج img-src إلى data: وغالبًا blob:. ونداءات التحليلات تحتاج نطاقها في connect-src لا في script-src، لأن السكربت يُحمَّل بينما النداء طلب منفصل. وإطارات Stripe أو YouTube المضمّنة تحتاج frame-src الذي لا يرث من default-src بمجرد تعداد التوجيهات صراحة.
كيف نطلق CSP دون كسر الإنتاج؟
أرسل السياسة نفسها تحت ترويسة Content-Security-Policy-Report-Only أولًا واتركها دورة مرور كاملة، أسبوعًا على الأقل، حتى يظهر سلوك أيام العمل وعطلة الأسبوع معًا. توجّه ترويسة التقارير فقط المتصفح لتقييم السياسة والإبلاغ عن المخالفات دون حجب أي شيء، فتتحول الأعطال إلى سجل قابل للقراءة.
جمع التقارير يحتاج معالج مسار واحدًا. التوجيه القديم report-uri يرسل كائن JSON واحدًا بنوع محتوى application/csp-report، بينما تستخدم واجهة التقارير الأحدث ترويسة Reporting-Endpoints مع توجيه report-to وترسل مصفوفات مجمّعة بنوع application/reports+json. المتصفحات منقسمة بين الاثنين، لذا نرسل التوجيهين ونوحّد الشكل عند الاستقبال.
رشّح إضافات المتصفح قبل إطلاق أي تنبيه. في اليوم الأول من الجمع، جاءت الغالبية الساحقة من التقارير من أصول chrome-extension: و moz-extension: تحقن سكربتات في الصفحات، وهي ضوضاء لا يمكن إصلاحها ولا يجب أن تُوقظ أحدًا. ما يتبقى بعد هذا المرشّح هو القائمة الفعلية لما كانت السياسة ستكسره.
الأسئلة الشائعة
س: هل يحمينا الـ nonce إذا كنا نعرض HTML غير موثوق عبر dangerouslySetInnerHTML؟
ج: بالنسبة لتنفيذ السكربتات، نعم. وسم script محقون بلا nonce مطابق لن يعمل، وخصائص معالجات الأحداث المضمّنة مثل onerror محجوبة بفضل غياب 'unsafe-inline'. لكنه لا يمنع الكود نفسه من العرض، فالسياسة ليست بديلًا عن التنقية بمكتبة مثل DOMPurify.
س: هل تعمل سياسة قائمة على الـ nonce مع موقع Next.js مُصدَّر بشكل ثابت؟
ج: لا. يجب توليد الـ nonce لكل طلب، والتصدير الثابت لا يملك حوسبة وقت الطلب. البدائل هي script-src قائم على البصمات، وهو يتطلب استخراج بصمات الحمولة التمهيدية بعد كل بناء لأنها تتغير مع كل بناء، أو تقديم التصدير خلف دالة حافة تحقن الترويسة.
س: هل تُضعف 'strict-dynamic' السياسة؟
ج: بل تجعلها أضيق. تجعل 'strict-dynamic' المتصفحات المتوافقة تتجاهل كل مدخلات قوائم النطاقات في script-src، فنطاق CDN كان موثوقًا بالكامل سابقًا لم يعد موثوقًا إطلاقًا. الثقة تتدفق من الـ nonce إلى الخارج فقط.
س: هل ما زلنا نحتاج ترويسة X-Frame-Options؟
ج: يحل frame-ancestors محلها في كل المتصفحات الحالية، وعند وجود الاثنين يفوز frame-ancestors. إبقاء X-Frame-Options: DENY بجانبها لا يكلف شيئًا ويغطي العملاء القدامى جدًا.
س: أين تُوضع السياسة، في next.config.js أم في الـ middleware؟
ج: ضع كل توجيه ثابت مثل frame-ancestors و object-src و base-uri و form-action في دالة headers() داخل next.config.js لأن تلك الاستجابات تبقى قابلة للتخزين المؤقت. السياسة التي تحتوي nonce لكل طلب هي وحدها التي تحتاج الـ middleware، وعلى المسارات التي تتطلبها فقط.
قراءات إضافية
هندسة الواجهات
الوضع الداكن دون وميض في Next.js 16: كيف نستخدم سكربت سمة حاجبًا وcolor-scheme وزر تبديل آمنًا مع الـ Hydration
وميض الوضع الداكن عند إعادة تحميل الصفحة مشكلة توقيت: الخادم لا يستطيع قراءة localStorage. نشرح سكربت السمة الحاجب الذي نستخدمه في Next.js 16، ولماذا لا نضع السمة في cookie حفاظًا على الرسم الثابت، وكيف نبني زر تبديل يمر بالـ hydration دون أي عدم تطابق.
هندسة الواجهات
صور Open Graph الديناميكية في Next.js 16: كيف نبني opengraph-image.tsx ونتحقق من إصدار Satori الذي تضمّنه next/og ونحافظ على صحة معاينات الروابط
واجهت بطاقات المشاركة المولّدة لدينا ثلاث مفاجآت: خط WOFF2 لم يستطع المحرّك قراءته، وملف README لـ Satori يصف محرّكًا أحدث من المضمّن في Next.js، وصفحة ظلّت تعرض صورة الـ layout. هذه ملاحظاتنا الميدانية حول opengraph-image.tsx في Next.js 16 وأولوية البيانات الوصفية وفحص og:image قبل مشاركة أي رابط.