La SPA de la Botiga Aroma viu a https://botigaaroma.example. L'API viu a https://api.botigaaroma.example. La primera vegada que algú escriu un fetch des de la SPA cap a l'API, la consola del navegador mostra el missatge més famós del desenvolupament web:

Access to fetch at 'https://api.botigaaroma.example/v1/cafes' from origin
'https://botigaaroma.example' has been blocked by CORS policy: No 'Access-Control-Allow-Origin'
header is present on the requested resource.

I llavors passa el pitjor: algú busca a Internet, troba app.use(cors()) sense arguments, l'enganxa, funciona, i acaba d'obrir la seva API a qualsevol pàgina web del món. Aquesta lliçó existeix perquè això no passi. Entendrem per què el navegador bloqueja, què protegeix exactament, per què curl i Aroma Mòbil no veuen cap d'aquests problemes, i com es configura una política que deixa passar la SPA i el tauler sense obrir la porta a ningú més. En acabar, src/config/cors.js existirà i el middleware cors ocuparà la posició 4 de la cadena de src/app.js —la posició importa, i veurem per què.

Advertiment. CORS és un mecanisme del navegador i no és un control d'accés del servidor. Una configuració de CORS ben feta no substitueix l'autenticació ni l'autorització. Qualsevol desplegament real l'ha de revisar un professional de seguretat. Tots els dominis i dades d'aquesta lliçó són ficticis.

Contingut

  1. La política del mateix origen
  2. Què és exactament un origen
  3. Què protegeix realment la política del mateix origen
  4. Per què curl i Aroma Mòbil no en resulten afectats
  5. Peticions simples i peticions amb preflight
  6. L'intercanvi OPTIONS complet
  7. Totes les capçaleres de CORS
  8. Access-Control-Expose-Headers: la que sempre falta
  9. La configuració real de la Botiga Aroma
  10. Per què * i Allow-Credentials són incompatibles
  11. La posició a la cadena de src/app.js
  12. Errors típics i com llegir-los a la consola
  13. Galetes entre orígens: SameSite, Secure, HttpOnly
  14. CSRF: què és i per què Bearer n'és immune
  15. Altres polítiques del navegador
  16. CORS no autoritza res
  17. Provar CORS amb curl i amb les DevTools

  1. La política del mateix origen

La política del mateix origen (Same-Origin Policy, SOP) és la regla de seguretat fonamental dels navegadors: el codi JavaScript d'una pàgina només pot llegir respostes del seu propi origen.

És el que impedeix això:

// Aquest script és a https://lloc-malicios.example, que has obert sense adonar-te'n.
const r = await fetch('https://el-meu-banc.example/api/comptes', { credentials: 'include' });
const dades = await r.json();          // ← la SOP ho impedeix
enviarAAtacant(dades);

Sense la SOP, qualsevol pàgina que visitessis podria, en segon pla, llegir el teu correu, el teu banc i la teva intranet aprofitant les sessions que ja tens obertes. El web seria inutilitzable.

I ara la part incòmoda: la SOP també bloqueja la teva pròpia SPA cridant la teva pròpia API, perquè el navegador no té manera de saber que botigaaroma.example i api.botigaaroma.example són la mateixa organització.

CORS (Cross-Origin Resource Sharing) és el mecanisme estandarditzat pel qual el servidor diu al navegador: «aquest origen concret sí que pot llegir les meves respostes». És a dir:

CORS no és una defensa. És una relaxació controlada d'una defensa que ja existeix.

Interioritzar aquesta frase evita el 90 % dels errors conceptuals sobre CORS.

  1. Què és exactament un origen

Un origen és la tripleta esquema + host + port. Tots tres han de coincidir, exactament, sense excepcions.

Prenent https://botigaaroma.example com a referència:

URL Mateix origen? Per què
https://botigaaroma.example/cistella La ruta no forma part de l'origen
https://botigaaroma.example:443/ 443 és el port per defecte d'HTTPS
http://botigaaroma.example No Esquema diferent
https://api.botigaaroma.example No Host diferent (subdomini ≠ mateix host)
https://www.botigaaroma.example No Host diferent
https://botigaaroma.example:8443 No Port diferent
https://botigaaroma.example.evil.example No Un altre domini que només comença igual

Dues conseqüències pràctiques:

Un subdomini és un altre origen. Aquesta és la font número u de sorpresa: molta gent assumeix que api.botigaaroma.example és "el mateix" que botigaaroma.example. Per al navegador no ho és, i per això la Botiga Aroma necessita CORS.

L'última fila importa per a la seguretat. En validar orígens, origen.startsWith('https://botigaaroma.example') accepta https://botigaaroma.example.evil.example, un domini que qualsevol pot registrar. La comparació ha de ser d'igualtat exacta contra una llista blanca.

  1. Què protegeix realment la política del mateix origen

Aquí hi ha una subtilesa que gairebé tothom entén malament al principi, i que canvia completament com es raona sobre CORS.

La petició s'envia. Quan la teva pàgina fa fetch a un altre origen sense preflight, el navegador envia la petició al servidor i el servidor la processa. El que la SOP bloqueja és que el JavaScript de la pàgina llegeixi la resposta.

sequenceDiagram
  participant JS as JS a lloc-malicios.example
  participant N as Navegador
  participant API as api.botigaaroma.example

  JS->>N: fetch('https://api.botigaaroma.example/v1/cafes')
  N->>API: GET /v1/cafes (la peticio SI que s'envia)
  API->>API: La processa: consulta la base de dades
  API->>N: 200 amb les dades (sense Access-Control-Allow-Origin)
  N->>N: Falta la capcalera: NO lliuro la resposta
  N->>JS: TypeError: Failed to fetch (sense detalls)

Conseqüències que cal tenir molt clares:

  • Si l'endpoint té efectes secundaris, es produeixen igualment. Un GET que esborrés alguna cosa l'esborraria, encara que l'atacant no en veiés la resposta. És un argument més per a la safety del GET de 02-03.
  • CORS no protegeix el teu servidor de res. Protegeix les dades de l'usuari impedint que una altra pàgina les llegeixi amb les credencials d'aquell usuari.
  • L'atacant no veu l'error. El JavaScript rep un TypeError: Failed to fetch genèric, sense codi d'estat ni cos. És deliberat: si veiés el 401 o el 404, ja tindria informació.

Les peticions amb preflight (apartat 5) sí que s'aturen abans d'enviar-se, i allà sí que hi ha una protecció real del servidor: per això Content-Type: application/json i Authorization disparen preflight.

  1. Per què curl i Aroma Mòbil no en resulten afectats

# Això funciona perfectament. Sempre. Amb qualsevol API del món.
curl -s https://api.botigaaroma.example/v1/cafes

curl no aplica CORS. Ni Postman, ni un script de Node, ni el backend de CataBox, ni l'app Aroma Mòbil (que fa peticions natives, no des d'un context de navegador). CORS l'implementa el navegador, i només el navegador, perquè és l'únic que executa codi de tercers amb les credencials de l'usuari.

