security

CORS: ce este, preflight și capcanele care apar la primul deploy

CORS controlează ce origini pot citi răspunsurile API-ului tău. Ce este preflight, capcanele cu credentials și wildcards, cum faci debug și cum configurezi.

Cuprins

CORS (Cross-Origin Resource Sharing) este mecanismul prin care un server declară explicit ce origini externe au voie să citească răspunsurile sale în browser. Fără această declarație, same-origin policy blochează JavaScript-ul să acceseze răspunsul, chiar dacă cererea HTTP a ajuns și a primit un cod de succes.

CORS apare invariabil la prima integrare frontend-backend pe domenii sau porturi diferite, la primul deploy în producție cu un CDN în față, sau când un partener vrea să consume API-ul tău dintr-o altă aplicație web. Confuzia frecventă este că CORS ar fi o problemă de server. Nu este: serverul doar declară politica, iar browserul o aplică. Un client non-browser ignoră complet headerele CORS.

Ce este CORS și ce problemă rezolvă same-origin policy?

Same-origin policy este o regulă de securitate a browserului care interzice JavaScript-ului să citească răspunsuri de la un origin diferit de cel al paginii. Un origin este combinația schema + host + port: https://app.exemplu.com și https://api.exemplu.com sunt origini diferite (subdomain diferit). La fel și http://localhost:4200 față de http://localhost:8080 (port diferit).

Regula există pentru că altfel un script malițios pe https://rau.com ar putea face fetch la https://banca-ta.com/sold în sesiunea ta autentificată și citi răspunsul. Same-origin policy oprește citirea, nu cererea: aceasta tot ajunge la server, dar browserul refuză să expună răspunsul scriptului.

CORS este mecanismul prin care serverul relaxează deliberat această regulă. Prin headerul Access-Control-Allow-Origin serverul declară ce origini pot citi răspunsul, iar browserul verifică și permite accesul.

Cum funcționează preflight-ul (OPTIONS și headerele Access-Control)?

Nu toate cererile cross-origin declanșează preflight. Specificația W3C împarte cererile în două categorii:

  • Cereri simple. GET sau POST cu Content-Type: application/x-www-form-urlencoded, multipart/form-data sau text/plain, fără headere custom. Browserul le trimite direct, cu un header Origin adăugat automat, și verifică dacă răspunsul conține Access-Control-Allow-Origin.
  • Cereri preflighted. Orice cerere cu metodă altă decât GET/POST/HEAD, cu Content-Type: application/json, sau cu headere custom (de exemplu, Authorization). Înainte de cererea reală, browserul trimite automat o cerere OPTIONS la același URL.

Cererea OPTIONS conține două headere cheie:

  • Access-Control-Request-Method: metoda HTTP pe care browserul vrea să o folosească (ex: PUT).
  • Access-Control-Request-Headers: headerele custom pe care vrea să le trimită (ex: Authorization, Content-Type).

Serverul trebuie să răspundă cu:

  • Access-Control-Allow-Origin: originea permisă sau *.
  • Access-Control-Allow-Methods: metodele permise.
  • Access-Control-Allow-Headers: headerele permise.
  • Access-Control-Max-Age (opțional): câte secunde poate browserul să cache-uieze răspunsul preflight, evitând un OPTIONS la fiecare cerere.

Dacă preflight-ul eșuează (server întoarce 404 pe OPTIONS, sau lipsesc headerele), cererea reală nu mai pleacă deloc. Eroarea din consolă va indica refuzul preflight, nu o problemă cu cererea principală.

