CORS wird häufig missverstanden — viele Entwickler:innen halten es für einen Sicherheits-Mechanismus, der vor Cross-Origin-Angriffen schützt. Tatsächlich ist es das Gegenteil: CORS ist eine Lockerung der Same-Origin-Policy, die kontrolliert erlaubt, dass eine fremde Origin mit deinem Server spricht. Die Default-Policy ist „nein". CORS sagt „okay, hier sind die Bedingungen". Dieser Artikel zeigt das Modell, die häufigsten Konfigurations-Fehler und die kritische Klarstellung: CORS schützt nicht vor CSRF.

Was die Same-Origin-Policy macht

Eine Origin ist die Kombination <scheme>://<host>:<port>. https://app.example.com:443 und https://api.example.com:443 sind verschiedene Origins.

Die Same-Origin-Policy (SOP) sagt: Skripte einer Origin dürfen Responses anderer Origins nicht lesen. Browser blockt den Read.

Wichtig — was SOP NICHT verhindert:

  • Skripte können Requests an fremde Origins schicken (z. B. <img src="https://other.com/x.png">, fetch('https://other.com/api') — der Request geht raus).
  • Der Browser blockt nur das Lesen der Response, wenn die Origin nicht zustimmt.

Was SOP verhindert:

  • Skript auf evil.com kann nicht fetch('https://bank.com/balance') machen und die Antwort lesen — auch wenn der User auf bank.com eingeloggt ist und Cookies mitgehen würden.

CORS ist der Mechanismus, mit dem bank.com sagen kann: „okay, partner.com darf die Response lesen."

Simple Requests vs. Preflighted Requests

Simple Requests (klassische HTML-Form-Requests):

  • Methode: GET, HEAD, POST.
  • Header: nur Standard-Sets (Accept, Accept-Language, Content-Language, Content-Type mit application/x-www-form-urlencoded, multipart/form-data, text/plain).
  • Kein Custom-Header.

Browser sendet den Request direkt. Server antwortet mit Access-Control-Allow-Origin — wenn ja, kann das Skript die Response lesen.

Preflighted Requests (alles andere — JSON-POSTs, PUT/PATCH/DELETE, Custom-Headers wie Authorization):

Browser sendet zuerst eine OPTIONS-Anfrage (Preflight):

HTTP cors-preflight.txt
# Preflight-Request vom Browser
OPTIONS /api/users HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Authorization, Content-Type

# Preflight-Response vom Server
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 7200

Wenn die Preflight-Response die geplante Aktion erlaubt, schickt der Browser den eigentlichen Request. Sonst wird der echte Request gar nicht gesendet.

Access-Control-Max-Age cached die Preflight-Antwort beim Browser für N Sekunden — spart Roundtrips bei mehreren Requests.

credentials — Cookies und Auth-Header

Default: Browser sendet bei Cross-Origin-Requests keine Cookies und kein Authorization-Header.

Mit Credentials: Wenn der Client explizit credentials: 'include' (fetch) oder withCredentials: true (XHR) setzt, versucht der Browser, Cookies/Auth mitzusenden. Server muss explizit zustimmen:

JavaScript cors-with-credentials-client.js
// Client (auf https://app.example.com)
fetch('https://api.example.com/me', {
  credentials: 'include',
});
HTTP cors-credentials-response.txt
# Server-Response
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true

Zwei kritische Regeln bei Credentials:

  1. Access-Control-Allow-Origin: * ist verboten, wenn Access-Control-Allow-Credentials: true gesetzt ist. Browser lehnt die Response ab. Muss eine konkrete Origin sein.

  2. Origin-Echo gefährlich: Server-Pattern „nimm Origin-Header aus dem Request, gib es als Allow-Origin zurück" plus Allow-Credentials: true ist faktisch das gleiche wie Wildcard — jede Origin wird erlaubt. Schwere Klasse. Pattern wird in Bug-Bounty-Reports regelmäßig gefunden.

Typische Konfigurations-Fehler

Fehler 1: Wildcard + Credentials per Origin-Echo

JavaScript cors-origin-echo-vulnerable.js
// Schadhaft
app.use((req, res, next) => {
  res.setHeader('Access-Control-Allow-Origin', req.headers.origin);
  res.setHeader('Access-Control-Allow-Credentials', 'true');
  next();
});

Effekt: jede Origin darf cross-origin mit Credentials zugreifen. Wenn der User auf einer fremden Site ist, kann diese Site authentifizierte Requests gegen die API machen und die Response lesen. Komplette CORS-Aushebelung.

Schutz: Explizite Allow-Liste.

JavaScript cors-allowlist.js
const ALLOWED_ORIGINS = new Set([
  'https://app.example.com',
  'https://admin.example.com',
]);

