اولین بار که به خطای CORS برخوردم، در یک پروژه React بود که قرار بود به یک API جداگانه وصل شود. همه‌چیز در Postman کار می‌کرد، ولی در مرورگر هیچ‌چیز. آن شب چند ساعت طول کشید تا بفهمم مشکل نه در کد کلاینت است و نه در سرور؛ مشکل در یک سیاست امنیتی است که مرورگر عمداً اجرا می‌کند تا جلوی حمله‌ها را بگیرد. از آن روز، CORS برای من از یک پیام قرمز ترسناک در کنسول، به یک مفهوم ساده ولی پرجزئیات تبدیل شد. این مقاله خلاصه همان مسیری است که به‌مرور طی کرده‌ام.

CORS چیست و چرا مرورگر جلوی درخواست شما را می‌گیرد؟

CORS که مخفف Cross-Origin Resource Sharing است و در فارسی می‌توان آن را اشتراک منابع بین مبدأها ترجمه کرد، یک سازوکار امنیتی مرورگر است که به سرور اجازه می‌دهد مشخص کند چه دامنه‌هایی می‌توانند به منابعش دسترسی داشته باشند. بدون CORS، یک سایت مخرب می‌توانست از طریق مرورگر کاربر، درخواست به سایت بانک کاربر بفرستد و اطلاعات حساس را بخواند.

نکته کلیدی که در ابتدا برای همه گیج‌کننده است: خطای CORS یک خطای مرورگر است، نه یک خطای سرور. اگر با Postman یا curl به همان endpoint درخواست بزنید، احتمالاً پاسخ سالم برمی‌گردد. این یعنی سرور شما دارد درست کار می‌کند؛ فقط مرورگر به‌خاطر سیاست امنیتی، پاسخ را به کد جاوااسکریپت شما نمی‌دهد.

برای درک ریشه، باید با مفهوم Origin یا مبدأ آشنا باشید. یک origin از سه بخش تشکیل می‌شود: پروتکل (http یا https)، دامنه (example.com) و پورت (۸۰، ۴۴۳ یا هر عدد دیگر). اگر هر یک از این سه بخش فرق کند، مرورگر آن را یک origin متفاوت می‌بیند و درخواست cross-origin محسوب می‌شود. مثلاً https://site.com و https://api.site.com دو origin متفاوت هستند. همچنین http://localhost:3000 و http://localhost:8080 هم دو origin جداگانه‌اند، حتی اگر روی یک دستگاه باشند.

اگر با مفهوم کلی API چیست و چه کاربردی دارد آشنا نیستید، پیشنهاد می‌کنم ابتدا آن را بخوانید تا تصویر روشن‌تری از این که چرا کلاینت و سرور جدا هستند داشته باشید.

خطای CORS معمولاً باگ نیست؛ پیام مرورگر است که می‌گوید سرور به آن اجازه دسترسی نداده است.

ریشه مشکل: سیاست Same-Origin

CORS در واقع یک استثنا روی یک قاعده بزرگ‌تر به نام Same-Origin Policy یا سیاست هم‌مبدأ است. این سیاست، از دهه نود میلادی در مرورگرها وجود داشته و پایه امنیت وب امروزی است. بر اساس این سیاست، هر صفحه وب فقط می‌تواند به منابع همان origin خودش دسترسی داشته باشد. اگر a.com بخواهد به b.com درخواست بزند، به‌طور پیش‌فرض مرورگر جلویش را می‌گیرد.

پس چرا اصلاً این‌قدر رایج است که سایت‌ها به سرورهای دیگر درخواست می‌زنند؟ چون معماری مدرن وب بر پایه جدا کردن سرویس‌ها ساخته شده. یک SPA (Single Page Application) روی یک دامنه سرو می‌شود و API آن روی دامنه دیگر، یا یک CDN جداگانه، یا یک سرویس پرداخت خارجی. بدون CORS، همه این معماری‌ها از کار می‌افتاد.

CORS با اضافه کردن هدرهای خاص در پاسخ سرور، این اجازه را می‌دهد. مرورگر قبل از اینکه پاسخ را در اختیار جاوااسکریپت قرار دهد، هدرهای CORS را بررسی می‌کند. اگر هدرها مجوز داده باشند، پاسخ به دست کد شما می‌رسد؛ در غیر این صورت، با وجود اینکه پاسخ فیزیکی از سرور دریافت شده، مرورگر آن را از دسترس جاوااسکریپت خارج می‌کند و همان پیام معروف CORS را نشان می‌دهد.

پیام‌های رایج خطای CORS