Consumidor Aplica CORS? Per què
SPA de la botiga És JavaScript en un navegador
Tauler intern Ídem
Aroma Mòbil (nativa) No No hi ha context d'origen
Aroma Mòbil (WebView) És un navegador incrustat
Backend de CataBox No Servidor a servidor
RàpidEnviaments No Ídem
curl, Postman No No són navegadors

D'aquí se'n segueix la conclusió més important de la lliçó, que cal repetir fins que resulti òbvia:

Una política de CORS restrictiva no impedeix a ningú cridar la teva API. Només impedeix que una pàgina web d'un altre origen llegeixi la resposta des del navegador d'un usuari. La protecció real de les teves dades és, sempre, l'autenticació i l'autorització del servidor.

  1. Peticions simples i peticions amb preflight

El navegador distingeix dues categories, i la diferència és visible en el rendiment i en els errors.

Peticions simples

S'envien directament, sense consulta prèvia. Han de complir totes aquestes condicions:

  • Mètode GET, HEAD o POST.
  • Capçaleres limitades a les «segures de llista»: Accept, Accept-Language, Content-Language, Content-Type (amb restriccions) i unes quantes més.
  • Content-Type només pot ser application/x-www-form-urlencoded, multipart/form-data o text/plain.
  • Sense gestors d'esdeveniments de pujada a l'XMLHttpRequest.

La raó històrica d'aquesta llista: són exactament les peticions que un formulari HTML podia fer abans que existís CORS. Com que ja eren possibles, exigir permís previ no hauria afegit seguretat i sí que hauria trencat el web.

Peticions amb preflight

Qualsevol cosa fora d'aquella llista dispara una petició prèvia OPTIONS. A la Botiga Aroma, això vol dir gairebé totes:

Petició Preflight? Per què
GET /v1/cafes sense capçaleres No Simple
GET /v1/cafes amb Authorization Capçalera no permesa a les simples
POST /v1/comandes amb Content-Type: application/json Aquell Content-Type no és a la llista
POST amb Content-Type: text/plain No Simple
PUT, PATCH, DELETE Mètode no permès a les simples
Qualsevol amb Aroma-Traca-Id Capçalera personalitzada

Com que la nostra API fa servir Authorization: Bearer i application/json a tot arreu, pràcticament tot el trànsit de la SPA porta preflight. Per això Access-Control-Max-Age (apartat 7) té un impacte real en el rendiment: sense memòria cau de preflight, cada petició es converteix en dues.

  1. L'intercanvi OPTIONS complet

Vegem, en cru, què passa quan la SPA crea una comanda.

sequenceDiagram
  participant JS as SPA (botigaaroma.example)
  participant N as Navegador
  participant API as api.botigaaroma.example

  JS->>N: fetch POST /v1/comandes amb Authorization i JSON
  Note over N: No es simple: cal preflight
  N->>API: OPTIONS /v1/comandes + Origin + Access-Control-Request-*
  API->>N: 204 + Allow-Origin, Allow-Methods, Allow-Headers, Max-Age
  Note over N: Permes. Desa la resposta 10 min
  N->>API: POST /v1/comandes (la peticio real)
  API->>N: 201 + Allow-Origin + Expose-Headers + Location
  N->>JS: Resposta lliurada, inclosa la capcalera Location

Pas 1: el preflight. El genera el navegador sol; el teu codi no l'escriu mai:

OPTIONS /v1/comandes HTTP/1.1
Host: api.botigaaroma.example
Origin: https://botigaaroma.example
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization,content-type,idempotency-key

