سیاست same-origin و آنچه CORS آزاد میکند
Origin سه چیز با هم است: scheme، host و port. https://shop.example.com و https://api.example.com دو origin متفاوتاند، و http://example.com و https://example.com هم همینطور. مسیر (path) حساب نمیشود.
مرورگرها سیاست same-origin را اجرا میکنند: JavaScript یک origin میتواند به origin دیگر درخواست بفرستد، اما تا طرف مقابل موافقت نکند نمیتواند پاسخ را بخواند. همین قاعده فونتهای وبی را که از host دیگری بارگذاری میشوند و خواندن پیکسلهای تصویری از origin دیگر که روی canvas کشیده شده هم در بر میگیرد.
خیلی چیزها بدون هیچ موافقتی مجاز میمانند. صفحه میتواند تصویر، stylesheet یا اسکریپت را از هر جایی embed کند و یک فرم میتواند به هر سایتی POST کند. چیزی که این سیاست از آن محافظت میکند خواندن است: بدون آن، هر صفحهای که باز میکنید میتوانست بیصدا با cookieهای شما webmail یا API بانکتان را صدا بزند و پاسخ را بخواند.
CORS (Cross-Origin Resource Sharing) همان موافقت است. مرورگر در هدر درخواست Origin به سرور میگوید کدام origin میپرسد، و سرور با هدرهای Access-Control-* پاسخ میدهد که کدام originها اجازهٔ خواندن پاسخ را دارند. هیچ چیز در 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 آن origin را مجاز کند، پاسخ را به صفحه میدهد.
هر چیز دیگری اول یک preflight دارد: درخواست OPTIONS که پیش از فرستادن درخواست اصلی اجازه میگیرد. متد را در Access-Control-Request-Method و بقیهٔ هدرها را در Access-Control-Request-Headers اعلام میکند. سرور باید با وضعیت 2xx، هدرهای مناسب Access-Control-Allow-* و بدون redirect پاسخ دهد. فقط آن وقت مرورگر درخواست واقعی را میفرستد.
غافلگیری معمول این است که یک API مبتنی بر JSON تقریباً در هر فراخوانی preflight راه میاندازد. Content-Type: application/json در فهرست امن نیست، Authorization هم نیست؛ پس fetchی که JSON را با توکن bearer میفرستد یک رفتوبرگشت اضافه میپردازد، مگر اینکه مرورگر هنوز پاسخ preflight را در cache داشته باشد. 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 همان هدری است که تصمیم میگیرد. دقیقاً یک مقدار میگیرد: * برای هر origin، یک origin مشخص مثل https://shop.example.com، یا null. فهرست جداشده با کاما معتبر نیست، و null را هرگز نباید مجاز کرد، چون iframeهای sandbox شده و فایلهای محلی هم Origin: null میفرستند.
Access-Control-Allow-Methods و Access-Control-Allow-Headers به preflight پاسخ میدهند: درخواست واقعی از کدام متدها و کدام هدرهای درخواست میتواند استفاده کند. Access-Control-Max-Age میگوید مرورگر چند ثانیه میتواند این پاسخ را دوباره به کار ببرد.
Access-Control-Allow-Credentials: true اجازه میدهد صفحه پاسخ درخواستی را بخواند که با cookie یا احراز هویت 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: cookieها، Authorization و چرا * دیگر کار نمیکند
درخواستی credentials دارد که cookie یا احراز هویت HTTP همراهش باشد: fetch با credentials: 'include'، یا XMLHttpRequest با withCredentials = true. برای این درخواستها مرورگر همهٔ قواعد را سختتر میکند.
Access-Control-Allow-Origin باید همان origin دقیق را نام ببرد؛ * رد میشود. Access-Control-Allow-Credentials: true باید حاضر باشد. و * در Allow-Headers، Allow-Methods یا Expose-Headers دیگر wildcard نیست: مثل هدری خوانده میشود که اسمش واقعاً * است.
چون هر پاسخ فقط یک origin را میتواند نام ببرد، سروری که چند front-end دارد یک allowlist نگه میدارد، Origin رسیده را با آن مقایسه میکند و مورد منطبق را برمیگرداند. حالا پاسخ به یک هدر درخواست وابسته است، پس هر پاسخ، حتی پاسخهایی که هیچ هدر CORS ندارند، باید Vary: Origin هم داشته باشد؛ وگرنه یک cache پاسخ یک origin را به origin دیگر میدهد.
اشتباه کلاسیک این است که هر originی را برگردانید و همزمان credentials را مجاز کنید. این کار به هر سایتی اجازه میدهد دادههای کاربران لاگینشدهٔ شما را از طریق مرورگر خودشان بخواند. اسکنرهای امنیتی اول همین را علامت میزنند و در OWASP Top 10 زیر کنترل دسترسی معیوب قرار میگیرد. cookieها هم دروازهٔ خودشان را دارند: یک cookie فقط وقتی با درخواست cross-site میرود که با SameSite=None و Secure تنظیم شده باشد.
یک allowlist که origin را برمیگرداند (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، redirect به صفحهٔ ورود یا 403 یک فایروال، که کدی تولیدش کرده که هرگز به منطق CORS شما نمیرسد. اول وضعیت را درست کنید، بعد مطمئن شوید پاسخهای خطا هم هدر را دارند تا خطای بعدی علت واقعیاش را نشان دهد.
The 'Access-Control-Allow-Origin' header contains multiple values '*, *', but only one is allowed. دو لایه هدر را اضافه میکنند: معمولاً اپلیکیشن و یک proxy یا 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. URL ریدایرکت میکند: از http به https، اسلش پایانیِ جاافتاده، host دیگر. مستقیم URL نهایی را صدا بزنید.
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'. صفحه cookie میفرستد و سرور * جواب میدهد. origin دقیق را برگردانید و 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 اضافه کنید.
origin برابر null یعنی صفحه از file:// باز شده است؛ آن را از یک وبسرور محلی سرو کنید. و یک «راهحل» که باید از آن پرهیز کرد: mode: 'no-cors' خطا را ساکت میکند، چون به کد شما پاسخی opaque میدهد که خواندنی نیست. درخواست میرود و اسکریپت شما هیچ چیز نمیگیرد.
CORS پشت CDN: cache و Vary: Origin
cache برای هر URL یک پاسخ نگه میدارد و آن را به همهٔ کسانی که آن URL را میخواهند میدهد. هدرهای CORS که با درخواست عوض میشوند این فرض را به دو شکل میشکنند.
اگر سرور origin فقط وقتی Access-Control-Allow-Origin را اضافه کند که درخواست هدر Origin داشته باشد، اولین درخواست تعیین میکند چه چیزی cache شود. اگر آن درخواست اول از یک تگ ساده <img> یا یک بازدید مستقیم آمده باشد، نسخهٔ cache شده هدر CORS ندارد و هر fetch cross-origin بعدی تا انقضای آن نسخه شکست میخورد. خطا تصادفی به نظر میرسد، چون به این بستگی دارد که چه کسی اول آمده است.
اگر سرور origin همان originی را که پرسیده برگرداند، نسخهٔ cache شده فقط یک origin را نام میبرد، و سایت دومی که همان URL را میخواند پاسخی میگیرد که خطاب به دیگری است.
دو طراحی تمیز وجود دارد. برای فایلهای عمومی مثل فونت، تصویر، اسکریپت و JSON عمومی، به همه همان هدر را بفرستید: یا * یا origin تنها سایتتان. آن وقت هر نسخهٔ cache شده برای هر بازدیدکنندهای درست است و نرخ hit دست نمیخورد. وقتی پاسخ باید بر اساس origin فرق کند، در هر پاسخ آن URL Vary: Origin بفرستید تا cache نسخهها را جدا نگه دارد. این کار کمی از نرخ hit کم میکند، پس آن را به مسیرهایی محدود کنید که واقعاً لازمش دارند.
مرورگرها هم cache میکنند. تصویری که اول با یک تگ ساده <img> بارگذاری شده و بعد با attribute crossorigin درخواست میشود، ممکن است بدون هدر از cache مرورگر بیاید. همان دو طراحی این مورد را هم حل میکنند.
هدرهای CORS در CDN.com.tr
برای فایلهایی که از طریق CDN سرو میشوند، edge میتواند خودش هدر را اضافه کند. در پنل قوانین تحویل را باز کنید، قانونی را که مسیر را پوشش میدهد ویرایش کنید (مثلاً /fonts/ یا /static/) و در فیلد هدرهای سفارشی در هر خط یک هدر به شکل Header-Name value بنویسید (در صفحهٔ قوانینِ زبانهدار: سرآیندها ← افزودن سرآیند به پاسخ). مقداری که فاصله دارد داخل گیومهٔ دوتایی نوشته میشود.
edge این هدرها را از لحظهٔ انتشار قانون به پاسخهای موفق و redirect (2xx و 3xx)، از جمله پاسخهایی که از cache میآیند، اضافه میکند. پنل و edge کاراکترهای ;، { و } را در خط هدر نمیپذیرند؛ هدر CORS هرگز به آنها نیاز ندارد، چون Access-Control-Allow-Origin فقط یک مقدار میگیرد.
اگر سرور origin شما برای این فایلها از قبل یک هدر Access-Control-* میفرستد، همان نام را در پنهانسازی هدرها در همان قانون انتخاب کنید. وگرنه مرورگر دو مقدار میگیرد و هر دو را رد میکند.
دو کار با سرور origin شما میماند. اول preflight: edge درخواستهای OPTIONS را به سرور شما میفرستد و هرگز cache نمیکند، پس APIای که preflight لازم دارد خودش به آن پاسخ میدهد. درخواستهای ساده GET برای فونت، تصویر و JSON عمومی preflight نمیخواهند و هدر قانون برایشان کافی است. دوم پاسخهایی که بر اساس origin فرق میکنند: برگرداندن یکی از چند origin مجاز، آنطور که درخواستهای دارای credentials لازم دارند، تصمیم اپلیکیشن شماست. وقتی سرور origin شما Vary: Origin بفرستد، edge این نسخهها را جدا نگه میدارد، به شرطی که قانون برای نادیدهگرفتن Vary تنظیم نشده باشد. در قانونی که بهینهسازی تصویر روشن است، تصاویر JPEG و PNG از طریق optimizer سرو میشوند که Vary خودش را میگذارد؛ برای تصاویر هدر ثابت بگذارید.
اگر WAF سراسری سایت روشن باشد، OPTIONS بهطور پیشفرض جزو متدهای مجاز آن است؛ PUT، PATCH و DELETE را باید در متدهای HTTP مجاز تیک بزنید. درخواستی که edge رد کند پاسخ 403 بدون هدرهای CORS میگیرد و مرورگر آن را خطای CORS گزارش میکند. هدر X-MT-Blocked-By: cdn-edge-security در آن 403 به شما میگوید که «نه» را edge گفته، نه اپلیکیشن شما.
هدرهای سفارشی در قانونی برای /fonts/ یا /static/
Access-Control-Allow-Origin *
Access-Control-Expose-Headers "Content-Length, ETag"
CORS در bucketهای object storage
فایلهایی که مرورگر مستقیم در یک bucket آپلود میکند، مثلاً با یک PUT از نوع presigned از یک اپلیکیشن وب، پاسخشان را سرویس ذخیرهسازی میدهد نه اپلیکیشن شما؛ پس bucket قواعد CORS مخصوص به خود را لازم دارد.
object storage در CDN.com.tr این قواعد را با فراخوانی استاندارد S3 یعنی PutBucketCors روی https://s3.cdn.com.tr میپذیرد و خودش به درخواستهای preflight مرورگر پاسخ میدهد. هر ابزار S3 که بتواند CORS یک bucket را تنظیم کند کار میکند؛ با AWS CLI یک فرمان و یک فایل JSON کوچک کافی است.
برای هر چیزی که مینویسد، به جای * originهای دقیق را فهرست کنید و ETag را expose کنید: کتابخانههای آپلود در مرورگر آن را میخوانند تا آپلود multipart را کامل کنند. CORS یک bucket را عمومی هم نمیکند. bucket خصوصی همچنان به خواندن ناشناس 403 میدهد؛ CORS فقط تعیین میکند صفحه میتواند پاسخی را بخواند که اجازهٔ گرفتنش را داشته است یا نه.
تنظیم CORS یک bucket با 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 هر جا که مقدار بر اساس origin عوض میشود. بعد درخواست را از طریق CDN تکرار و مقایسه کنید. در CDN.com.tr هدر X-Proxy-Cache-MT نشان میدهد پاسخ از cache آمده یا نه؛ اگر پاسخ cache شده با اولی فرق داشت، همان مشکل cache که بالاتر گفته شد در کار است.
# 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 از API من محافظت میکند؟
نه. CORS از مرورگر کاربران شما در برابر صفحههایی محافظت میکند که میخواهند از طرف آنها داده بخوانند. در برابر اسکریپت، سرور یا curlی که مستقیم API شما را صدا میزند کاری نمیکند، و درخواستهای ساده حتی اگر مرورگر بعداً پاسخ را پنهان کند به سرور شما میرسند. API شما همچنان احراز هویت و مجوزدهی مخصوص خودش را لازم دارد.
آیا Access-Control-Allow-Origin: * امن است؟
برای منابع عمومی که ورود لازم ندارند، مثل فونت، تصویر، اسکریپت و JSON عمومی، بله: هر کسی در هر حال میتواند آنها را دانلود کند. برای هر چیزی که به cookie یا session کاربر وابسته است اشتباه است، و مرورگرها از ترکیب آن با credentials خودداری میکنند.
چطور به بیش از یک origin اجازه بدهم؟
با فهرست نه: این هدر فقط یک مقدار میگیرد. روی سرور یک allowlist نگه دارید، اگر Origin درخواست در آن بود همان را برگردانید، و در هر پاسخ Vary: Origin بفرستید تا cacheها پاسخها را جدا نگه دارند.
چرا درخواست در Postman یا curl کار میکند ولی در مرورگر نه؟
چون فقط مرورگرها CORS را اجرا میکنند. Postman و curl درخواست را میفرستند و هر چه برگردد نشان میدهند؛ مرورگر پیش از آنکه بگذارد JavaScript شما پاسخ را ببیند، هدرهای Access-Control-* را بررسی میکند.
چرا خطای CORS فقط گاهی پیش میآید؟
معمولاً به خاطر یک cache. اگر پاسخ بر اساس هدر Origin عوض شود ولی Vary: Origin نگوید، درخواستی که اول به cache رسیده تعیین میکند بقیه چه بگیرند. برای فایلهای عمومی هدر ثابت بفرستید و جایی که پاسخ باید فرق کند Vary: Origin.
چطور CORS را در CDN.com.tr فعال کنم؟
برای فایلهایی که CDN سرو میکند، Access-Control-Allow-Origin را در هدرهای سفارشی قانون تحویل اضافه کنید؛ پاسخ preflightهای API شما و پاسخهایی که بر اساس origin فرق میکنند از سرور origin شما میآیند. برای bucketهای object storage، قواعد CORS را با فراخوانی S3 یعنی PutBucketCors روی https://s3.cdn.com.tr تنظیم کنید.