app.use((req, res, next) => {
  const origin = req.headers.origin;
  if (origin && ALLOWED_ORIGINS.has(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Access-Control-Allow-Credentials', 'true');
    res.setHeader('Vary', 'Origin');  // wichtig für Caching
  }
  next();
});

Fehler 2: Vary-Header vergessen

Wenn Access-Control-Allow-Origin pro Request unterschiedlich gesetzt wird, muss Vary: Origin mit — sonst kann ein CDN/Proxy die Response mit Origin-A für einen Request von Origin-B servieren. Cross-Origin-Leak.

Fehler 3: Subdomain-Wildcard via Regex

JavaScript cors-subdomain-regex-trap.js
// Schadhaft — Regex matched zu viel
if (/example\.com$/.test(origin)) {
  res.setHeader('Access-Control-Allow-Origin', origin);
}
// origin = "https://evil-example.com" → matched!
// origin = "https://app.example.com.evil.com" → matched!

Korrekt: strikte URL-Match oder strenges Regex:

JavaScript cors-strict-regex.js
// Strikt: nur app, admin, www
if (/^https:\/\/(app|admin|www)\.example\.com$/.test(origin)) {
  res.setHeader('Access-Control-Allow-Origin', origin);
}

Fehler 4: null-Origin akzeptieren

Browser sendet Origin: null für Sandboxed iframes, file://-URLs, redirects. Wer das akzeptiert, lässt evil-Iframes auf die API zugreifen.

JavaScript cors-null-origin-trap.js
// Schadhaft
if (origin === 'null' || ALLOWED_ORIGINS.has(origin)) { ... }

Niemals null akzeptieren.

CORS ist KEIN CSRF-Schutz

Der wahrscheinlich verbreitetste CORS-Mythos: „Wir haben strikte CORS-Policy, also sind wir CSRF-sicher."

Warum das falsch ist:

CORS kontrolliert, ob Skripte die Response einer Cross-Origin-Request lesen dürfen. Es kontrolliert nicht, ob der Request gesendet wird.

Klassischer CSRF-Angriff (state-changing POST):

HTML csrf-attack-no-cors-protection.html
<!-- Auf https://evil.com -->
<form action="https://bank.example.com/transfer" method="POST">
  <input name="to" value="attacker-account">
  <input name="amount" value="1000">
</form>
<script>document.forms[0].submit();</script>

Browser submittet das Form (klassischer Form-Submit, keine CORS-Restriction). Cookies gehen mit. Server führt die Aktion aus. Angreifer sieht die Response zwar nicht (CORS blockt das Lesen), aber die Aktion ist passiert.

Konsequenz: CSRF-Schutz braucht eine separate Schicht — siehe Kap 12 CSRF:

  • SameSite-Cookies (Lax oder Strict).
  • CSRF-Token.
  • Origin-/Sec-Fetch-Header-Check server-seitig.

Was CORS Indirekt für CSRF tut:

Bei Preflighted Requests (mit JSON-Body, Custom-Headers) muss der Browser vor dem echten Request einen OPTIONS-Preflight machen. Wenn der Server diesen ablehnt, geht der echte Request gar nicht raus. Das macht JSON-POST-CSRF schwerer — aber:

  • Klassischer Form-POST mit Content-Type: application/x-www-form-urlencoded ist Simple Request — kein Preflight. CORS hilft nicht.
  • Wenn der Server bei Preflight grundsätzlich erlaubt (Wildcard, Origin-Echo), greift CORS-„Schutz" gar nicht.

Pragmatisch: CORS ist Lockerung für legitime Cross-Origin-Use-Cases, kein CSRF-Schutz.

Standard-Library-Patterns

Express (cors-Package):

JavaScript express-cors.js
import cors from 'cors';

app.use(cors({
  origin: (origin, callback) => {
    const allowed = ['https://app.example.com', 'https://admin.example.com'];
    if (!origin || allowed.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error('Not allowed by CORS'));
    }
  },
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  maxAge: 7200,
}));

Python (FastAPI):

Python fastapi-cors.py
from fastapi.middleware.cors import CORSMiddleware

app.add_middleware(
    CORSMiddleware,
    allow_origins=['https://app.example.com', 'https://admin.example.com'],
    allow_credentials=True,
    allow_methods=['GET', 'POST', 'PUT', 'DELETE'],
    allow_headers=['Content-Type', 'Authorization'],
    max_age=7200,
)

Spring (Java):

Java spring-cors.java
@Configuration
public class CorsConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
            .allowedOrigins("https://app.example.com")
            .allowedMethods("GET", "POST", "PUT", "DELETE")
            .allowCredentials(true)
            .maxAge(7200);
    }
}

CORS für Public APIs

