Loading...

الأمان · قراءة 10 دقائق

ما هو CORS؟ قاعدة المتصفح وراء كل خطأ cross-origin

CORS (Cross-Origin Resource Sharing) هو الطريقة التي يخبر بها الخادم المتصفحَ أيّ المواقع الأخرى يحق لها قراءة استجاباته. يمنع المتصفح السكربت من قراءة بيانات أصل آخر ما لم تحمل الاستجابة ترويسة Access-Control-Allow-Origin تسمح بأصل الصفحة. تُصلَح أخطاء CORS على الخادم أو CDN الذي يجيب، لا في المتصفح.

آخر تحديث

ما هو CORS؟ قاعدة المتصفح وراء كل خطأ cross-origin

سياسة الأصل الواحد وما يخففه CORS

الأصل (origin) ثلاثة أشياء معًا: المخطط والمضيف والمنفذ. https://shop.example.com وhttps://api.example.com أصلان مختلفان، وكذلك http://example.com وhttps://example.com. المسار لا يُحتسب.

تطبّق المتصفحات سياسة الأصل الواحد (same-origin policy): يستطيع JavaScript في أصل ما أن يرسل طلبات إلى أصل آخر، لكنه لا يستطيع قراءة الرد ما لم يوافق الطرف الآخر. وتشمل القاعدة نفسها خطوط الويب المحمّلة من مضيف آخر، وقراءة بكسلات صورة من أصل آخر رُسمت على canvas.

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

CORS (Cross-Origin Resource Sharing) هو تلك الموافقة. يخبر المتصفح الخادمَ بالأصل الذي يسأل عبر ترويسة الطلب Origin، ويرد الخادم بترويسات Access-Control-* تحدد الأصول التي يحق لها قراءة الاستجابة. لا شيء في CORS يوقف الطلب عند الخادم: المتصفح هو الذي يحجب الرد عن الصفحة.

الطلبات البسيطة وطلبات preflight

الطلبات البسيطة تنطلق مباشرة. يُعدّ طلب GET أو HEAD أو POST بسيطًا إذا لم يحمل إلا ترويسات من قائمة CORS الآمنة، مثل Accept وAccept-Language وContent-Language، وكان Content-Type لجسمه، إن وُجد، text/plain أو multipart/form-data أو application/x-www-form-urlencoded. يرسله المتصفح مع ترويسة Origin، ويستلم الرد، ولا يسلّمه للصفحة إلا إذا سمحت Access-Control-Allow-Origin بذلك الأصل.

وكل ما عدا ذلك يسبقه preflight: طلب OPTIONS يستأذن قبل إرسال الطلب الحقيقي. يذكر الطريقة في Access-Control-Request-Method والترويسات الأخرى في Access-Control-Request-Headers. على الخادم أن يجيب بحالة 2xx وبترويسات Access-Control-Allow-* المناسبة ومن دون إعادة توجيه. عندها فقط يرسل المتصفح الطلب الفعلي.

المفاجأة المعتادة أن واجهة JSON تُطلق preflight في كل استدعاء تقريبًا. فـContent-Type: application/json ليس في القائمة الآمنة، ولا Authorization أيضًا، لذا يدفع fetch يرسل JSON مع رمز bearer رحلة ذهاب وإياب إضافية، إلا إذا كان المتصفح لا يزال يحتفظ برد الـpreflight. هذا هو دور Access-Control-Max-Age: يحتفظ Chromium بالرد ساعتين على الأكثر وFirefox ‏24 ساعة على الأكثر، ومن دون الترويسة يحتفظ به المتصفح خمس ثوانٍ.

طلب preflight والرد الذي يسمح بمرور الطلب الحقيقي

OPTIONS /v1/orders HTTP/1.1
Host: api.example.com
Origin: https://shop.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: authorization, content-type

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://shop.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 7200
Vary: Origin

ترويسات Access-Control واحدة واحدة