در تجربه‌ام با انواع پیام‌های CORS مواجه شده‌ام. جدول زیر پیام‌های رایج و علت اصلی‌شان را نشان می‌دهد:

پیام خطا در کنسولعلت اصلی
No Access-Control-Allow-Origin headerسرور هیچ هدر CORS برنگردانده
Multiple values in Access-Control-Allow-Originهدر دوبار ارسال شده، یا مقدار حاوی لیست است
Credentials flag is true but... wildcardCookie ارسال شده اما هدر ستاره است
Method PUT is not allowed by preflightلیست متدهای مجاز در هدر ناقص است
Request header field not allowedهدر سفارشی در لیست Access-Control-Allow-Headers نیست

پیام اول یعنی سرور شما به هیچ وجه هدر CORS نفرستاده. پیام دوم یعنی کد اضافه‌کردن هدر، دوبار اجرا شده — مثلاً یک بار از طریق افزونه و یک بار از طریق middleware. پیام سوم یکی از پرتکرارترین‌هاست و در بخش ملاحظات امنیتی به‌طور جدی به آن می‌پردازم.

Preflight Request چیست؟

یکی از سردرگم‌کننده‌ترین بخش‌های CORS برای توسعه‌دهندگان، Preflight یا درخواست پیش‌پرواز است. برای درخواست‌های ساده مثل GET ساده یا POST با Content-Type: application/x-www-form-urlencoded، مرورگر مستقیم درخواست را می‌فرستد. اما برای درخواست‌های پیچیده‌تر — مثل PUT، DELETE، یا هر درخواستی که هدر سفارشی دارد یا Content-Type: application/json — مرورگر اول یک درخواست OPTIONS به سرور می‌فرستد تا بپرسد آیا اجازه این عملیات را دارید.

این همان جایی است که بیشتر خطاهای CORS ریشه می‌گیرند. سرورهای توسعه‌داده‌شده بدون توجه به Preflight، فقط به GET و POST پاسخ می‌دهند و درخواست OPTIONS را با ۴۰۵ رد می‌کنند. راه‌حل این است که در کد سرور، endpoint OPTIONS را صریحاً مدیریت کنید یا از کتابخانه‌هایی که این کار را خودکار انجام می‌دهند استفاده کنید.

درک درست Preflight برای هر کسی که با APIهای مبتنی بر REST از مفاهیم پایه تا طراحی حرفه‌ای کار می‌کند ضروری است. اگر با مفهوم کلی REST آشنایی ندارید، مقاله آموزش REST API نقطه شروع خوبی است.

راه‌حل اصلی: هدرهای سمت سرور

مهم‌ترین نکته‌ای که در سال اول کار با CORS یاد گرفتم: مشکل CORS هیچ‌وقت در سمت کلاینت حل نمی‌شود. ممکن است راه‌حل‌های موقت سمت کلاینت ببینید، ولی راه‌حل واقعی و پایدار، اضافه کردن هدرهای مناسب در سرور است. سه هدر اصلی که باید بشناسید:

  • Access-Control-Allow-Origin — دامنه یا دامنه‌هایی که اجازه دسترسی دارند
  • Access-Control-Allow-Methods — متدهای HTTP مجاز مثل GET, POST, PUT, DELETE
  • Access-Control-Allow-Headers — هدرهای سفارشی که کلاینت می‌تواند بفرستد

یک هدر چهارم هم برای حالتی است که درخواست شما کوکی یا توکن احراز هویت ارسال می‌کند: Access-Control-Allow-Credentials باید مقدار true بگیرد، و در آن حالت Access-Control-Allow-Origin نباید ستاره باشد. اگر با مفهوم کامل هدرهای امنیتی آشنا نیستید، هدرهای امنیتی HTTP نقشه جامعی از این دسته ارائه می‌دهد.

تنظیم CORS در Apache و Nginx

اگر سرور شما Apache است، می‌توانید هدرها را در فایل .htaccess اضافه کنید:

<IfModule mod_headers.c>
    Header set Access-Control-Allow-Origin "https://app.example.com"
    Header set Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
    Header set Access-Control-Allow-Headers "Content-Type, Authorization"
</IfModule>

در Nginx، این هدرها معمولاً در بلاک server یا location اضافه می‌شوند:

location /api/ {
    add_header 'Access-Control-Allow-Origin' 'https://app.example.com' always;
    add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
    add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization' always;

    if ($request_method = OPTIONS) {
        return 204;
    }
}

نکته مهم در Nginx: کلمه کلیدی always باعث می‌شود هدرها حتی روی پاسخ‌های خطا هم ارسال شوند. بدون آن، اگر درخواست OPTIONS با خطا برگردد، هدر CORS هم ارسال نمی‌شود و همان پیام گیج‌کننده در کنسول ظاهر می‌شود.