Fixa't en tres coses: no porta cos, no porta l'Authorization real (només anuncia que l'enviarà) i les capçaleres anunciades van en minúscules i ordenades.

Pas 2: la resposta al preflight.

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://botigaaroma.example
Access-Control-Allow-Methods: GET,POST,PUT,PATCH,DELETE
Access-Control-Allow-Headers: Authorization,Content-Type,Idempotency-Key,Aroma-Traca-Id
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 600
Vary: Origin

Pas 3: la petició real. Només si el pas 2 ho va permetre:

POST /v1/comandes HTTP/1.1
Host: api.botigaaroma.example
Origin: https://botigaaroma.example
Authorization: Bearer eyJhbGciOi...
Content-Type: application/json
Idempotency-Key: 3f2b9a10-7c4e-4b6a-9d21-0a5e1f8c7b33

{"clientId":"cli_842","linies":[{"cafeId":"caf_001","quantitat":2}]}

Pas 4: la resposta real. I aquí hi ha el punt que s'oblida sempre: la resposta real també necessita Access-Control-Allow-Origin. Que el preflight hagi anat bé no n'hi ha prou.

HTTP/1.1 201 Created
Access-Control-Allow-Origin: https://botigaaroma.example
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: Location,Link,Aroma-Traca-Id,Aroma-RateLimit-Limit,Aroma-RateLimit-Restants,Aroma-RateLimit-Reinici,Retry-After
Location: /v1/comandes/com_5002
Vary: Origin
Content-Type: application/json

{"id":"com_5002","estat":"pendent_pagament", ...}

Sense Expose-Headers, la SPA rebria el 201 i el cos, però resposta.headers.get('Location') retornaria null.

  1. Totes les capçaleres de CORS

Que envia el navegador

Capçalera Quan Contingut
Origin Tota petició entre orígens L'origen de la pàgina. No es pot falsejar des de JavaScript
Access-Control-Request-Method Només preflight Mètode que es farà servir
Access-Control-Request-Headers Només preflight Capçaleres que s'enviaran

Que Origin no sigui falsificable des de JavaScript és el que fa que el mecanisme funcioni: el navegador l'escriu i no deixa que l'script la toqui. Ara bé, un client que no sigui un navegador pot escriure el que vulgui (curl -H "Origin: ..."), i per això Origin no serveix com a control d'accés.

Que respon el servidor

Capçalera Valors Què fa
Access-Control-Allow-Origin Un origen concret o * Qui pot llegir la resposta. Un sol valor, mai una llista
Access-Control-Allow-Methods GET,POST,PUT,... Mètodes permesos (només en preflight)
Access-Control-Allow-Headers Llista de capçaleres Capçaleres que el client pot enviar (només en preflight)
Access-Control-Expose-Headers Llista de capçaleres Capçaleres de resposta que el JS pot llegir
Access-Control-Allow-Credentials true Permet enviar galetes i llegir la resposta amb credencials
Access-Control-Max-Age Segons Quant desar el preflight a la memòria cau
Vary Origin Imprescindible: vegeu a sota

Dos detalls que causen bugs:

Allow-Origin admet un únic valor. No existeix Access-Control-Allow-Origin: https://a.example, https://b.example. Per a diversos orígens, el servidor reflecteix l'origen rebut si és a la seva llista blanca. Això és el que fa el paquet cors amb una funció origin.

Max-Age té sostres del navegador. Encara que demanis 86.400 segons, els navegadors imposen el seu propi màxim (de l'ordre de dues hores a Chromium, menys en d'altres). Un valor de 600 és un bon equilibri: redueix molt els preflights i no deixa un canvi de política congelat massa temps.

Vary: Origin

Si el servidor reflecteix l'origen, la resposta depèn de la capçalera Origin. Sense Vary: Origin, qualsevol memòria cau intermèdia —una CDN, un proxy corporatiu, la memòria cau del navegador— pot desar la resposta per a https://botigaaroma.example i servir-la a una petició de https://panel.botigaaroma.example, que llavors rebrà un Allow-Origin equivocat i fallarà de manera intermitent i inexplicable.

És la mateixa mecànica de Vary que vam veure a 02-05 amb Accept-Language, i la reprendrem a 04-06 en parlar de memòria cau. Els tres casos són el mateix principi: si la resposta depèn d'una capçalera de la petició, digues-ho a Vary.

  1. Access-Control-Expose-Headers: la que sempre falta

Per defecte, el JavaScript d'un altre origen només pot llegir set capçaleres de resposta, les anomenades «segures de llista»:

Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified, Pragma.

Totes les altres són invisibles, encara que hi siguin. I això arrasa amb bona part del que hem construït en aquest curs:

Capçalera Per a què la necessita la SPA Sense exposar
Location Saber la URI de la comanda acabada de crear (03-03) null
Link Paginació RFC 8288 (02-06) La SPA no pot paginar
ETag Peticions condicionals i If-Match (04-06) Sense memòria cau ni concurrència optimista
Retry-After Saber quant esperar després d'un 429 (04-04) Reintents a cegues
Aroma-RateLimit-* Frenar abans de xocar (04-04) Mecanisme inútil
Aroma-Traca-Id Mostrar l'identificador en un missatge d'error (04-07) Suport a cegues
Allow Saber quins mètodes admet després d'un 405 Invisible
Accept-Patch Saber quin format de pedaç accepta (02-05) Invisible

Aquest és l'esglaó que unia les tres lliçons anteriors. La Botiga Aroma exposa exactament això:

Access-Control-Expose-Headers: Location, Link, ETag, Accept-Patch, Allow, Retry-After,
  Aroma-Traca-Id, Aroma-RateLimit-Limit, Aroma-RateLimit-Restants, Aroma-RateLimit-Reinici

I un criteri de disseny: s'exposa el necessari, no tot. Existeix el comodí * per a Expose-Headers, però no funciona quan hi ha credencials i, sobretot, exposar capçaleres d'infraestructura filtra informació innecessària.

  1. La configuració real de la Botiga Aroma

npm install cors
// src/config/cors.js  (fitxer NOU)
import { entorn } from './entorn.js';

/**
 * Orígens permesos, per entorn, llegits de la configuració.
 *
 * A .env:
 *   ORIGENS_PERMESOS=https://botigaaroma.example,https://panel.botigaaroma.example
 *
 * En desenvolupament s'hi afegeixen els locals perquè la SPA a Vite funcioni.
 */
function origensPermesos() {
  const configurats = (entorn.ORIGENS_PERMESOS ?? '')
    .split(',')
    .map((o) => o.trim())
    .filter(Boolean);

  if (entorn.NODE_ENV === 'desenvolupament') {
    return [...configurats, 'http://localhost:5173', 'http://localhost:4173'];
  }
  return configurats;
}

const PERMESOS = new Set(origensPermesos());

export const opcionsCors = {
  /**
   * Funció de decisió. El paquet `cors` la crida amb el valor d'Origin.
   * - callback(null, true)  → reflecteix aquell origen a Access-Control-Allow-Origin
   * - callback(null, false) → NO emet la capçalera: el navegador bloquejarà
   *
   * IMPORTANT: no es crida callback(error). Retornar un error convertiria el
   * preflight en un 500 i el missatge de la consola seria encara més confús. Es
   * respon sense la capçalera i que el navegador apliqui la seva política.
   */
  origin(origen, callback) {
    // Sense Origin: curl, Postman, Aroma Mòbil, servidor a servidor. Es permet:
    // aquestes peticions no les protegeix CORS, les protegeix l'autenticació.
    if (!origen) return callback(null, true);

    // Comparació per IGUALTAT EXACTA contra la llista blanca.
    return callback(null, PERMESOS.has(origen));
  },

  // Mètodes que l'API admet. L'OPTIONS el gestiona el mateix paquet.
  methods: ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'],

  // Capçaleres que el client pot ENVIAR.
  allowedHeaders: [
    'Authorization',
    'Content-Type',
    'Accept',
    'Accept-Language',
    'If-Match',            // concurrència optimista (04-06)
    'If-None-Match',       // memòria cau condicional (04-06)
    'Idempotency-Key',     // 02-03 / 03-03
    'Aroma-Traca-Id',      // el client pot propagar la seva pròpia traça (04-07)
  ],

  // Capçaleres que el client pot LLEGIR. Sense això, són invisibles.
  exposedHeaders: [
    'Location',
    'Link',
    'ETag',
    'Accept-Patch',
    'Allow',
    'Retry-After',
    'Aroma-Traca-Id',
    'Aroma-RateLimit-Limit',
    'Aroma-RateLimit-Restants',
    'Aroma-RateLimit-Reinici',
  ],

  // La Botiga Aroma fa servir Authorization: Bearer, NO galetes. Vegeu l'apartat 13.
  credentials: false,

  // El preflight es desa a la memòria cau 10 minuts: redueix a la meitat les anades i tornades.
  maxAge: 600,

  // Respondre el preflight amb 204 i tallar aquí.
  optionsSuccessStatus: 204,
  preflightContinue: false,
};
# .env.example  (MODIFICAT)
ORIGENS_PERMESOS=https://botigaaroma.example,https://panel.botigaaroma.example

I la validació en l'arrencada, coherent amb 03-01: si en producció no hi ha orígens configurats, el procés no ha d'arrencar, perquè l'alternativa silenciosa és pitjor.

// src/config/entorn.js  (MODIFICAT, fragment)
ORIGENS_PERMESOS: z
  .string()
  .min(1, 'ORIGENS_PERMESOS és obligatori')
  .refine(
    (v) => v.split(',').every((o) => o.trim().startsWith('https://') || o.includes('localhost')),
    'Tots els orígens permesos han de fer servir https (llevat de localhost en desenvolupament)'
  ),

  1. Per què * i Allow-Credentials són incompatibles

Access-Control-Allow-Origin: * significa «qualsevol pàgina web del món pot llegir aquesta resposta». Hi ha un cas en què és perfectament raonable:

Escenari * acceptable Per què
GET /v1/cafes públic sense autenticació És l'aparador; la informació ja és pública
Qualsevol endpoint que retorni dades d'un usuari No Depèn de qui pregunta
Qualsevol cosa amb Authorization o galetes No I a més està prohibit per l'estàndard

La prohibició és explícita a l'especificació: si Access-Control-Allow-Credentials: true, llavors Access-Control-Allow-Origin no pot ser *. Ha de ser un origen concret.

# ❌ El navegador REBUTJA aquesta combinació i bloqueja la resposta.
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

El motiu és clar quan es pensa en l'atac que evita: si es permetés, qualsevol pàgina web podria fer peticions amb les galetes de sessió de l'usuari a qualsevol API i llegir-ne el resultat. Seria l'eliminació completa de la política del mateix origen. El comodí només es pot aplicar a dades que no depenen de qui pregunta, i tan bon punt hi ha credencials, en depenen.

Un matís que confon: credentials: 'include' al fetch afecta galetes i capçaleres d'autenticació HTTP gestionades pel navegador, no un Authorization: Bearer que hi poses tu a mà. Tot i així, amb Bearer continua calent una llista blanca, perquè el token identifica l'usuari i la resposta en depèn.

Decisió de la Botiga Aroma: llista blanca a tota l'API. Es podria fer una excepció amb * a GET /v1/cafes, que és públic i cacheable, però mantenir una sola política és més simple, menys fràgil i coherent amb el principi de consistència de 04-01.

  1. La posició a la cadena de src/app.js

// src/app.js  (extracte després de 04-05)
import express from 'express';
import cors from 'cors';
import { rutesV1 } from './rutes/index.js';
import { assignarTracaId } from './middleware/traca.js';
import { capcaleresSeguretat } from './middleware/seguretat.js';
import { opcionsCors } from './config/cors.js';                 // ← NOU
import { limitGlobal } from './middleware/limit-peticions.js';
import { gestorNoTrobat } from './middleware/no-trobat.js';
import { gestorErrors } from './middleware/errors.js';

export const app = express();

app.disable('x-powered-by');                                    // 1
app.set('trust proxy', 1);                                      //   (04-04)
app.use(assignarTracaId);                                       // 2
app.use(capcaleresSeguretat);                                   // 3  helmet (04-02)
app.use(cors(opcionsCors));                                     // 4  ← NOU
// (5) registre estructurat → 04-07
app.use(limitGlobal);                                           // 6  (04-04)
app.use(express.json({ limit: '100kb', /* ... */ }));           // 7
app.use(express.urlencoded({ extended: false, limit: '10kb' }));
app.get('/salut', (req, res) => res.json({ estat: 'ok' }));     // 8
app.use('/v1', rutesV1);                                        // 9
app.use(gestorNoTrobat);                                        // 10
app.use(gestorErrors);                                          // 11

Per què la posició 4 i no una altra. És la decisió més important d'aquesta lliçó:

  • Abans del rate limiting (6): si no, un 429 surt sense capçaleres de CORS i el navegador el converteix en un error de xarxa opac. El desenvolupador de la SPA veu «Failed to fetch» i no té ni idea que ha superat un límit.
  • Abans de l'analitzador de JSON (7): el preflight OPTIONS no porta cos, però un POST amb JSON mal format produiria un 400 sense capçaleres CORS, i un altre cop la SPA veuria un error inútil.
  • Abans de les rutes (9) i, per tant, abans de tota autenticació: aquesta és la clau de l'error clàssic de l'apartat 12. El preflight OPTIONS no porta Authorization —el navegador no l'envia mai—, així que si el middleware d'autenticació s'executés abans, respondria 401 al preflight i la petició real no s'enviaria mai.
  • Després de helmet (3): perquè la resposta al preflight porti també les capçaleres de seguretat i perquè, si helmet i CORS entren en conflicte (el cas de Cross-Origin-Resource-Policy que vam veure a 04-02), CORS tingui l'última paraula sobre les seves pròpies capçaleres.
  • Després de la traça (2): per poder correlacionar un preflight fallit als logs.

I una conseqüència de disseny que convé notar: el paquet cors respon l'OPTIONS i acaba la cadena (preflightContinue: false). El preflight no arriba a les rutes, així que el router.all(...) amb metodeNoPermes(...) de cada ruta no hi interfereix.

  1. Errors típics i com llegir-los a la consola

Missatge a la consola Causa real Solució
No 'Access-Control-Allow-Origin' header is present L'origen no és a la llista blanca, o el middleware no es va executar Afegir l'origen a ORIGENS_PERMESOS; comprovar que cors és abans del que respon
The 'Access-Control-Allow-Origin' header has a value 'https://altre.example' that is not equal to the supplied origin Una memòria cau intermèdia va retornar la resposta d'un altre origen Afegir Vary: Origin
Response to preflight request doesn't pass access control check L'OPTIONS no va retornar 2xx, o li falten capçaleres Veure l'OPTIONS a la pestanya de xarxa; sol ser un 401 (vegeu a sota)
Method PATCH is not allowed by Access-Control-Allow-Methods Falta el mètode a methods Afegir-lo a opcionsCors.methods
Request header field idempotency-key is not allowed by Access-Control-Allow-Headers Capçalera no declarada Afegir-la a allowedHeaders
Credentials flag is 'true', but 'Access-Control-Allow-Origin' is '*' Combinació prohibida Llista blanca en comptes de *
La resposta arriba però headers.get('Link') és null Falta Access-Control-Expose-Headers Afegir la capçalera a exposedHeaders
TypeError: Failed to fetch sense més detall Pot ser CORS, xarxa, DNS o certificat Mirar la pestanya de xarxa, no la consola

El clàssic: el preflight retorna 401

Access to fetch at 'https://api.botigaaroma.example/v1/comandes' from origin
'https://botigaaroma.example' has been blocked by CORS policy: Response to preflight
request doesn't pass access control check: It does not have HTTP ok status.

Traducció: l'OPTIONS va retornar 401. I va retornar 401 perquè el middleware d'autenticació es va executar abans que el de CORS, i el preflight no porta Authorization: el navegador no l'inclou mai, per disseny, perquè el preflight és una consulta sobre la política, no una petició de dades.

// ❌ El preflight mor a autenticar i no arriba mai a cors.
app.use(autenticar);
app.use(cors(opcionsCors));

// ✅ CORS primer. El preflight es respon amb 204 i ni tan sols arriba a les rutes.
app.use(cors(opcionsCors));
app.use('/v1', rutesV1);       // dins de cada ruta: autenticar, exigirRol...

La confusió aquí és doble, i per això l'error dura tardes senceres: el missatge parla de CORS, però la causa és l'ordre dels middlewares; i la petició que falla (OPTIONS) no és la que vas escriure (POST). La regla de diagnòstic: quan falli CORS, obre la pestanya de xarxa del navegador i busca la petició OPTIONS. Si no hi és, el problema és un altre. Si hi és i no retorna 2xx, allà hi ha la fallada, i el seu codi d'estat et diu exactament quin middleware la va interceptar.

  1. Galetes entre orígens: SameSite, Secure, HttpOnly

Si la Botiga Aroma fes servir galetes de sessió en comptes d'Authorization: Bearer, la configuració necessària seria:

Set-Cookie: sessio=abc123; HttpOnly; Secure; SameSite=None; Path=/; Max-Age=900
Atribut Efecte Per què
HttpOnly JavaScript no la pot llegir Un XSS no roba la sessió
Secure Només s'envia per HTTPS No viatja en clar
SameSite=None S'envia entre llocs Necessari si l'API és en un altre domini
SameSite=Lax Només en navegació de nivell superior Per defecte als navegadors moderns
SameSite=Strict Mai entre llocs Màxima protecció CSRF
Path=/ Àmbit
Max-Age Caducitat Sessió curta

El problema salta a la vista: SameSite=None és exactament el que cal posar perquè funcioni entre botigaaroma.example i api.botigaaroma.example, i és exactament el que reobre la porta al CSRF. A canvi cal afegir tokens anti-CSRF, i a sobre SameSite=None requereix Secure i està subjecte a les restriccions de galetes de tercers que els navegadors fa anys que endureixen.

Per això la Botiga Aroma fa servir Authorization: Bearer:

Galeta de sessió Authorization: Bearer
Enviament automàtic Sí, el navegador l'adjunta No, el codi la posa
Vulnerable a CSRF No
Necessita SameSite=None entre dominis
Afectada pel bloqueig de galetes de tercers No
Funciona igual en mòbil natiu No
Vulnerable a XSS Menys amb HttpOnly Sí si es desa a localStorage
Requereix credentials: true a CORS No

Cap opció no és perfecta: Bearer elimina el CSRF però trasllada el risc a l'emmagatzematge del token al client (04-03). Per a una API consumida per SPA, app mòbil i tercers, Bearer és l'opció coherent, i per això credentials: false a la nostra configuració de CORS.

  1. CSRF: què és i per què Bearer n'és immune

CSRF (Cross-Site Request Forgery) és un atac que aprofita que el navegador adjunta les galetes automàticament. La víctima, amb sessió oberta a la Botiga Aroma, visita una pàgina maliciosa:

<!-- A https://lloc-malicios.example -->
<form action="https://api.botigaaroma.example/v1/comandes" method="POST"
      enctype="text/plain" id="f">
  <input name='{"clientId":"cli_842","linies":[{"cafeId":"caf_999","quantitat":50}],"x":"' value='"}'>
</form>
<script>document.getElementById('f').submit();</script>

El navegador envia la petició amb les galetes de sessió de la víctima. L'atacant no en pot llegir la resposta (la SOP l'hi impedeix), però no li cal: la comanda ja s'ha creat. CSRF és un atac d'escriptura a cegues.