Access-Control-Allow-Origin هي صاحبة القرار. تأخذ قيمة واحدة بالضبط: * لأي أصل، أو أصلًا واحدًا مثل https://shop.example.com، أو null. القائمة المفصولة بفواصل غير صالحة، ولا ينبغي السماح بـnull أبدًا، لأن إطارات iframe المعزولة والملفات المحلية ترسل هي أيضًا Origin: null.

Access-Control-Allow-Methods وAccess-Control-Allow-Headers تجيبان عن الـpreflight: أي الطرق وأي ترويسات الطلب يحق للطلب الحقيقي استخدامها. وتحدد Access-Control-Max-Age عدد الثواني التي يجوز فيها للمتصفح إعادة استخدام هذا الرد.

Access-Control-Allow-Credentials: true تتيح للصفحة قراءة الرد على طلب أُرسل مع ملفات تعريف الارتباط أو مصادقة HTTP. القواعد المرافقة لها في القسم التالي.

Access-Control-Expose-Headers تسرد ترويسات الاستجابة التي يحق لـJavaScript الصفحة قراءتها. من دونها لا يرى السكربت إلا Cache-Control وContent-Language وContent-Length وContent-Type وExpires وLast-Modified وPragma، فتبقى ترويسة خاصة مثل X-Request-Id، أو ETag الذي تحتاجه مكتبة الرفع، غير مرئية.

وفي جانب الطلب ثلاث ترويسات يكتبها المتصفح كلها، ولا يستطيع كود الصفحة تزويرها: Origin وAccess-Control-Request-Method وAccess-Control-Request-Headers.

Credentials: ملفات تعريف الارتباط وAuthorization ولماذا يتوقف * عن العمل

يحمل الطلب credentials حين يتضمن ملفات تعريف الارتباط أو مصادقة HTTP: fetch مع credentials: 'include'، أو XMLHttpRequest مع withCredentials = true. ومع هذه الطلبات يشدد المتصفح كل القواعد.

يجب أن تسمّي Access-Control-Allow-Origin الأصل بالضبط؛ فـ* مرفوض. ويجب أن تكون Access-Control-Allow-Credentials: true موجودة. أما * في Allow-Headers أو Allow-Methods أو Expose-Headers فيكفّ عن كونه حرف بدل، ويُقرأ كترويسة اسمها حرفيًّا *.

ولأن الاستجابة لا تسمّي إلا أصلًا واحدًا، يحتفظ الخادم الذي يخدم عدة واجهات أمامية بقائمة سماح، ويقارن بها Origin الوارد، ويعيد الأصل المطابق. صار الرد الآن معتمدًا على ترويسة في الطلب، لذا يجب أن تقول كل استجابة Vary: Origin، بما فيها الاستجابات الخالية من أي ترويسة CORS؛ وإلا سلّمت ذاكرة التخزين المؤقت رد أصل إلى أصل آخر.

الخطأ الكلاسيكي هو إعادة أي أصل مع السماح بالـcredentials. هذا يتيح لكل موقع قراءة بيانات مستخدميك المسجّلين عبر متصفحاتهم هم. إنه أول ما تلتقطه أدوات الفحص الأمني، ويندرج تحت ضعف التحكم في الوصول في OWASP Top 10. ولملفات تعريف الارتباط بوابتها الخاصة أيضًا: لا يسافر الملف مع طلب cross-site إلا إذا ضُبط بـSameSite=None وSecure.

قائمة سماح تعيد الأصل (Node.js وExpress)

const allowed = new Set([
  'https://shop.example.com',
  'https://admin.example.com',
]);

app.use((req, res, next) => {
  const origin = req.headers.origin;
  res.vary('Origin');
  if (allowed.has(origin)) {
    res.set('Access-Control-Allow-Origin', origin);
    res.set('Access-Control-Allow-Credentials', 'true');
  }
  if (req.method === 'OPTIONS') {
    res.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
    res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type');
    res.set('Access-Control-Max-Age', '7200');
    return res.sendStatus(204);
  }
  next();
});

أخطاء CORS الشائعة ومعنى كل منها