تنظیم CORS در Node.js و Express

در Express.js، ساده‌ترین راه استفاده از پکیج رسمی cors است:

const express = require( 'express' );
const cors = require( 'cors' );

const app = express();

app.use( cors( {
    origin: 'https://app.example.com',
    methods: [ 'GET', 'POST', 'PUT', 'DELETE' ],
    credentials: true,
} ) );

اگر فقط به یک endpoint خاص نیاز دارید، می‌توانید CORS را به‌صورت نقطه‌ای اعمال کنید:

app.get( '/api/public', cors(), ( req, res ) => {
    res.json( { status: 'ok' } );
} );

یک اشتباه رایجی که در پروژه‌ها دیده‌ام این است که توسعه‌دهنده cors() را بدون گزینه origin صدا می‌زند، که باعث می‌شود همه دامنه‌ها مجاز شوند. در محیط توسعه اشکالی ندارد، ولی روی سرور تولید، این کار عملاً سیاست Same-Origin را برای API شما خاموش می‌کند.

تنظیم CORS در PHP

در PHP خالص، هدرها را با تابع header ارسال می‌کنید. مهم این است که این کد قبل از هر خروجی دیگری اجرا شود، وگرنه با خطای headers already sent مواجه می‌شوید:

<?php
$allowed_origins = array(
    'https://app.example.com',
    'https://admin.example.com',
);

$origin = isset( $_SERVER['HTTP_ORIGIN'] )
    ? $_SERVER['HTTP_ORIGIN']
    : '';

if ( in_array( $origin, $allowed_origins, true ) ) {
    header( 'Access-Control-Allow-Origin: ' . $origin );
    header( 'Access-Control-Allow-Credentials: true' );
}

header( 'Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS' );
header( 'Access-Control-Allow-Headers: Content-Type, Authorization' );

if ( $_SERVER['REQUEST_METHOD'] === 'OPTIONS' ) {
    http_response_code( 204 );
    exit;
}

این الگو را در پروژه‌های زیادی پیاده‌سازی کرده‌ام و به‌نظرم تمیزترین راه برای PHP خالص است. نکته مهم این است که به‌جای ستاره، دامنه‌های مجاز را در یک لیست سفید نگه می‌دارید و از پویا ارسال کردن origin استفاده می‌کنید.

تنظیم CORS در Django

در Django، پکیج استاندارد django-cors-headers است. بعد از نصب با pip install django-cors-headers، باید آن را در INSTALLED_APPS و MIDDLEWARE اضافه کنید، و سپس در settings.py تنظیمات زیر را تعریف کنید:

CORS_ALLOWED_ORIGINS = [
    'https://app.example.com',
    'https://admin.example.com',
]

CORS_ALLOW_CREDENTIALS = True

CORS_ALLOW_METHODS = [
    'GET',
    'POST',
    'PUT',
    'PATCH',
    'DELETE',
    'OPTIONS',
]

ترتیب میان‌افزارها اهمیت دارد: CorsMiddleware باید تا حد امکان بالا در لیست MIDDLEWARE قرار بگیرد، ولی بعد از CommonMiddleware. اگر ترتیب را رعایت نکنید، ممکن است هدرهای CORS روی پاسخ‌های ریدایرکت ارسال نشوند و همان خطای همیشگی را ببینید. نکات دقیق‌تر درباره تفاوت احراز هویت و مجوزدهی در امنیت API در وب بحث شده که برای پروژه‌های Django بسیار کاربردی است.

راه‌حل پروکسی برای محیط توسعه

در محیط توسعه، اغلب ساده‌ترین راه‌حل استفاده از یک پروکسی است. ایده این است که مرورگر شما به‌جای درخواست مستقیم به دامنه API، درخواست به سرور توسعه محلی خودتان می‌فرستد و سرور توسعه، آن را به API اصلی هدایت می‌کند. چون این انتقال در سمت سرور انجام می‌شود، سیاست Same-Origin مرورگر درگیر نمی‌شود.

در Vite یا Create React App، این کار با یک فایل تنظیم ساده انجام می‌شود:

// vite.config.js
export default {
    server: {
        proxy: {
            '/api': {
                target: 'https://api.example.com',
                changeOrigin: true,
                rewrite: ( path ) => path.replace( /^\/api/, '' ),
            },
        },
    },
};

در محیط تولید، این کار معمولاً توسط یک reverse proxy (مثل Nginx) انجام می‌شود که هم مزیت امنیتی دارد و هم مشکل CORS را از بین می‌برد.