Per què la Botiga Aroma n'és immune:

  1. Fa servir Authorization: Bearer, i el navegador no adjunta aquella capçalera automàticament. Un formulari d'un altre lloc no la hi pot afegir.
  2. Exigeix Content-Type: application/json, que un formulari HTML no pot produir (el truc de l'enctype="text/plain" de l'exemple és precisament per intentar esquivar-ho, i el nostre express.json({type: [...]}) rebutja aquell tipus).
  3. Tota escriptura dispara preflight, i el preflight fallaria perquè lloc-malicios.example no és a la llista blanca.

Totes tres són conseqüència de decisions que ja estaven preses. Si algun dia es migrés a galetes, caldrien defenses explícites:

Defensa Com funciona Nota
SameSite=Lax o Strict El navegador no envia la galeta entre llocs La primera línia, i sovint suficient
Token anti-CSRF sincronitzador El servidor emet un token que el client retorna en una capçalera Estàndard; l'atacant no el pot llegir
Doble enviament de galeta Galeta + mateixa capçalera; el servidor compara Més simple, una mica menys robust
Comprovar Origin Rebutjar si l'Origin no és l'esperat Bon reforç, no defensa única

I l'advertiment final de l'apartat: CSRF i CORS són coses diferents i sovint es confonen. CORS controla qui pot llegir respostes; CSRF explota que les peticions s'envien amb credencials. Configurar CORS estrictament no elimina el CSRF si fas servir galetes, perquè les peticions simples s'envien igualment.

  1. Altres polítiques del navegador