رسالة الكونسول تسمّي القاعدة التي فشلت. اقرأها وتبويب Network مفتوح، فرمز حالة الطلب الفاشل هو غالبًا القصة الحقيقية. الرسائل أدناه بصياغة Chromium.

No 'Access-Control-Allow-Origin' header is present on the requested resource. وصل الرد بلا الترويسة. في أغلب الأحيان يكون ردَّ خطأ، مثل 404 أو 500 أو إعادة توجيه إلى صفحة الدخول أو 403 من جدار حماية، يصدر عن كود لا يبلغ معالجة CORS لديك أصلًا. أصلح الحالة أولًا، ثم تأكد أن ردود الأخطاء تحمل الترويسة أيضًا، كي يُظهر العطل القادم سببه الحقيقي.

The 'Access-Control-Allow-Origin' header contains multiple values '*, *', but only one is allowed. طبقتان تضيفان الترويسة: عادةً التطبيق وخادم وكيل أو CDN أمامه. أبقِ واحدة فقط.

Response to preflight request doesn't pass access control check: It does not have HTTP ok status. تلقى طلب OPTIONS الرمز 401 أو 403 أو 404 أو 405. السبب المعتاد مصادقة تعمل قبل CORS: طلب الـpreflight لا يحمل ترويسة Authorization الخاصة بك أبدًا، فيجب أن يُجاب قبل أي تحقق من الدخول.

Redirect is not allowed for a preflight request. الرابط يعيد التوجيه: من http إلى https، أو شرطة مائلة ختامية ناقصة، أو مضيف آخر. استدعِ الرابط النهائي مباشرة.

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'. الصفحة ترسل ملفات تعريف الارتباط والخادم يجيب بـ*. أعِد الأصل بالضبط وأضف Access-Control-Allow-Credentials: true، أو توقف عن إرسال credentials.

Request header field authorization is not allowed by Access-Control-Allow-Headers in preflight response. أضف الترويسة المذكورة إلى Access-Control-Allow-Headers.

الأصل null يعني أن الصفحة فُتحت من file://؛ قدّمها من خادم ويب محلي. وثمة «حل» ينبغي تجنبه: mode: 'no-cors' يُسكت الخطأ بإعطاء كودك ردًّا معتمًا لا يمكن قراءته. يمر الطلب ولا يتلقى السكربت شيئًا.

CORS خلف CDN: التخزين المؤقت وVary: Origin

تحفظ ذاكرة التخزين المؤقت ردًّا واحدًا لكل رابط وتقدّمه لكل من يطلب ذلك الرابط. وترويسات CORS التي تتغير بحسب الطلب تكسر هذا الافتراض بطريقتين.

إذا كان خادم الأصل يضيف Access-Control-Allow-Origin فقط حين يحمل الطلب ترويسة Origin، فإن أول طلب يقرر ما يُخزَّن. وإن جاء ذلك الطلب الأول من وسم <img> عادي أو من زيارة مباشرة، تبقى النسخة المخزنة بلا ترويسة CORS، ويفشل كل fetch من أصل آخر بعدها حتى تنتهي صلاحية النسخة. يبدو الخطأ عشوائيًّا لأنه يتوقف على من وصل أولًا.

وإذا كان خادم الأصل يعيد الأصل الذي يسأل، فالنسخة المخزنة تسمّي أصلًا واحدًا، وموقع ثانٍ يقرأ الرابط نفسه يتلقى ردًّا موجّهًا لغيره.

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

والمتصفحات تخزّن مؤقتًا أيضًا. صورة حُمّلت أولًا بوسم <img> عادي ثم طُلبت لاحقًا بالسمة crossorigin قد تأتي من ذاكرة المتصفح بلا الترويسة. والتصميمان نفساهما يعالجان هذه الحالة.

ترويسات CORS على CDN.com.tr

للملفات المقدَّمة عبر CDN تستطيع الحافة إضافة الترويسة بنفسها. في اللوحة افتح قواعد التسليم، وعدّل القاعدة التي تغطي المسار (مثل /fonts/ أو /static/)، واكتب في حقل الرؤوس المخصصة ترويسة واحدة في كل سطر بصيغة Header-Name value (في صفحة القواعد ذات التبويبات: الرؤوس ← إضافة رؤوس للاستجابة). القيمة التي تحتوي على مسافات توضع بين علامتي تنصيص مزدوجتين.

