مقالات › امنیت

CORS به زبان ساده؛ چرا درخواست API خطا می‌دهد و راه‌حل درست آن

CORS را از پایه بفهمید: سیاست Same-Origin، درخواست ساده و Preflight، ارسال کوکی و Credentials، پیکربندی درست CORS در ASP.NET Core، رفع خطاهای رایج و اشتباهات امنیتی که باید از آن‌ها دوری کرد.

«Access to fetch at '...' from origin '...' has been blocked by CORS policy.» تقریباً هر توسعه‌دهنده‌ی وبی این پیام را دیده است؛ معمولاً اولین باری که اپلیکیشن React یک API روی پورت یا دامنه‌ی دیگر را صدا می‌زند. واکنش رایج جست‌وجوی تکه‌کدی است که خطا را از بین ببرد — اغلب AllowAnyOrigin() — بدون این‌که بدانیم CORS از چه چیزی محافظت می‌کند. این مقاله توضیح می‌دهد CORS واقعاً چطور کار می‌کند و چطور آن را درست پیکربندی کنیم.

سیاست Same-Origin

مرورگرها سیاست هم‌مبدأ (Same-Origin Policy) را اعمال می‌کنند: جاوااسکریپت یک Origin نمی‌تواند پاسخ‌های Origin دیگر را بخواند. Origin ترکیب پروتکل + میزبان + پورت است:

آدرس هم‌مبدأ با https://example.com؟
https://example.com/about ✅ بله (مسیر مهم نیست)
http://example.com ❌ پروتکل متفاوت
https://api.example.com ❌ میزبان متفاوت
https://example.com:8443 ❌ پورت متفاوت

چرا؟ فرض کنید در سایت بانک خود وارد شده‌اید. بدون این سیاست، هر سایتی که بازدید می‌کنید می‌توانست fetch("https://bank.com/api/balance") اجرا کند — مرورگر کوکی‌های بانک را ضمیمه می‌کرد — و اطلاعات شما را بخواند.

CORS (Cross-Origin Resource Sharing) روش کنترل‌شده‌ای است که سرور با آن می‌گوید: «این Originهای دیگر اجازه دارند پاسخ‌های من را بخوانند.»

نکته: CORS را مرورگر اعمال می‌کند. Postman، curl و فراخوانی سرور به سرور آن را نادیده می‌گیرند. به همین دلیل «در Postman کار می‌کند ولی در مرورگر نه» نشانه‌ی کلاسیک مشکل CORS است.

درخواست‌های ساده

برای درخواست‌های «ساده» — GET، HEAD یا POST فقط با هدرهای پایه و Content Type از نوع text/plain، multipart/form-data یا application/x-www-form-urlencoded — مرورگر درخواست را مستقیم و همراه هدر Origin می‌فرستد:

GET /api/articles HTTP/1.1
Host: api.example.com
Origin: https://example.com

اگر پاسخ هدر منطبق داشته باشد، جاوااسکریپت می‌تواند آن را بخواند:

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://example.com

در غیر این صورت درخواست به سرور رسیده، اما مرورگر پاسخ را از کد شما پنهان می‌کند.

درخواست‌های Preflight

هر چیز دیگری — PUT، DELETE، PATCH، Content-Type: application/json یا هدر Authorization — باعث Preflight می‌شود: مرورگر ابتدا یک درخواست OPTIONS برای گرفتن اجازه می‌فرستد:

OPTIONS /api/articles/42 HTTP/1.1
Origin: https://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://example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: authorization, content-type
Access-Control-Max-Age: 600

فقط اگر Preflight موفق باشد، مرورگر درخواست PUT واقعی را می‌فرستد. Access-Control-Max-Age اجازه می‌دهد مرورگر پاسخ را کش کند تا هر درخواست به Preflight نیاز نداشته باشد.

Credentials: کوکی و احراز هویت

به‌طور پیش‌فرض fetch بین‌مبدأ کوکی نمی‌فرستد. برای ارسال آن‌ها:

fetch("https://api.example.com/api/me", { credentials: "include" });

سرور باید این پاسخ را بدهد:

Access-Control-Allow-Origin: https://example.com
Access-Control-Allow-Credentials: true

همراه Credentials، مرورگر Access-Control-Allow-Origin: * را رد می‌کند و سرور باید یک Origin مشخص را برگرداند. کوکی‌ها هم برای ارسال بین‌سایتی به SameSite=None; Secure نیاز دارند.

توکن‌هایی که در هدر Authorization ارسال می‌شوند (JWT) به credentials: "include" نیاز ندارند؛ اما هدر باید در Preflight مجاز باشد.

پیکربندی CORS در ASP.NET Core

var allowedOrigins = builder.Configuration.GetSection("Cors:Origins").Get<string[]>() ?? [];

builder.Services.AddCors(options =>
{
    options.AddPolicy("frontend", policy => policy
        .WithOrigins(allowedOrigins)           // مثلاً https://example.com و https://admin.example.com
        .WithMethods("GET", "POST", "PUT", "DELETE")
        .WithHeaders("Content-Type", "Authorization")
        .AllowCredentials()
        .SetPreflightMaxAge(TimeSpan.FromMinutes(10)));
});

var app = builder.Build();

app.UseRouting();
app.UseCors("frontend");        // بعد از UseRouting و قبل از Authentication/Authorization
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
{
  "Cors": {
    "Origins": [ "https://example.com", "https://admin.example.com" ]
  }
}