CORS no és l'única política que governa la relació entre la SPA i l'API.

Referrer-Policy, que ja vam activar amb helmet a 04-02 amb el valor no-referrer. Controla quanta informació de la URL d'origen s'envia en navegar o en carregar recursos. Importa perquè les nostres URIs contenen identificadors (/v1/comandes/com_5001) i no volem que apareguin als logs de tercers.

Permissions-Policy (abans Feature-Policy) declara quines capacitats del navegador pot fer servir un document: càmera, micròfon, geolocalització. És una capçalera per a documents HTML, així que la posa la SPA, no l'API:

Permissions-Policy: camera=(), microphone=(), geolocation=(self), payment=()

Content-Security-Policy a la SPA, i en concret la directiva connect-src, que és el complement simètric de CORS: mentre CORS diu quins orígens ens poden llegir, connect-src diu a quins orígens pot cridar la SPA.

Content-Security-Policy:
  default-src 'self';
  connect-src 'self' https://api.botigaaroma.example;
  img-src 'self' https://imatges.botigaaroma.example data:;
  script-src 'self';
  style-src 'self';
  frame-ancestors 'none';
  base-uri 'self'

El seu valor defensiu és concret: si un atacant aconsegueix injectar JavaScript a la SPA (un XSS), connect-src li impedeix exfiltrar les dades al seu propi servidor, perquè el navegador bloquejarà el fetch a un domini no declarat. És la raó que CSP sigui una defensa en profunditat valuosa fins i tot quan ja saneges les entrades.