تضيف الحافة هذه الترويسات إلى الاستجابات الناجحة واستجابات إعادة التوجيه (2xx و3xx)، بما فيها ما يُقدَّم من الذاكرة المؤقتة، منذ لحظة نشر القاعدة. وترفض اللوحة والحافة الرموز ; و{ و} في سطر الترويسة؛ وترويسة CORS لا تحتاجها أبدًا، فـAccess-Control-Allow-Origin تأخذ قيمة واحدة.

إذا كان خادم الأصل يرسل أصلًا ترويسة Access-Control-* لتلك الملفات، فاختر الاسم نفسه في إخفاء الرؤوس ضمن تلك القاعدة. وإلا تلقّى المتصفح قيمتين ورفضهما معًا.

ويبقى أمران على خادم الأصل. الأول طلبات preflight: تمرر الحافة طلبات OPTIONS إلى خادمك ولا تخزنها مؤقتًا أبدًا، فالواجهة التي تحتاج preflight تجيب عنه بنفسها. أما طلبات GET العادية للخطوط والصور وJSON العام فلا تحتاج preflight، وتكفيها ترويسة القاعدة. والثاني الردود التي تختلف بحسب الأصل: إعادة أصل واحد من عدة أصول مسموح بها، كما تتطلب الطلبات ذات الـcredentials، قرار يتخذه تطبيقك. وتفصل الحافة بين هذه النسخ حين يرسل خادم الأصل Vary: Origin، ما لم تكن القاعدة مضبوطة على تجاهل Vary. وفي قاعدة مفعّل فيها تحسين الصور، تُقدَّم صور JPEG وPNG عبر المُحسِّن الذي يضع Vary الخاص به، فامنح الصور ترويسة ثابتة.

إذا كان WAF مفعّلًا على مستوى الموقع، فـOPTIONS بين طرقه المسموح بها افتراضيًّا؛ أما PUT وPATCH وDELETE فيجب تحديدها في طرق HTTP المسموح بها. والطلب الذي ترفضه الحافة يتلقى 403 بلا ترويسات CORS، فيعرضه المتصفح كخطأ CORS. وترويسة X-MT-Blocked-By: cdn-edge-security في ذلك الـ403 تخبرك أن الرافض هو الحافة لا تطبيقك.

الرؤوس المخصصة في قاعدة لـ /fonts/ أو /static/

Access-Control-Allow-Origin *
Access-Control-Expose-Headers "Content-Length, ETag"

CORS في حاويات تخزين الكائنات

الملفات التي يرفعها المتصفح مباشرة إلى حاوية (bucket)، كأن يكون ذلك عبر PUT موقَّع مسبقًا من تطبيق ويب، يجيب عنها خادم التخزين لا تطبيقك، ولذلك تحتاج الحاوية قواعد CORS خاصة بها.

يقبل تخزين الكائنات في CDN.com.tr هذه القواعد عبر استدعاء S3 القياسي PutBucketCors على https://s3.cdn.com.tr، ويجيب بنفسه عن طلبات preflight من المتصفح. تصلح أي أداة S3 قادرة على ضبط CORS للحاوية؛ ومع AWS CLI يكفي أمر واحد وملف JSON صغير.

لكل ما يكتب البيانات، اذكر أصولًا محددة بدل *، واكشف ETag: فمكتبات الرفع في المتصفح تقرؤه لإتمام الرفع متعدد الأجزاء. ولا يجعل CORS الحاوية عامة أيضًا. الحاوية الخاصة تظل تجيب بـ403 على القراءة المجهولة؛ CORS يقرر فقط هل يحق للصفحة قراءة رد سُمح لها أصلًا بالحصول عليه.

ضبط CORS للحاوية باستخدام AWS CLI