پروکسی، خط CORS را حذف نمی‌کند؛ به‌جای آن، اصلاً اجازه نمی‌دهد مرورگر متوجه شود که دو origin متفاوت وجود دارد.

CORS در وردپرس و REST API

وردپرس یک REST API داخلی دارد که به‌طور پیش‌فرض CORS را برای درخواست‌های عمومی مدیریت می‌کند. اما وقتی می‌خواهید از یک دامنه خارجی به یک endpoint سفارشی درخواست بزنید، اغلب به مشکل می‌خورید. راه‌حل استاندارد استفاده از فیلتر rest_pre_serve_request یا rest_send_cors_headers است:

add_action( 'rest_api_init', function () {
    remove_filter( 'rest_pre_serve_request', 'rest_send_cors_headers' );

    add_filter( 'rest_pre_serve_request', function ( $value ) {
        $origin = get_http_origin();

        if ( $origin && in_array( $origin, [
            'https://app.example.com',
        ], true ) ) {
            header( 'Access-Control-Allow-Origin: ' . esc_url_raw( $origin ) );
            header( 'Access-Control-Allow-Methods: GET, POST, OPTIONS' );
            header( 'Access-Control-Allow-Credentials: true' );
        }

        return $value;
    } );
} );

این الگو را در چند پروژه هدلس وردپرس پیاده کرده‌ام و از همه راه‌حل‌های دیگر تمیزتر جواب می‌دهد. فقط یادتان باشد که همیشه به‌جای ستاره، دامنه‌های مشخص را مجاز کنید.

ملاحظات امنیتی: چرا ستاره خطرناک است

یکی از اشتباهات رایجی که بارها دیده‌ام، ست گذاشتن Access-Control-Allow-Origin: * برای حل سریع مشکل است. این کار در محیط توسعه اشکالی ندارد، ولی روی سرور تولید پیامدهای جدی دارد. اگر روی یک endpoint که به کوکی کاربر وابسته است ستاره بگذارید، عملاً به همه سایت‌های دنیا اجازه داده‌اید از طریق مرورگر یک کاربر لاگین‌کرده، به API شما درخواست بزنند.

ترکیب Access-Control-Allow-Origin: * با Access-Control-Allow-Credentials: true در واقع توسط خود مرورگر رد می‌شود — این یکی از چند محدودیتی است که W3C برای جلوگیری از این نوع حمله‌ها قرار داده. ولی حتی بدون credentials، ستاره روی endpointهای حساس، سطح حمله را به‌شدت بالا می‌برد. رویکرد درست، استفاده از لیست سفید و بازتاب دادن دامنه‌های مجاز است.

برای درک چارچوب کلی امنیت وب که CORS یکی از اعضای آن است، امنیت وب چیست و چه اصولی دارد نقشه راه خوبی است. همچنین بهترین روش‌های امنیت وب و حملات XSS هم به درک خطر ناشی از تنظیم اشتباه CORS کمک می‌کنند.

اشتباهات رایج در رفع CORS

در این سال‌ها، ده‌ها بار شاهد تکرار این اشتباهات بوده‌ام که همگی در تجربه‌های شخصی خودم ریشه دارند:

  • اضافه کردن هدر دوبار. وقتی هم افزونه اضافه می‌کند و هم .htaccess، مرورگر پیام Multiple values می‌دهد. همیشه یک لایه را به‌عنوان منبع اصلی هدر انتخاب کنید.
  • فراموش کردن مدیریت OPTIONS. وقتی Preflight فرستاده می‌شود و سرور ۴۰۵ برمی‌گرداند، CORS شکست می‌خورد حتی اگر هدرها روی GET درست باشند.
  • ستاره روی endpoint احراز هویت‌شده. این کار عملاً کوکی کاربر را در معرض همه دامنه‌ها قرار می‌دهد.
  • تنظیم CORS فقط در محیط توسعه. اگر CORS را در فایل‌های لوکال حل کنید و روی سرور تولید فراموش کنید، پدیده‌ای به نام works on my machine اتفاق می‌افتد که هر توسعه‌دهنده‌ای با آن آشناست.
  • نگذاشتن Vary: Origin. اگر با لیست سفید کار می‌کنید و پاسخ‌ها را کش می‌کنید، باید هدر Vary: Origin هم اضافه کنید. وگرنه cache می‌تواند پاسخ کاربر A را به کاربر B بدهد.

خطای CORS وقتی با خطاهای دیگر جاوااسکریپت هم‌زمان رخ می‌دهد، عیب‌یابی را پیچیده‌تر می‌کند. اگر در کنسول مرورگر پیام TypeError یا Failed to load resource هم می‌بینید، ابتدا آن‌ها را با مقالات رفع خطای TypeError در جاوااسکریپت و رفع خطای Failed to load resource حل کنید، چون گاهی خطای CORS فقط یک نتیجه ثانویه است.