Originها را در پیکربندی نگه دارید تا محیط توسعه (http://localhost:5173) و تولید بدون تغییر کد متفاوت باشند.

کنترل به‌ازای هر Endpoint هم ممکن است:

[EnableCors("frontend")]
public class ArticlesController : ControllerBase { }

app.MapGet("/api/public/stats", GetStats).RequireCors("public");

ترتیب میان‌افزارها مهم است

اگر UseCors بعد از Authorization اجرا شود، خطای احراز هویت پاسخ 401 را بدون هدرهای CORS برمی‌گرداند و مرورگر به‌جای 401 واقعی یک خطای گیج‌کننده‌ی CORS نشان می‌دهد. UseCors را زود قرار دهید. به همین ترتیب اگر یک Exception مدیریت‌نشده پاسخ 500 بدون هدر CORS تولید کند، مرورگر خطای CORS نشان می‌دهد؛ لاگ سرور را بررسی کنید.

در تولید، CORS را کلاً حذف کنید

اگر فرانت‌اند و API را از یک Origin سرو کنید، اصلاً CORS وجود ندارد:

  • Build ری‌اکت را در wwwroot همان API قرار دهید و /api/... را با آدرس نسبی صدا بزنید.
  • یا هر دو را پشت یک Reverse Proxy بگذارید: example.com/ به فرانت‌اند و example.com/api به بک‌اند.

در محیط توسعه، سرور Vite می‌تواند درخواست‌های API را پراکسی کند تا مرورگر فقط یک Origin ببیند:

// vite.config.js
export default defineConfig({
  server: {
    proxy: { "/api": "http://localhost:5179" },
  },
});

عیب‌یابی خطاهای CORS

  1. DevTools ← تب Network را باز کنید و درخواست ناموفق (و OPTIONS قبل از آن) را پیدا کنید.
  2. هدرهای پاسخ را ببینید: آیا Access-Control-Allow-Origin وجود دارد و دقیقاً برابر Origin شماست (پروتکل، میزبان، پورت و بدون اسلش انتهایی)؟
  3. آیا Preflight کد 2xx برگرداند؟ 404 یا 405 روی OPTIONS یعنی سرور Preflight را برای آن مسیر مدیریت نمی‌کند.
  4. آیا خطای واقعی 401 یا 500 بدون هدر CORS است؟ لاگ سرور را بررسی کنید.
  5. از Credentials استفاده می‌کنید؟ مطمئن شوید Origin برابر * نیست و Allow-Credentials برابر true است.
پیام خطا به این اشاره دارد علت محتمل
«No 'Access-Control-Allow-Origin' header» Origin مجاز نیست، ترتیب میان‌افزار یا خطای سرور
«does not pass access control check: wildcard» استفاده از * همراه Credentials
«Method PUT is not allowed» متد در Policy نیست
«Request header field authorization is not allowed» هدر در WithHeaders نیست

اشتباهات امنیتی

  • AllowAnyOrigin() روی APIای که از کوکی استفاده می‌کند — یا بدتر، برگرداندن هر Origin همراه Credentials. این کار به هر سایتی اجازه می‌دهد به‌جای کاربران شما درخواست احراز هویت‌شده بفرستد و پاسخ را بخواند.
  • تطبیق Origin با Contains یا EndsWith — آدرس‌هایی مثل example.com.attacker.net یا evil-example.com عبور می‌کنند. مقادیر را دقیق مقایسه کنید.
  • CORS را احراز هویت فرض کردن. CORS فقط کنترل می‌کند جاوااسکریپت کدام مرورگرها پاسخ را بخواند. مهاجم با curl مستقیم API را صدا می‌زند. همیشه در سرور احراز هویت و مجوزدهی کنید.
  • مجاز کردن Origin برابر null — iframeهای Sandbox و فایل‌های محلی Origin: null می‌فرستند؛ آن را در لیست سفید نگذارید.

AllowAnyOrigin() فقط برای APIهای واقعاً عمومی و فقط‌خواندنی بدون کوکی قابل قبول است (مثل یک API عمومی آب‌وهوا).

جمع‌بندی

CORS استثنای کنترل‌شده‌ی مرورگر بر سیاست Same-Origin است. درخواست‌های ساده مستقیم ارسال می‌شوند، بقیه با یک Preflight از نوع OPTIONS شروع می‌شوند و Credentials به Origin مشخص و Allow-Credentials نیاز دارد. در ASP.NET Core یک Policy صریح با Originهای پیکربندی‌شده تعریف کنید، UseCors را زود قرار دهید و در صورت امکان میزبانی هم‌مبدأ یا پراکسی توسعه را ترجیح دهید. و یادتان باشد: CORS از مرورگر کاربران محافظت می‌کند و جایگزین امنیت سمت سرور نیست.

پرسش‌های متداول

چرا درخواستم در Postman کار می‌کند اما در مرورگر نه؟

Postman مرورگر نیست و سیاست Same-Origin را اعمال نمی‌کند. فقط مرورگرها CORS را اجرا می‌کنند.

آیا می‌توانم CORS را در فرانت‌اند حل کنم؟

خیر. فقط سرور (یا پراکسی جلوی آن) می‌تواند هدرهای مجوز را اضافه کند. «راه‌حل‌های» فرانت‌اندی مثل mode: "no-cors" فقط پاسخ را غیرقابل‌خواندن می‌کنند.

آیا CORS در برابر CSRF محافظت می‌کند؟

در بهترین حالت تا حدی. برای محافظت از CSRF از کوکی‌های SameSite، توکن Anti-Forgery یا Bearer Token استفاده کنید؛ پیکربندی اشتباه CORS حتی می‌تواند CSRF را بدتر کند.