Care sunt capcanele comune: credentials, wildcards și cache?

  • Wildcard cu credentials. Access-Control-Allow-Origin: * și fetch(..., { credentials: 'include' }) nu funcționează împreună. Specificația interzice explicit combinația: dacă cererea include credentials (cookies, autorization headers), serverul trebuie să returneze originea exactă, nu *. Browserul refuză să expună răspunsul dacă vede * în loc de un origin explicit.
  • Preflight cache agresiv în CDN. Unele CDN-uri sau proxy-uri cache-uiesc răspunsurile OPTIONS fără să respecte Access-Control-Max-Age. Dacă schimbi politica CORS iar utilizatorii continuă să primească erori ore în șir, cauza probabilă este un preflight cache-uit mai sus în stivă, nu serverul tău.
  • Header duplicat din configurare la ambele straturi. Dacă atât nginx cât și aplicația (Spring Boot, de exemplu) adaugă Access-Control-Allow-Origin, răspunsul final va conține headerul de două ori. Browserul tratează valori duplicate ca o eroare și blochează cererea. Configurează CORS la un singur strat.
  • Origin cu trailing slash sau port implicit omis. https://app.exemplu.com/ și https://app.exemplu.com sunt origini diferite pentru verificarea strictă. Același lucru pentru https://app.exemplu.com:443 față de https://app.exemplu.com. Compară exact ce trimite browserul în headerul Origin cu ce configurezi pe server.
  • Subdomain wildcard nesuportat. Access-Control-Allow-Origin: *.exemplu.com nu este valid: specificația nu suportă wildcards parțiale. Dacă ai mai multe subdomenii, fie listezi fiecare explicit, fie validezi dinamic originea față de o listă albă și returnezi originea exactă în header.

Cum faci debug rapid unei erori de CORS în browser?

Eroarea din consolă (CORS policy: No 'Access-Control-Allow-Origin' header sau variante) nu spune totul. Pașii de diagnostic:

  1. Deschide DevTools, tabul Network. Filtrează după OPTIONS. Dacă nu vezi nicio cerere OPTIONS pentru endpointul problematic, fie este o cerere simplă (nu declanșează preflight), fie a eșuat înainte de a pleca.
  2. Inspectează cererea OPTIONS. Verifică statusul: trebuie să fie 200 sau 204. Dacă e 404 sau 405, serverul nu are un handler pentru OPTIONS pe acea rută. Dacă e 200 dar eroarea persistă, deschide Response Headers și caută Access-Control-Allow-Origin.
  3. Verifică headerele de răspuns ale cererii principale. Chiar dacă preflight-ul trece, cererea reală tot are nevoie de Access-Control-Allow-Origin în răspuns. Uneori preflight-ul e configurat corect iar cererea reală nu.
  4. Verifică dacă headerul apare de două ori. Selectează cererea, tabul Headers, caută duplicate la Access-Control-Allow-Origin. Orice duplicat este eroare.
  5. Reproduci cu curl ca sanity check. curl -v -H "Origin: https://app.ta.com" https://api.ta.com/endpoint îți arată exact ce returnează serverul, fără logica de browser în mijloc. Dacă curl primește headerele corecte și browserul încă refuză, cauza e în browser: poate o extensie, poate o politică de securitate suplimentară.

Erorile CORS din browser apar ca cereri neterminate, nu ca statusuri 4xx, deci nu le vei vedea în log-urile serverului. Dacă vrei să le înregistrezi, adaugă un handler global pentru fetch care prinde eșecurile de tip network și le trimite la stack-ul de observabilitate.

Cum configurezi CORS corect în nginx și Spring Boot?

Regula de aur: configurează CORS la un singur strat. Dacă îl configurezi la ambele, headerele se duplică și browserul refuză.

Opțiunea 1: CORS în nginx. Are sens când vrei o politică uniformă pentru tot ce servește nginx-ul, indiferent de backend. Exemplu de configurare:

server {
    location /api/ {
        # adaugă headerele CORS pe toate răspunsurile, inclusiv preflight
        add_header 'Access-Control-Allow-Origin' 'https://app.ta.com' always;
        add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
        add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type' always;
        add_header 'Access-Control-Max-Age' '3600' always;

        # răspunde la preflight fără să trimită cererea la backend
        if ($request_method = 'OPTIONS') {
            return 204;
        }

        proxy_pass http://127.0.0.1:8080;
    }
}

