Saltar al contenido principal

CORS Avanzado en ASP.NET Core 🌐

CORS (Cross-Origin Resource Sharing) es el mecanismo del navegador que controla qué orígenes externos pueden hacer requests a tu API. Entender CORS a profundidad es clave para evitar errores de producción y configuraciones inseguras.


¿Por qué existe CORS?

El navegador implementa la Same-Origin Policy: por defecto, JavaScript solo puede hacer requests al mismo origen (protocolo + dominio + puerto). CORS es el mecanismo para relajar esto de forma controlada.

Frontend: https://mi-app.com:443
↓ fetch('/api/datos')
API: https://mi-api.com:443 ← ORIGEN DIFERENTE → CORS bloqueado por defecto

El BROWSER bloquea la respuesta, no el servidor.
El servidor ya procesó el request — el browser simplemente no deja leer la respuesta.
Error común

CORS no es seguridad del servidor. Es seguridad del browser. Un cliente como curl, Postman o un servidor backend NO está restringido por CORS. CORS protege a los usuarios de ataques CSRF desde navegadores.


Configuración básica

// Program.cs — setup completo
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddCors(options =>
{
// Política para producción — restrictiva
options.AddPolicy("ProdPolicy", policy =>
policy
.WithOrigins(
"https://mi-app.com",
"https://www.mi-app.com"
)
.WithMethods("GET", "POST", "PUT", "DELETE", "PATCH")
.WithHeaders("Content-Type", "Authorization", "X-Requested-With")
.AllowCredentials() // permite cookies y Authorization header
.SetPreflightMaxAge(TimeSpan.FromMinutes(10))
);

// Política para desarrollo — permisiva
options.AddPolicy("DevPolicy", policy =>
policy.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()
);
});

var app = builder.Build();

// ⚠️ ORDEN IMPORTA: CORS debe ir ANTES de Authorization
app.UseRouting();
app.UseCors(app.Environment.IsDevelopment() ? "DevPolicy" : "ProdPolicy");
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();

Preflight requests (OPTIONS)

Para requests "no simples" (con Authorization header, Content-Type: application/json, métodos no estándar), el browser envía primero un preflight request OPTIONS:

Browser                          Servidor
| |
| OPTIONS /api/datos |
| Origin: https://mi-app.com |
| Access-Control-Request-Method: POST |
| Access-Control-Request-Headers: Authorization, Content-Type |
| |
| ← 204 No Content |
| Access-Control-Allow-Origin: https://mi-app.com |
| Access-Control-Allow-Methods: GET, POST, PUT |
| Access-Control-Allow-Headers: Authorization, Content-Type |
| Access-Control-Max-Age: 600 |
| |
| POST /api/datos (request real)|
| |
// SetPreflightMaxAge reduce la cantidad de preflights
// (el browser cachea el resultado por X segundos)
options.AddPolicy("OptimizedPolicy", policy =>
policy
.WithOrigins("https://mi-app.com")
.AllowAnyMethod()
.AllowAnyHeader()
.SetPreflightMaxAge(TimeSpan.FromHours(1)) // cachea 1 hora
);

Políticas por endpoint

// Política default para toda la app
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
policy.WithOrigins("https://mi-app.com").AllowAnyMethod().AllowAnyHeader()
);

options.AddPolicy("PublicApi", policy =>
policy.AllowAnyOrigin().WithMethods("GET") // endpoints públicos de lectura
);

options.AddPolicy("AdminApi", policy =>
policy.WithOrigins("https://admin.mi-app.com").AllowAnyMethod().AllowAnyHeader().AllowCredentials()
);
});

app.UseCors(); // aplica DefaultPolicy

// Sobrescribir por controlador o endpoint
[ApiController]
[Route("api/[controller]")]
[EnableCors("AdminApi")] // ← aplica AdminApi a todo el controlador
public class AdminController : ControllerBase { }

// Desactivar CORS en un endpoint específico
[HttpGet("public-data")]
[DisableCors] // ← no CORS headers
public IActionResult GetPublicData() => Ok(data);

// Endpoint público con su propia política
app.MapGet("/api/status", () => "OK")
.RequireCors("PublicApi");

AllowCredentials y sus implicaciones

// ⚠️ AllowCredentials con AllowAnyOrigin es INVÁLIDO
// ASP.NET Core lanzará excepción en runtime

// ❌ Inválido
options.AddPolicy("Broken", policy =>
policy.AllowAnyOrigin().AllowCredentials() // FALLA: InvalidOperationException
);

// ✅ Correcto: necesitas orígenes específicos para credentials
options.AddPolicy("WithCredentials", policy =>
policy
.WithOrigins("https://mi-app.com")
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials() // permite cookies, Authorization header, TLS client certs
);

AllowCredentials es necesario cuando:

  • Tu frontend envía cookies de sesión
  • Usas Authorization: Bearer con credentials: 'include' en fetch
  • Usas autenticación HTTP básica

Orígenes dinámicos (multitenancy)

Cuando los orígenes válidos son dinámicos (lista de tenants), usa SetIsOriginAllowed:

// Program.cs
builder.Services.AddCors(options =>
{
options.AddPolicy("MultiTenant", policy =>
policy
.SetIsOriginAllowed(origin => EsOrigenPermitido(origin))
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials()
);
});

// Desde configuración o base de datos
bool EsOrigenPermitido(string origin)
{
var permitidos = new HashSet<string>
{
"https://cliente1.mi-saas.com",
"https://cliente2.mi-saas.com",
"https://cliente3.mi-saas.com",
};
return permitidos.Contains(origin);
}
// Versión con DI (para leer de base de datos)
public class DynamicCorsPolicy : ICorsPolicyProvider
{
private readonly ITenantRepository _tenants;

public DynamicCorsPolicy(ITenantRepository tenants) => _tenants = tenants;

public async Task<CorsPolicy?> GetPolicyAsync(HttpContext context, string? policyName)
{
var allowedOrigins = await _tenants.GetAllowedOriginsAsync();

return new CorsPolicyBuilder()
.WithOrigins(allowedOrigins.ToArray())
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials()
.Build();
}
}

// Registrar
builder.Services.AddSingleton<ICorsPolicyProvider, DynamicCorsPolicy>();

CORS en minimal APIs

var app = builder.Build();
app.UseCors();

// Política inline en el endpoint
app.MapGet("/api/publico", () => "datos")
.RequireCors(policy => policy.AllowAnyOrigin().WithMethods("GET"));

// Referencia a política con nombre
app.MapPost("/api/datos", async (Dto dto, DbContext db) =>
{
// ...
}).RequireCors("ProdPolicy");

// Grupo de endpoints con misma política
var api = app.MapGroup("/api").RequireCors("ProdPolicy");
api.MapGet("/productos", GetProductos);
api.MapPost("/productos", CreateProducto);

Testing de CORS

// Integration test verificando headers CORS
public class CorsTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;

public CorsTests(WebApplicationFactory<Program> factory)
{
_client = factory.CreateClient();
}

[Fact]
public async Task Origen_Permitido_Retorna_Headers_CORS()
{
var request = new HttpRequestMessage(HttpMethod.Get, "/api/datos");
request.Headers.Add("Origin", "https://mi-app.com");

var response = await _client.SendAsync(request);

response.Headers.TryGetValues("Access-Control-Allow-Origin", out var values);
Assert.Contains("https://mi-app.com", values);
}

[Fact]
public async Task Origen_No_Permitido_No_Retorna_Headers_CORS()
{
var request = new HttpRequestMessage(HttpMethod.Get, "/api/datos");
request.Headers.Add("Origin", "https://sitio-malicioso.com");

var response = await _client.SendAsync(request);

Assert.False(response.Headers.Contains("Access-Control-Allow-Origin"));
}

[Fact]
public async Task Preflight_Retorna_204()
{
var request = new HttpRequestMessage(HttpMethod.Options, "/api/datos");
request.Headers.Add("Origin", "https://mi-app.com");
request.Headers.Add("Access-Control-Request-Method", "POST");
request.Headers.Add("Access-Control-Request-Headers", "Content-Type, Authorization");

var response = await _client.SendAsync(request);

Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
}
}

Errores comunes y soluciones

ErrorCausaSolución
AllowCredentials + AllowAnyOriginCombinación inválidaUsar WithOrigins("url") específica
CORS falla en producción pero no en devPolítica diferente por environmentVerificar política activa en producción
OPTIONS retorna 404CORS middleware después de RoutingMover app.UseCors() antes de app.UseRouting()
Authorization header bloqueadoHeader no permitido explícitamenteAgregar a WithHeaders o usar AllowAnyHeader()
Wildcard * con credencialesEl browser lo rechazaUsar origen específico con AllowCredentials()

Preguntas frecuentes de entrevista 🎯

1. ¿CORS es seguridad del servidor o del browser?

Del browser. El servidor recibe y procesa el request independientemente de CORS. El browser es quien bloquea la respuesta si los headers no coinciden. Por eso CORS no protege contra ataques de servidor a servidor (curl, Postman, etc.) — solo de código JavaScript en un navegador.

2. ¿Por qué AllowAnyOrigin() y AllowCredentials() no pueden usarse juntos?

El estándar CORS prohíbe explícitamente que un servidor responda Access-Control-Allow-Origin: * cuando el cliente envía credentials: 'include'. El browser rechazaría la respuesta de todos modos. ASP.NET Core lanza excepción al intentarlo para prevenir configuraciones rotas.

3. ¿Qué es un preflight request y cuándo ocurre?

El browser envía una solicitud OPTIONS previa para verificar que el servidor permite el método y headers del request real. Ocurre cuando: el método no es GET/POST/HEAD, hay headers no estándar (como Authorization), o Content-Type no es uno de los tres tipos simples. SetPreflightMaxAge hace que el browser cachee el resultado para evitar preflights repetidos.

🧠 Mini-Quiz — CORS Avanzado1/3

¿Qué entidad implementa la restricción de CORS?