# cors.json
{
  "CORSRules": [{
    "AllowedOrigins": ["https://app.example.com"],
    "AllowedMethods": ["GET", "PUT"],
    "AllowedHeaders": ["*"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3600
  }]
}

aws s3api put-bucket-cors --bucket my-bucket \
  --cors-configuration file://cors.json \
  --endpoint-url https://s3.cdn.com.tr

aws s3api get-bucket-cors --bucket my-bucket \
  --endpoint-url https://s3.cdn.com.tr

اختبار CORS من سطر الأوامر

لا يطبّق curl قواعد CORS، ولهذا هو الأداة المناسبة لرؤية ما يقوله الخادم فعلًا. أرسل ترويسة Origin بنفسك واقرأ ترويسات Access-Control-* العائدة؛ وللـpreflight أرسل OPTIONS مع الترويستين اللتين كان المتصفح سيضيفهما.

تحقق من ثلاثة أشياء: حالة 2xx للـpreflight، وترويسة Access-Control-Allow-Origin واحدة بالضبط، وVary: Origin حيثما تتغير القيمة بحسب الأصل. ثم كرر الطلب عبر CDN وقارن. على CDN.com.tr تُظهر ترويسة X-Proxy-Cache-MT هل جاء الرد من الذاكرة المؤقتة؛ فإذا اختلف الرد المخزَّن عن الأول، فمشكلة التخزين المؤقت الموصوفة أعلاه قائمة.

اقرأ الترويسات التي يعتمد عليها المتصفح
# a simple request: what does the edge answer for this origin?
curl -sI https://cdn.example.com/fonts/inter.woff2 \
  -H 'Origin: https://www.example.com' \
  | grep -i -E 'access-control|vary|x-proxy-cache'

# a preflight, as the browser would send it
curl -si -X OPTIONS https://api.example.com/v1/orders \
  -H 'Origin: https://shop.example.com' \
  -H 'Access-Control-Request-Method: PUT' \
  -H 'Access-Control-Request-Headers: authorization, content-type'

أسئلة شائعة حول CORS

هل يحمي CORS واجهتي البرمجية؟

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

هل Access-Control-Allow-Origin: * آمن؟

للموارد العامة التي لا تحتاج تسجيل دخول، كالخطوط والصور والسكربتات وJSON العام، نعم: يستطيع أي أحد تنزيلها على أي حال. لكنه خاطئ لكل ما يعتمد على ملفات تعريف الارتباط أو جلسة المستخدم، والمتصفحات ترفض جمعه مع الـcredentials.

كيف أسمح بأكثر من أصل واحد؟

ليس بقائمة: الترويسة تأخذ قيمة واحدة. احتفظ بقائمة سماح على الخادم، وأعِد Origin الطلب إذا كان فيها، وأرسل Vary: Origin في كل استجابة كي تفصل الذاكرات المؤقتة بين الردود.

لماذا يعمل الطلب في Postman أو curl ويفشل في المتصفح؟

لأن المتصفحات وحدها تطبّق CORS. يرسل Postman وcurl الطلب ويعرضان ما يعود؛ أما المتصفح فيفحص ترويسات Access-Control-* قبل أن يتيح لـJavaScript رؤية الرد.

لماذا يظهر خطأ CORS أحيانًا فقط؟

غالبًا بسبب ذاكرة مؤقتة. إذا كان الرد يتغير بحسب ترويسة Origin ولا يقول Vary: Origin، فإن أول طلب وصل إلى الذاكرة المؤقتة يقرر ما يتلقاه الجميع. أرسل ترويسة ثابتة للملفات العامة، أو Vary: Origin حيث يجب أن يختلف الرد.

كيف أفعّل CORS على CDN.com.tr؟

للملفات التي يقدّمها CDN، أضف Access-Control-Allow-Origin في الرؤوس المخصصة لقاعدة التوصيل؛ أما ردود preflight لواجهتك والردود التي تختلف بحسب الأصل فتأتي من خادم الأصل. ولحاويات تخزين الكائنات، اضبط قواعد CORS باستدعاء S3 PutBucketCors على https://s3.cdn.com.tr.