Wenn die API öffentlich sein soll (z. B. Public-Data-API ohne Auth), kann Wildcard sinnvoll sein:

HTTP cors-public-api.txt
Access-Control-Allow-Origin: *
# KEIN Allow-Credentials → kein Cookie wird gesendet

Sicher wenn:

  • Keine Auth-Cookies mitgesendet werden (Public-Data).
  • Read-only oder mit eigenständigen API-Keys/Tokens, nicht via Cookies.
  • Keine sensitive Information in Cookies, die nicht für die Ziel-Origin gedacht ist.

Beispiele: öffentliche REST-APIs (Wettervorhersage, OpenStreetMap, GitHub-Public-API-Endpoints).

Test-Strategien

Manuell:

Bash cors-test-curl.sh
# Preflight simulieren
curl -X OPTIONS https://api.example.com/users \
  -H "Origin: https://evil.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: Authorization" \
  -i

# Schauen: enthält Response Access-Control-Allow-Origin mit evil.com?
# → Origin-Echo-Bug

Browser: DevTools Network-Tab zeigt CORS-Fehler im Console.

Automatisiert:

  • CORScanner — Standard-Pentest-Tool für CORS-Misconfigs.
  • Burp Suite Pro Active Scanner hat CORS-Checks eingebaut.
  • OWASP ZAP mit CORS-Plugin.

Integration-Tests:

JavaScript cors-integration-test.js
test('CORS allows app.example.com', async () => {
  const res = await fetch('https://api.example.com/users', {
    headers: { Origin: 'https://app.example.com' },
  });
  expect(res.headers.get('Access-Control-Allow-Origin')).toBe('https://app.example.com');
});

test('CORS blocks evil.com', async () => {
  const res = await fetch('https://api.example.com/users', {
    headers: { Origin: 'https://evil.com' },
  });
  expect(res.headers.get('Access-Control-Allow-Origin')).toBeNull();
});

Häufige Stolperfallen

Browser zeigt CORS-Fehler — aber der Request ist trotzdem gegangen

Bei Simple Requests sendet der Browser den Request ohne Preflight. Der Server verarbeitet ihn. Erst die Response wird vom Browser blockiert (für JS unleserlich). Bei Side-effect-API (z. B. POST /transfer) ist der Schaden trotzdem passiert. Konsequenz: Server muss eigenständig prüfen (CSRF-Token, SameSite-Cookies).

cors-Package mit origin: true ist Wildcard

Das verbreitete app.use(cors()) ohne Options ist defaultmäßig origin: '*' — Wildcard. Bei API mit Auth-Cookies plus dieser Config ist es harmlos (Browser blockt Credentials bei Wildcard), aber bei API mit Custom-Auth-Header oder JSON-POST kann es überraschend permissiv sein. Explizit Origin-Liste setzen.

Vary: Origin Pflicht bei dynamischem ACAO

Wenn der Server Access-Control-Allow-Origin pro Request unterschiedlich setzt, muss Vary: Origin mit. Sonst kann ein CDN die Response mit Origin-A für einen Request von Origin-B liefern — Cross-Origin-Leak. Cloudflare, Fastly, Varnish — alle berücksichtigen Vary, aber nur wenn gesetzt.

OPTIONS-Endpoints brauchen keine Auth

Klassischer Bug: Auth-Middleware blockt auch OPTIONS-Preflight-Requests. Browser bekommt 401 auf Preflight → kein echter Request. Pattern: OPTIONS vor Auth-Middleware durchschleusen (Express: app.options('*', cors()) ganz oben).

`*` in Allow-Methods und Allow-Headers (CSP-Level-2)

Seit Spec-Update 2020 ist Access-Control-Allow-Methods: * und Access-Control-Allow-Headers: * möglich (CORS 3). Praktisch für lockere APIs, aber explizite Listen sind defensiver — neue Header werden nicht automatisch durchgelassen.

CORS in Local-Dev mit unterschiedlichen Ports

Frontend auf localhost:3000, Backend auf localhost:8080 — Cross-Origin. CORS-Config im Backend muss http://localhost:3000 explizit erlauben. Production-Config mit Domain unterscheidet sich. Pattern: Env-Var CORS_ALLOWED_ORIGINS mit komma-getrennten URLs.

Network-Errors statt CORS-Fehler bei Preflight-Failure

Wenn der Preflight scheitert (Server returns 500 oder timeoutet), bekommt der JavaScript-Client einen generischen Network-Error — nicht „CORS preflight failed". Debug schmerzlich, weil die Ursache schwer zu finden ist. Server-Logs der OPTIONS-Requests sind die erste Anlaufstelle.

Weiterführende Ressourcen

Externe Quellen

/ Weiter

Zurück zu Secure Headers & Cookies

Zur Übersicht