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

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

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

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

وهنا يقع أخطر خطأ في هذا الموضوع، فالمطور الذي يرى رسالة الرفض يبحث عن طريقة يتجاوز بها الشرط، فيجد أن السيرفر يستطيع أن يأخذ قيمة 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-noneGET /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 أعلاه:
- أرسل
Vary: Originمع كل رد على المسارات التي تطبق عليها CORS. - قارن
Originبقائمة ثابتة مطابقة تامة، ولا تستخدم البحث عن جزء من النص ولا تعبيراً نمطياً Regex واسعاً. - إذا وجدته فأعده كما هو في
Access-Control-Allow-Origin، وأضفAllow-Credentials: trueفقط إذا كنت تستخدم ملفات تعريف الارتباط. - رد على
OPTIONSقبل أي فحص للمصادقة، واذكر الطرق والترويسات التي تحتاجها فعلاً وليس كل شيء. - إذا لم تجده فلا ترسل أي ترويسة سماح، ولا ترفض الطلب برمز خطأ إلا إذا كان التطبيق يريد ذلك لسبب آخر.
ولا تكتب هذا المنطق بنفسك إذا كان إطارك يوفره، فتجده في الحزمة 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.

والإعداد في 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 إلا بعد مطابقته بقائمة صريحة.
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، وأعد الطلب، ثم امش مع القائمة التالية بالترتيب:
- ابحث عن صف الطلب التمهيدي، ويظهر في Chrome بالنوع
preflightوالطريقةOPTIONSقبل الطلب الحقيقي، فإذا لم يكن موجوداً فالطلب بسيط أو أن المتصفح حفظ الإذن سابقاً. - انظر إلى رمز الرد في كل صف، فإذا كان
401أو403فالمصادقة أمامOPTIONS، وإذا كان413أو502أو504فالمشكلة ليست في CORS أصلاً. - افتح ترويسات الطلب Request Headers وتأكد من قيمة
Originبالضبط، بالبروتوكول والمنفذ، ثم قارنها بالقائمة في السيرفر حرفاً بحرف، فالشرطة المائلة في الآخر أوhttpبدلاً منhttpsتكفي لفشل المطابقة. - افتح ترويسات الرد Response Headers وتأكد أن
Access-Control-Allow-Originموجودة مرة واحدة فقط، وأن قيمتها هي المصدر وليست النجمة إذا كان الطلب يحمل ملفات تعريف الارتباط. - في الطلب التمهيدي تأكد أن
Access-Control-Allow-Methodsفيها الطريقة، وأنAccess-Control-Allow-Headersفيها كل ترويسة فيAccess-Control-Request-Headers. - أعد نفس الطلب بالأمر
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.