A CORS preflight nem HTTP cache: miért nem tűnik el az OPTIONS kérés?

Az Access-Control-Max-Age hasznos, de a CORS preflightot nem a szokásos HTTP cache kezeli. Ez megmagyarázza, miért maradnak meg az OPTIONS kérések Vary és CDN mellett is.

title: "A CORS preflight nem HTTP cache: miért nem tűnik el az OPTIONS kérés?"

slug: cors-preflight-kulon-cache-options

excerpt: "Az Access-Control-Max-Age hasznos, de a CORS preflightot nem a szokásos HTTP cache kezeli. Ez megmagyarázza, miért maradnak meg az OPTIONS kérések Vary és CDN mellett is."

Ha egy külön frontend és API között minden POST előtt felbukkan egy OPTIONS, az első reakció gyakran az, hogy „ezt majd elcache-eli a CDN”. Aztán nem cache-eli. Vagy legalábbis nem ott és nem úgy, ahogy várnánk. A CORS preflight a böngésző saját engedélykérő köre: az API-nak még a tényleges kérés előtt ki kell mondania, hogy az adott origin, metódus és fejléckészlet rendben van.

CORS preflight folyamat: böngésző, engedélykérő OPTIONS kérés, API és külön böngésző-cache

Ez nem minden cross-origin fetch előtt történik. A CORS-safelisted kérés például GET, HEAD, vagy megfelelő korlátokkal POST lehet. De egy tipikus JSON API-hívás, Content-Type: application/json-nal és Authorization fejléc­cel már kilóg ebből a körből. A böngésző előbb nagyjából ezt küldi:

OPTIONS /orders HTTP/1.1
Origin: https://app.example.test
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization, content-type, x-request-id

Az API válasza nem a rendelést hozza létre. Csak engedélyt ad rá, hogy a böngésző ezután megpróbálhassa:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.test
Access-Control-Allow-Methods: GET, POST, DELETE
Access-Control-Allow-Headers: authorization, content-type, x-request-id
Access-Control-Max-Age: 600
Vary: Origin

Az OPTIONS-válasz nem a „rendes” cache-be kerül

A meglepő rész itt következik. A preflight válaszát a böngésző külön, CORS-preflight cache-ben tartja; nem a normál HTTP cache-ben. Emiatt a Cache-Control, az ETag, a CDN cache szabálya vagy az OPTIONS válaszra tett hosszú s-maxage nem a böngésző preflight-döntését vezérli. Az Access-Control-Max-Age igen.

Na, ezt nem tudtam: a Fetch szabvány cache-bejegyzése nem pusztán hostonként létezik. A hálózati partíció, a kérő origin, a pontos URL és a credentials mód is számít; külön metódus- és fejlécengedélyeket tárol. Emiatt egy /orders/42 és egy /orders/43 preflightja nem feltétlenül osztozik ugyanazon az engedélyen, és egy beágyazott widgetnek sem kell ugyanazt a cache-t megkapnia, mint a saját alkalmazásodnak.

Ez szándékos. A CORS nem sebességfunkció, hanem böngészőoldali hozzáférés-ellenőrzés. Ha túl tág kulccsal működne, egy másik originnek vagy más kérésnek maradhatna ott egy olyan engedély, amihez semmi köze. A browser inkább kérdez még egyszer; az API-szerver kibírja.

Mire jó akkor a Vary: Origin?

A Vary: Origin továbbra is kell, ha a szerver az Origin alapján más Access-Control-Allow-Origin értéket ad. Ez a normál HTTP cache-eknek — reverse proxy, CDN, böngésző HTTP cache — jelzi, hogy az egyik originnek készített válasz nem adható oda vakon a másiknak. A Fetch szabvány CORS és HTTP cache fejezete külön ki is emeli ezt a helyzetet.

Csakhogy a Vary nem a preflight cache kulcsának konfigurációja. Nem rövidíti meg az OPTIONS-t, nem tesz két URL-t azonosnak, és nem pótolja az Access-Control-Max-Age fejlécet. Ez két cache két külön problémával. Az egyik a HTTP-reprezentációt, a másik azt tárolja, milyen cross-origin műveletet szabad megkísérelni. A hasonló nevű fejlécmezők itt csak arra jók, hogy egy hibajegyben fél délutánt elvigyenek.

Access-Control-Max-Age: legyen értelmes, ne végtelen

Az Access-Control-Max-Age: 600 józan kiindulópont egy stabil API-nál: tíz percig nincs új engedélykérő kör azonos feltételek mellett. A fejléc dokumentációja szerint az alapértelmezés öt másodperc, és a böngészők saját felső korlátot is alkalmaznak. Vagyis a 31536000 nem egy év megbízható ígérete, csak egy magabiztos szám a konfigurációban.