L'API, que no serveix HTML, manté el seu default-src 'none' de 04-02: res per carregar, res per executar.

  1. CORS no autoritza res

Mereix un apartat propi perquè és el malentès amb pitjors conseqüències.

CORS és una instrucció per al navegador. No és un control d'accés.

El que CORS que fa El que CORS no fa
Dir al navegador quin origen pot llegir la resposta Impedir que algú cridi la teva API
Protegir l'usuari perquè un altre web no faci servir la seva sessió Autenticar ningú
Permetre llegir capçaleres concretes Autoritzar operacions
Reduir la superfície des del navegador Protegir de curl, scripts o bots

Una API amb Access-Control-Allow-Origin: https://botigaaroma.example i sense autenticació és una API completament oberta: qualsevol amb curl hi accedeix del tot. I una API amb la política de CORS més restrictiva del món continua sent vulnerable a BOLA si no comprova la propietat dels recursos (04-02).

La regla d'or:

Configura CORS pensant a protegir els teus usuaris al seu navegador. Configura autenticació i autorització pensant a protegir les teves dades de tota la resta. No substitueixis mai la segona per la primera.

  1. Provar CORS amb curl i amb les DevTools

Amb curl

curl no aplica CORS, però serveix perfectament per inspeccionar les capçaleres que emet el servidor, que és el que volem verificar.

# 1. Origen permès: hi ha d'aparèixer Access-Control-Allow-Origin amb aquell valor.
curl -sI https://api.botigaaroma.example/v1/cafes \
  -H 'Origin: https://botigaaroma.example' | grep -i 'access-control\|vary'

# Esperat:
# access-control-allow-origin: https://botigaaroma.example
# access-control-expose-headers: Location, Link, ETag, ...
# vary: Origin

# 2. Origen NO permès: NO hi ha d'aparèixer la capçalera. Compte: el cos arriba igual,
#    perquè curl no és un navegador. El que comprovem és l'absència de la capçalera.
curl -sI https://api.botigaaroma.example/v1/cafes \
  -H 'Origin: https://lloc-malicios.example' | grep -i 'access-control-allow-origin'
# Esperat: sense sortida

# 3. Simular un preflight complet.
curl -si -X OPTIONS https://api.botigaaroma.example/v1/comandes \
  -H 'Origin: https://botigaaroma.example' \
  -H 'Access-Control-Request-Method: POST' \
  -H 'Access-Control-Request-Headers: authorization,content-type,idempotency-key'

# Esperat: HTTP/1.1 204 amb Allow-Methods, Allow-Headers i Max-Age.

# 4. Comprovar que el preflight NO necessita autenticació (la fallada de l'apartat 12).
curl -so /dev/null -w '%{http_code}\n' -X OPTIONS \
  https://api.botigaaroma.example/v1/comandes \
  -H 'Origin: https://botigaaroma.example' \
  -H 'Access-Control-Request-Method: POST'
# Esperat: 204. Si surt 401, el middleware d'autenticació està mal col·locat.

Amb les DevTools

  1. Pestanya Xarxa, filtre «Fetch/XHR», i activa «Preserve log» perquè no es perdin en navegar.
  2. Busca la petició OPTIONS. Si falla, allà hi ha el problema; el seu codi d'estat assenyala el culpable.
  3. A «Headers», compara Access-Control-Request-Headers (el que demana el navegador) amb Access-Control-Allow-Headers (el que concedeix el servidor). La diferència és el teu error.
  4. Comprova a la resposta real que hi són Access-Control-Allow-Origin i Access-Control-Expose-Headers.
  5. Si la resposta arriba però una capçalera és null al codi, és Expose-Headers. Les DevTools que mostren totes les capçaleres encara que JavaScript no les pugui llegir: aquella discrepància entre «la veig a les DevTools però headers.get() dona null» és la signatura inconfusible del problema.