نگاهی دقیق‌تر به لایه‌های CORS

برای مهندسانی که با معماری‌های توزیع‌شده سروکار دارند، CORS فقط یک هدر ساده نیست؛ یک نقطه اتصال بین چند لایه است و هر لایه می‌تواند منبع خطا باشد. سه مشاهده دقیق‌تر از پروژه‌های واقعی:

اول، در معماری‌های multi-tenant یا چند-مستأجری که هر مستأجر روی یک زیردامنه سرو می‌شود، نگه‌داشتن لیست سفید به‌صورت دستی به سرعت غیرقابل نگهداری می‌شود. راه‌حل صحیح، پویا محاسبه کردن Origin مجاز از پایگاه داده یا از یک الگوی regex کنترل‌شده است. اما این پویایی باید با احتیاط پیاده شود؛ اگر regex شما بیش از حد باز باشد، عملاً به ستاره تبدیل می‌شود با این تفاوت که خودتان هم نمی‌دانید.

دوم، در معماری‌های مبتنی بر سرویس‌مش (Service Mesh) مثل Istio یا Linkerd، CORS می‌تواند در چند سطح اعمال شود: در ingress، در sidecar پروکسی، و در خود اپلیکیشن. اگر این لایه‌ها هم‌راستا نباشند، ممکن است هدرها دوبار ارسال شوند یا یکی از هدرها حذف شود. قاعده‌ای که خودم رعایت می‌کنم این است که CORS را فقط در یک لایه اعمال کنم و بقیه لایه‌ها را برای عبور شفاف تنظیم کنم.

سوم، بحث امنیت API در معماری‌های zero-trust. در این الگو، هویت هر درخواست با یک توکن مثل JWT (JSON Web Token) منتقل می‌شود و CORS به‌عنوان یک لایه مکمل، جلوی درخواست‌های مرورگرهای ناشناس را می‌گیرد. اما نکته‌ای که در طراحی سیستم‌های حساس جدی می‌گیرم این است که CORS هرگز نباید به‌عنوان کنترل دسترسی اصلی در نظر گرفته شود. مرورگر CORS را اعمال می‌کند، ولی یک مهاجم می‌تواند درخواست HTTP را خارج از مرورگر بفرستد و CORS روی آن اثری ندارد. CORS یک لایه دفاعی در مرورگر است، نه مرز اعتماد.

چهارم، در CI/CD (Continuous Integration / Continuous Deployment) و تست خودکار، خطای CORS اغلب به‌عنوان یک مشکل محیطی نادیده گرفته می‌شود. اگر تست‌های end-to-end شما با Puppeteer یا Playwright اجرا می‌شوند، باید در استجینگ هم همان تنظیمات CORS مربوط به دامنه واقعی اعمال شود، وگرنه تست‌ها سبز می‌شوند ولی کاربران واقعی خطا می‌بینند. برای جزئیات بیشتر درباره ساختار این جریان‌های استقرار، CI/CD چگونه تحویل نرم‌افزار را متحول می‌کند را ببینید.

از تجربه خودم: سه نکته عملی

اگر بخواهم کل این مقاله را در سه نکته کوتاه فشرده کنم، اول این که مشکل CORS همیشه در سرور حل می‌شود، نه در کلاینت. هر راه‌حلی که سمت کلاینت پیشنهاد می‌شود، یا موقتی است یا صورت مسئله را تغییر می‌دهد. دوم این که در رفع خطاهای CORS، اول Preflight را چک کنید؛ اگر OPTIONS درست مدیریت نشود، بقیه تلاش‌ها هدر می‌رود. سوم این که هرگز برای حل سریع، ستاره نگذارید؛ لیست سفید دقیق، هم امن‌تر است هم پایدارتر.

خطای CORS در ابتدا ترسناک به نظر می‌رسد چون پیام‌هایش گویا نیستند، ولی وقتی اصول Same-Origin و Preflight را در ذهن داشته باشید، تقریباً همه خطاها به یک الگوی مشخص قابل‌تشخیص تبدیل می‌شوند. اگر در پروژه‌ای با یک مورد عجیب و غریب از CORS مواجه شده‌اید — مثلاً حالتی که در یکی از مرورگرها کار می‌کند و در دیگری نه — تجربه‌تان را در دیدگاه بنویسید؛ موارد لبه‌ای CORS همیشه چیزهای تازه‌ای برای یاد گرفتن دارند. 🌐