Nem érdemes a legnagyobb értéket reflexből beírni. Ha gyakran változik az engedélyezett fejléc vagy metódus, a kliens a régi engedély miatt később veszi észre a módosítást. Ez önmagában nem megkerülhető jogosultság — a tényleges kérésre továbbra is a szerver dönt —, de üzemeltetési szempontból kellemetlen, amikor egy CORS-változtatás csak néhány böngészőben „nem ment át”. A szabvány azt is megengedi, hogy a böngésző a lejárat előtt kidobja a bejegyzést, tehát ez optimalizálás, nem SLA.

Egy szigorú Node példa

Az alábbi szerver nem tükrözi vissza vaktában az Origin-t, és a kért metódust, illetve fejléceket is ellenőrzi. Ez a rész fontosabb, mint az, hogy az OPTIONS 204-et vagy 200-at küld: a CORS biztonsági útmutató credentialös helyzetben különösen óv az origin reflexes visszaadásától.

import { createServer } from 'node:http';

const allowedOrigins = new Set(['https://app.example.test']);
const allowedMethods = new Set(['GET', 'POST', 'DELETE']);
const allowedHeaders = new Set(['authorization', 'content-type', 'x-request-id']);

function handleCors(req, res) {
  const origin = req.headers.origin;
  if (!allowedOrigins.has(origin)) return false;

  res.setHeader('Access-Control-Allow-Origin', origin);
  res.setHeader('Vary', 'Origin');

  const requestedMethod = req.headers['access-control-request-method'];
  if (req.method !== 'OPTIONS' || !requestedMethod) return false;

  const requestedHeaders = (req.headers['access-control-request-headers'] ?? '')
    .split(',').map((header) => header.trim().toLowerCase()).filter(Boolean);

  if (!allowedMethods.has(requestedMethod) ||
      requestedHeaders.some((header) => !allowedHeaders.has(header))) {
    res.writeHead(403).end();
    return true;
  }

  res.writeHead(204, {
    'Access-Control-Allow-Methods': [...allowedMethods].join(', '),
    'Access-Control-Allow-Headers': [...allowedHeaders].join(', '),
    'Access-Control-Max-Age': '600',
  }).end();
  return true;
}

createServer((req, res) => {
  if (handleCors(req, res)) return;
  if (req.url === '/orders' && req.method === 'POST') {
    res.writeHead(201, { 'Content-Type': 'application/json' });
    res.end('{"ok":true}');
    return;
  }
  res.writeHead(404).end();
}).listen(Number(process.env.PORT ?? 3000));

Élesben ezt tipikusan framework middleware, API gateway vagy ingress intézi, de a logika ugyanaz. Ha session cookie kell, külön kérés a credentials: 'include', az explicit origin és az Access-Control-Allow-Credentials: true; wildcarddal ezt nem lehet összebékíteni. A preflight maga ettől még nem visz cookie-t, csak a tényleges kérés lehet credentialös.

Mit mérj, mit javíts?

Először DevTools Network nézetben szűrj OPTIONS-ra. Ha egy oldalbetöltés alatt rengeteg különböző, dinamikus URL-re hívsz JSON API-t egyedi fejlécekkel, a preflight cache eleve kevés ismétlődést talál. Itt sokszor többet ér az API útvonalainak és klienshívásainak rendbetétele, mint a max-age tekergetése.

Másodszor csak azokat a request headereket küldd, amelyeknek van értelmük. Az „minden kéréshez kitalálunk egy új X-* fejlécnevet” megfigyelési vagy kliensmeta-szokás könnyen új preflight-feltétellé válik. Nem azért kell eltüntetni, hogy trükkösen megkerüld a CORS-t; ez biztonsági határ. Hanem azért, mert a cross-origin API szerződése legyen szűk és szándékos.

Én productionben beállítanék mérsékelt Access-Control-Max-Age-t, explicit origin-, metódus- és fejlérlistát, valamint Vary: Origin-t minden dinamikus originválasznál. Nem építenék viszont CDN-es „OPTIONS cache megoldásra” úgy, mintha attól a böngésző engedélykérése eltűnne. A CDN a szerverterhelést csökkentheti; a böngésző külön cache-e viszont saját szabályok szerint dolgozik. Ugyanaz a szó, cache, csak épp teljesen más fiókban van.

Források

Leave a Reply

Az e-mail címet nem tesszük közzé. A kötelező mezőket * karakterrel jelöltük