Una prova automatitzada

// proves/integracio/cors.prova.js
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import request from 'supertest';
import { app } from '../../src/app.js';

describe('CORS', () => {
  it('reflecteix l\'origen permès i afegeix Vary', async () => {
    const r = await request(app)
      .get('/v1/cafes')
      .set('Origin', 'https://botigaaroma.example');

    assert.equal(r.headers['access-control-allow-origin'], 'https://botigaaroma.example');
    assert.match(r.headers['vary'] ?? '', /Origin/);
  });

  it('no emet Allow-Origin per a un origen desconegut', async () => {
    const r = await request(app)
      .get('/v1/cafes')
      .set('Origin', 'https://lloc-malicios.example');

    assert.equal(r.headers['access-control-allow-origin'], undefined);
  });

  it('respon el preflight SENSE exigir autenticació', async () => {
    const r = await request(app)
      .options('/v1/comandes')
      .set('Origin', 'https://botigaaroma.example')
      .set('Access-Control-Request-Method', 'POST')
      .set('Access-Control-Request-Headers', 'authorization,content-type,idempotency-key');

    assert.equal(r.status, 204);                              // MAI 401
    assert.match(r.headers['access-control-allow-headers'], /Idempotency-Key/i);
    assert.match(r.headers['access-control-allow-methods'], /POST/);
  });

  it('exposa les capçaleres que la SPA necessita llegir', async () => {
    const r = await request(app)
      .get('/v1/cafes')
      .set('Origin', 'https://botigaaroma.example');

    const exposades = (r.headers['access-control-expose-headers'] ?? '').toLowerCase();
    for (const capcalera of ['link', 'etag', 'retry-after', 'aroma-ratelimit-restants']) {
      assert.ok(exposades.includes(capcalera), `falta exposar ${capcalera}`);
    }
  });
});

La tercera prova és la més valuosa de les quatre: fixa l'ordre dels middlewares. Si algú mou cors després de l'autenticació, aquesta prova falla amb un 401 i el problema es detecta a la integració contínua en comptes de a la consola d'un desenvolupador de la SPA.

Errors Comuns i Consells

Fer servir app.use(cors()) sense opcions. Equival a Access-Control-Allow-Origin: * a tota l'API. Funciona, i per això és tan perillós: el problema no es manifesta mai.

Validar l'origen amb startsWith o una expressió regular laxa. https://botigaaroma.example.evil.example passaria el filtre. Igualtat exacta contra un Set.

Oblidar Vary: Origin. Produeix fallades intermitents que depenen de què hagi desat la CDN, i són dels bugs més difícils de reproduir.

Posar cors després de l'autenticació. El preflight rep 401 i res no funciona, amb un missatge que apunta en una altra direcció.

Oblidar exposedHeaders. La SPA no pot llegir Location, Link, ETag ni Retry-After, i bona part de la feina dels mòduls 2, 3 i 4 li queda invisible.

Intentar arreglar CORS des del client. No es pot: la decisió és del servidor. Els proxys i extensions que «desactiven CORS» només serveixen a la teva màquina i amaguen el problema real.

Creure que mode: 'no-cors' al fetch soluciona alguna cosa. Retorna una resposta opaca: no pots llegir ni el cos ni l'estat. Gairebé mai no és el que vols.

Afegir un origen a la llista blanca «temporalment» per depurar. Els * temporals es queden per sempre. Fes servir un entorn de desenvolupament amb la seva pròpia configuració.

Consell: la resposta al preflight també necessita les capçaleres de seguretat. Per això helmet va abans que CORS.

Consell: quan falli CORS, mira la pestanya de xarxa abans que la consola. El missatge de la consola és un resum; la petició OPTIONS és l'evidència.

Consell: documenta la llista d'orígens permesos. Quan la SPA canviï de domini, algú l'haurà d'actualitzar, i si no està escrit ningú no sabrà on.

Exercicis

Exercici 1: preflight o no

Per a cada petició des de https://botigaaroma.example, digues si dispara preflight i per què:

  1. fetch('https://api.botigaaroma.example/v1/cafes')
  2. fetch('https://api.botigaaroma.example/v1/cafes', { headers: { Authorization: 'Bearer x' } })
  3. fetch('https://api.botigaaroma.example/v1/comandes', { method: 'POST', body: 'hola', headers: { 'Content-Type': 'text/plain' } })
  4. fetch('https://api.botigaaroma.example/v1/comandes', { method: 'POST', body: '{}', headers: { 'Content-Type': 'application/json' } })
  5. fetch('https://botigaaroma.example/api/cafes')
  6. fetch('https://api.botigaaroma.example/v1/cafes/caf_001', { method: 'DELETE' })

Exercici 2: diagnosticar quatre fallades

Diagnostica cada situació i proposa la correcció concreta:

  • (a) La SPA crea una comanda correctament (201), però resposta.headers.get('Location') retorna null.
  • (b) El tauler intern funciona des de la màquina d'un desenvolupador i falla en producció amb No 'Access-Control-Allow-Origin' header.
  • (c) Després de posar una CDN al davant, la SPA falla una de cada cinc vegades amb un Allow-Origin que correspon al tauler.
  • (d) POST /v1/comandes falla des de la SPA amb «Response to preflight request doesn't pass access control check», però el mateix POST amb curl funciona.

Exercici 3: configuració per a un tercer