Keyword always este important: fără el, nginx adaugă headerele CORS doar pe 2xx, nu și pe erori. Preflight-ul cu 204 se oprește la nginx și nu ajunge la backend.

Opțiunea 2: CORS în Spring Boot. Are sens când vrei politici per endpoint sau logică dinamică pe lista albă. Două abordări:

Prin adnotare pe controller:

@CrossOrigin(origins = "https://app.ta.com", maxAge = 3600)
@RestController
@RequestMapping("/api/v1")
public class ArticleController { ... }

Sau global prin WebMvcConfigurer, când politica e uniformă pe tot API-ul:

@Configuration
public class CorsConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
                .allowedOrigins("https://app.ta.com")
                .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
                .allowedHeaders("Authorization", "Content-Type")
                .maxAge(3600);
    }
}

La crawlerra, nginx stă în față cu Spring Boot în spate. Politica practică: configurează CORS la aplicație (Spring Boot), nu la nginx, dacă ai origini diferite per endpoint sau logică dinamică pe lista albă. Nginx rămâne pentru TLS termination și routing, descrise în intrarea despre API gateway și rolul nginx ca proxy. Pentru endpoints sensibile, combini CORS cu autentificare JWT ca să te asiguri că un origin permis tot nu poate accesa date fără token valid.

Atenție la mediul de development față de producție: în dev, lista albă de origini include localhost:4200; în producție, include doar domeniile reale. Gestionează asta prin profile Spring Boot (application-dev.yml vs application-prod.yml), nu prin hardcodarea ambelor origini în același fișier. O configurare CORS corectă, combinată cu autentificare și rate limiting, contribuie la un SLA previzibil.

Întrebări frecvente

De ce primesc eroare CORS deși am setat Access-Control-Allow-Origin?

Cel mai probabil headerul e setat de două ori (nginx și Spring Boot simultan) sau lipsește pe răspunsul de preflight OPTIONS. Verifică în DevTools tab-ul Network, filtrează OPTIONS: dacă headerul apare de două ori în răspuns, browserul îl respinge. Dacă cererea OPTIONS întoarce 404 sau nu conține Access-Control-Allow-Methods, preflight-ul eșuează înainte ca cererea reală să plece.

Pot folosi Access-Control-Allow-Origin: * cu credentials?

Nu, combinația este interzisă de specificație și browserul o refuză. Dacă folosești fetch cu credentials: include sau XMLHttpRequest cu withCredentials: true, serverul trebuie să returneze exact originea solicitantă în Access-Control-Allow-Origin, nu wildcard-ul *. Browserul refuză să expună răspunsul dacă vede * în loc de un origin explicit.

CORS este o măsură de securitate pe server?

Nu, CORS este aplicat exclusiv de browser. Un client non-browser (curl, Postman, aplicație mobilă nativă, server-to-server) ignoră complet headerele CORS. Dacă vrei să restricționezi accesul la API la nivel de server, ai nevoie de autentificare și autorizare reale, nu de CORS.

Cât timp pot pune în cache preflight-ul?

Maxim 600 de secunde (10 minute) în Chrome; Firefox acceptă până la 86400 de secunde (24 de ore). Access-Control-Max-Age controlează durata de cache pentru răspunsul OPTIONS. Un cache prea lung înseamnă că schimbările de politică CORS nu se propagă rapid la utilizatori. Valoarea practică de pornire este 3600 (1 oră).

Frontend și backend pe același domeniu mai au nevoie de CORS?

Nu, same-origin policy nu se aplică dacă schema, host-ul și portul sunt identice. CORS apare la granița de origine: frontend pe app.exemplu.com care apelează api.exemplu.com, sau un SPA pe localhost:4200 care apelează localhost:8080 (portul diferă, origini diferite). Dacă servești ambele pe același host:port, nu ai nevoie de CORS.