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