La Botiga Aroma permetrà que CataBox (https://catabox.example) cridi l'API des de la seva pròpia SPA al navegador, fent servir OAuth amb Authorization: Bearer i només l'àmbit comandes.llegir. Escriu la configuració de CORS necessària i respon: n'hi ha prou d'afegir l'origen a la llista blanca perquè CataBox pugui llegir les comandes? Què més cal i què no aporta CORS aquí?

Solucions

Solució 1

Núm. Preflight? Per què
1 No GET sense capçaleres especials: és una petició simple
2 Authorization no és a la llista de capçaleres segures
3 No POST amb text/plain compleix les condicions de petició simple
4 Content-Type: application/json no està permès a les simples
5 No s'aplica És el mateix origen: CORS no hi intervé en absolut
6 DELETE no és entre els mètodes de les peticions simples

Observació sobre el cas 3: encara que no dispari preflight, la nostra API el rebutjaria igualment amb un 400, perquè express.json està configurat amb type: ['application/json', 'application/merge-patch+json'] i no analitza text/plain. Aquell rebuig és justament el que fa que el truc del formulari CSRF de l'apartat 14 no funcioni.

Solució 2

(a) Falta Location a exposedHeaders. La resposta arriba completa i la capçalera hi és —es veu a les DevTools—, però el navegador no deixa que JavaScript la llegeixi perquè no és una de les set segures de llista. Correcció: afegir Location a exposedHeaders a src/config/cors.js.

(b) La variable ORIGENS_PERMESOS de producció no inclou el domini del tauler. En desenvolupament funcionava perquè origensPermesos() hi afegeix localhost quan NODE_ENV és desenvolupament. Correcció: afegir https://panel.botigaaroma.example a la configuració de l'entorn de producció i redesplegar. Verificació: curl -sI -H 'Origin: https://panel.botigaaroma.example' ....

(c) Falta Vary: Origin. La CDN desa la resposta a la memòria cau —inclosa la seva capçalera Access-Control-Allow-Origin— sense saber que depèn d'Origin, i la serveix indistintament als dos orígens. La fallada és intermitent perquè depèn de quina resposta hi hagi desada. Correcció: emetre Vary: Origin (el paquet cors ho fa quan es fa servir una funció origin, però cal verificar que la CDN el respecta i no l'elimina).

(d) El preflight no arriba a respondre's correctament. curl funciona perquè no fa preflight. Les dues causes probables, per ordre: el middleware d'autenticació s'executa abans que cors i respon 401 a l'OPTIONS; o falta Idempotency-Key a allowedHeaders, amb la qual cosa el navegador rebutja el preflight encara que retorni 204. Diagnòstic: mirar la petició OPTIONS a la pestanya de xarxa. Si retorna 401, és el primer; si retorna 204 però l'Access-Control-Allow-Headers no inclou idempotency-key, és el segon.

Solució 3

// src/config/cors.js  (fragment)
// Al .env de producció:
// ORIGENS_PERMESOS=https://botigaaroma.example,https://panel.botigaaroma.example,https://catabox.example

No cal res més a la configuració: CataBox fa servir Authorization: Bearer, que ja és a allowedHeaders, i credentials continua a false perquè no hi ha galetes.

N'hi ha prou amb això perquè CataBox llegeixi les comandes? No. Afegir l'origen només permet que el navegador lliuri la resposta al JavaScript de CataBox. Perquè l'API retorni dades calen tres coses més, totes del costat del servidor:

  1. Un token vàlid emès pel servidor d'autorització, amb aud igual a la nostra API (04-03).
  2. L'àmbit comandes.llegir en aquell token, comprovat per exigirAmbit.
  3. La comprovació de propietat: el token només dona accés a les comandes del seu sub, no a les de qualsevol (04-02).

Què no aporta CORS aquí: absolutament cap autorització. Si demà el backend de CataBox crida l'API des del seu servidor, no hi haurà Origin ni CORS pel mig, i l'accés continuarà funcionant o fallant exactament igual, segons el token. I una nota pràctica: com que la SPA de CataBox és un client públic, ha de fer servir Authorization Code + PKCE i no pot desar un client_secret (04-03).

Conclusió

CORS deixa de ser un misteri tan bon punt s'entén que no és una defensa, sinó una relaxació controlada d'una defensa que ja existeix: la política del mateix origen, que impedeix que el JavaScript d'una pàgina llegeixi respostes d'un altre origen. Saps què és exactament un origen —esquema, host i port, amb el subdomini comptant com a diferent—, que la petició s'envia encara que la resposta es bloquegi, i per què curl, Aroma Mòbil i el backend de CataBox no veuen res d'això. Distingeixes peticions simples de les que disparen preflight, i has vist l'intercanvi OPTIONS complet en cru, amb l'observació clau que la resposta real també necessita les seves capçaleres de CORS. Coneixes les set capçaleres del protocol, la importància de Vary: Origin davant de les memòries cau, i sobretot Access-Control-Expose-Headers, que era l'esglaó que faltava perquè la SPA pugui llegir Location, Link, ETag, Retry-After i les Aroma-RateLimit-* que portem tres lliçons construint. Al projecte tens src/config/cors.js amb llista blanca per entorn validada en arrencar, i el middleware a la posició 4 de src/app.js: després de helmet, abans del rate limiting, de l'analitzador i de tota autenticació —que és precisament el que evita el clàssic preflight amb 401—. I tens clar per què la Botiga Aroma fa servir Authorization: Bearer en comptes de galetes, cosa que la fa immune a CSRF sense necessitat de tokens sincronitzadors.

Amb l'API assegurada, delimitada i accessible des dels orígens correctes, queda fer-la ràpida. A 04-06, Memòria cau HTTP i rendiment, recuperarem la restricció «cacheable» de 01-04 i la convertirem en implementació: els nivells de memòria cau des del navegador fins a la base de dades; Cache-Control a fons, amb max-age, s-maxage, private, aquell no-cache que no significa «no desar a la memòria cau», stale-while-revalidate i immutable; la validació condicional amb ETag i Last-Modified que produeix un 304 sense cos; i el retrobament que portem anunciant des de 03-05, quan If-Match i el 412 tanquin el cercle de la concurrència optimista i expliquin per què una capçalera és millor lloc que el cos per al camp versio. Afegirem src/middleware/cache.js, veurem la invalidació —el problema difícil—, la memòria cau de servidor amb Redis i el seu patró cache-aside, la compressió, l'N+1, el 202 per a les operacions llargues, i per què cal mesurar p50, p95 i p99 abans d'optimitzar res.

Curs de REST API: Principis de Disseny i Desenvolupament d'APIs RESTful

Mòdul 1: Introducció a les APIs RESTful

Mòdul 2: Disseny d'APIs RESTful

Mòdul 3: Desenvolupament d'APIs RESTful

Mòdul 4: Bones Pràctiques i Seguretat

Mòdul 5: Eines i Frameworks

Mòdul 6: Casos d'Estudi i Projectes

© Copyright 2026. Tots els drets reservats