CyberNugget

L37 · Security headers and CORS done right

Module 5: Web Application Security · ~20 min · Lesson 37 of 71

🎯 What you'll learn

  • Which response headers are worth setting, for a browser app and for a JSON API, and what each one tells the browser to do
  • What the same-origin policy blocks, and what CORS actually relaxes (it isn't a firewall)
  • How a CORS preflight works, header by header
  • The CORS mistakes that let any website read your customers' data, and how to spot them in a Kong cors plugin config

🔥 Why this matters

In 2016, James Kettle of PortSwigger presented research called "Exploiting CORS misconfigurations for Bitcoins and bounties". He found sites, including Bitcoin exchanges, that copied whatever Origin a request sent into Access-Control-Allow-Origin and also allowed credentials. Any web page a logged-in user visited could read their account data. Now picture a Friday fix on a team that runs Kong: an advisor portal gets CORS errors, so someone sets the cors plugin to origins: ["*"] with credentials: true. The errors go away. It's the same bug.

1. Response headers are instructions to the browser

Most security headers don't change what your server does. They change what the browser does with your response: which protocol to use next time, whether to guess a content type, whether another site may frame the page or read the data.

 api.example-bank.com                         browser
        │  HTTP/1.1 200 OK                        │
        │  Strict-Transport-Security ───────────▶ │ HTTPS only, for
        │                                         │ the next year
        │  X-Content-Type-Options: nosniff ─────▶ │ trust the declared
        │                                         │ type, never guess
        │  Content-Security-Policy ─────────────▶ │ listed JS only,
        │                                         │ no framing
        │  Access-Control-Allow-Origin ─────────▶ │ which other sites
        │                                         │ may READ this

That has a consequence people miss: curl, Java's HttpClient, Python requests and Airflow operators ignore all of these. They protect people using browsers: a customer portal, Swagger UI, the Airflow web UI, Kong Manager. For a JSON API they're cheap defence in depth, because sooner or later someone opens it in a browser.

2. The short list

Header Typical value What it stops
Strict-Transport-Security max-age=31536000; includeSubDomains Downgrade to plain HTTP on later visits
Content-Security-Policy Page: nonce-based (L32). API: default-src 'none'; frame-ancestors 'none' Injected scripts; framing
X-Content-Type-Options nosniff The browser "guessing" a JSON or uploaded file is HTML or script
X-Frame-Options DENY Clickjacking, for old browsers (frame-ancestors supersedes it)
Referrer-Policy strict-origin-when-cross-origin or no-referrer Full URLs with IDs or codes leaking in Referer
Cache-Control no-store Account data kept in browser or shared caches
Permissions-Policy camera=(), geolocation=() Embedded content using powerful browser features

Two to remove rather than add. X-XSS-Protection switched on an old XSS filter that browsers have since removed (Chrome dropped it in 2019), and the filter itself could be abused; send 0 or nothing. Server and X-Powered-By only advertise versions (L09).

In clickjacking, an attacker's page loads your "Confirm transfer" screen in an invisible <iframe> under a fake button. frame-ancestors 'none' forbids framing your page at all.

HTTP Strict Transport Security (HSTS) needs a picture. Without it, a user who types example-bank.com makes their first request over plain HTTP, and an attacker on café Wi-Fi can answer it. This is SSL stripping: the attacker talks HTTPS to you and plain HTTP to the victim.

 WITHOUT HSTS                         WITH HSTS (seen before)
 user types example-bank.com          user types example-bank.com
   │ 1. http:// request                 │ 1. browser rewrites to
   ▼                                    │    https:// itself (Chrome
 café attacker ◀─ reads cookies,        │    shows "307 Internal
   │              rewrites links        │    Redirect")
   │ 2. https:// to the real site       ▼
   ▼                                  real site, TLS from byte one
 real site (sees nothing wrong)       attacker sees only TLS

The browser only accepts the header over HTTPS, and only after a first visit. The preload list (first deep dive) closes that gap.

3. Same-origin policy: what it blocks, what CORS relaxes

An origin is the triple scheme + host + port. https://portal.example-bank.com and https://api.example-bank.com are different origins, and so are http:// and https:// versions of the same host.

The same-origin policy (SOP) is the browser rule that a page from one origin can send many requests to another origin, but can't read the responses. That's why a random site can't fetch /v1/accounts/me from your bank in the background and read your balance.

Cross-Origin Resource Sharing (CORS) is how a server opts out of that rule for named origins: "pages from https://portal.example-bank.com may read my responses." CORS never adds protection; it removes some, on purpose. So:

  • CORS is enforced by the browser. curl and server-to-server calls never check it.
  • It isn't authentication. A request sent without a preflight (a simple request, L33) still reaches your server whatever your policy says; the browser only hides the response.
  • The danger is in what the browser attaches automatically: cookies, cached Basic credentials, client certificates, and network position. A page that can read your intranet API through an employee's browser has effectively joined your VPN.

For anything else (JSON bodies, an Authorization header, PUT), the browser first asks with a preflight:

 portal.example-bank.com            api.example-bank.com (Kong)
 (page in the browser)
        │ 1. OPTIONS /v1/accounts/me                 │
        │    Origin: https://portal.example-bank.com │
        │    Access-Control-Request-Method: GET      │
        │    Access-Control-Request-Headers:         │
        │      authorization                         │
        │───────────────────────────────────────────▶│
        │ 2. 200 OK                                  │
        │    Access-Control-Allow-Origin:            │
        │      https://portal.example-bank.com       │
        │    Access-Control-Allow-Methods: GET, POST │
        │    Access-Control-Allow-Headers:           │
        │      Authorization, Content-Type           │
        │    Access-Control-Max-Age: 600             │
        │◀───────────────────────────────────────────│
        │ 3. GET /v1/accounts/me  (real request)     │
        │───────────────────────────────────────────▶│
        │ 4. 200 + Access-Control-Allow-Origin       │
        │◀─── (needed again, or JS can't read it) ───│

Access-Control-Allow-Credentials: true is the dangerous one: the page may send cookies and read the reply. The browser refuses to combine it with Access-Control-Allow-Origin: *. Developers then "fix" that by echoing back the request's origin, which is * with the safety catch removed.

4. The CORS mistakes that matter

Mistake Example Why it hurts
Reflect any origin + credentials ACAO: <whatever Origin said> Any site reads logged-in users' data
Allow null ACAO: null Sandboxed iframes and file: pages send Origin: null, so attackers can too
Suffix or prefix match endsWith("example-bank.com") evilexample-bank.com passes
Unescaped regex dot https://api.example-bank.com as a regex . matches any character: apixexample-bank.com
Wildcard subdomains https://.*\.example-bank\.com One XSS or taken-over subdomain (L08) can read everything
Allow http:// origins http://portal.example-bank.com A network attacker can inject script into the HTTP page
Reflect without Vary: Origin Cached response with one origin A cache serves site A's CORS answer to site B

ACAO: * without credentials is fine for public data such as exchange rates. It isn't fine for an internal API whose only "authentication" is being reachable from inside the network.

🧪 Worked example: the Friday fix in Kong

The advisor portal (https://advisor.example-bank.com) logs in with a session cookie that Kong validates. The CORS errors went away after this change:

plugins:
- name: cors
  route: accounts-v1
  config:
    origins: ["*"]
    credentials: true

Browsers reject * with credentials, so Kong's plugin source (checked against 3.x; confirm for your version) handles this case by echoing the request's Origin with Access-Control-Allow-Credentials: true. An empty origins list behaves the same way. A test from your own laptop against a dev route shows it:

$ curl -s -o /dev/null -D - \
    -H 'Origin: https://evil.example.net' \
    https://dev-api.example-bank.com/v1/accounts/me
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://evil.example.net   ◀ reflected
Access-Control-Allow-Credentials: true                  ◀ cookies OK
Vary: Origin

Any page an advisor visits can now fetch(url, {credentials: "include"}) and read client accounts. The fix lists exact origins, methods and headers:

plugins:
- name: cors
  route: accounts-v1
  config:
    origins:
    - https://advisor.example-bank.com
    - https://portal.example-bank.com
    methods: [GET, POST]
    headers: [Authorization, Content-Type, X-Request-ID]
    credentials: true
    max_age: 600
- name: response-transformer
  route: accounts-v1
  config:
    add:
      headers:
      - "Strict-Transport-Security:max-age=31536000;
        includeSubDomains"
      - "X-Content-Type-Options:nosniff"
      - "Content-Security-Policy:default-src 'none';
        frame-ancestors 'none'"
      - "Cache-Control:no-store"
      - "Referrer-Policy:no-referrer"

Two details from Kong's source. An entry made only of letters, digits, ., :, / and - is compared as an exact string; anything else is treated as a regex (anchored at both ends). And response-transformer's add skips a header the service already set, so a service can still send a stricter value.

🛠 Try it (optional, ~5 min)

Compare the headers of two public sites with read-only HEAD requests:

H='^(strict|x-frame|x-content|x-xss|referrer|content-sec|server:)'
for site in https://github.com https://example.com; do
  echo "== $site"
  curl -sI "$site" | grep -iE "$H" | cut -c1-68
done

Real output (November 2026; cut trims long lines to 68 characters):

== https://github.com
strict-transport-security: max-age=31536000; includeSubdomains; prel
x-frame-options: deny
x-content-type-options: nosniff
x-xss-protection: 0
referrer-policy: origin-when-cross-origin, strict-origin-when-cross-
content-security-policy: default-src 'none'; base-uri 'self'; child-
server: github.com
== https://example.com
server: cloudflare

GitHub sets the whole short list, including x-xss-protection: 0 and preload. The example page sets none, which is reasonable for a static page with no logins. Now score a dev route you own against section 2's table.

🧠 Key takeaways

  • Security headers instruct browsers; non-browser clients ignore them, so they protect people, not servers.
  • For JSON APIs: HSTS, nosniff, default-src 'none'; frame-ancestors 'none', no-store.
  • The same-origin policy blocks reading, not sending; CORS loosens it for named origins.
  • Never reflect arbitrary origins with credentials, never allow null, and match exact origins.
  • In Kong, origins: ["*"] plus credentials: true echoes any origin back.

📖 Words to know

Term Meaning
HSTS Strict-Transport-Security: tells the browser to use only HTTPS for a host for max-age seconds
SSL stripping A network attacker keeping the victim on plain HTTP while talking HTTPS to the real site
MIME sniffing A browser guessing a response's type from its content instead of Content-Type
Clickjacking Tricking a user into clicking inside an invisible frame of your page
Origin Scheme + host + port, such as https://api.example-bank.com:443
Same-origin policy Browser rule: pages may send cross-origin requests but not read the responses
CORS Server headers that let named origins read cross-origin responses
Preflight The OPTIONS request a browser sends to ask whether a non-simple request is allowed
Vary: Origin Tells caches the response differs by Origin, so one site's answer isn't reused for another

⏭ Next time

L38 is the misconfiguration lesson: a Spring Boot /actuator/heapdump handed researchers live AWS keys for a carmaker's cloud, and a Kong Admin API on the wrong port can reroute every request.

Deep dive 1

HSTS preload: the one-way door

HSTS only protects a browser that has already seen the header. The very first request, or the first one after max-age runs out, can still go out over plain HTTP. The preload list closes that gap: a list of domains compiled into Chrome, and also used by Firefox, Safari and Edge, that are HTTPS-only before the browser ever contacts them.

How the pieces fit

 Strict-Transport-Security:
   max-age=31536000; includeSubDomains; preload
   │                 │                  │
   │                 │                  └ "please add me" to the list
   │                 └ every subdomain, at any depth
   └ seconds (1 year); 0 = forget me

The header is ignored when it arrives over HTTP (an attacker could inject it), and on HTTPS with a broken certificate. Once a host is known as HSTS, certificate errors on it become hard failures: the user can't click through. Good for security, bad if you let a certificate expire (L11).

Submitting a domain at hstspreload.org requires, at the time of writing: a valid certificate, an HTTP-to-HTTPS redirect on the same host, HTTPS on every subdomain, and the header on the bare domain with max-age of at least one year, includeSubDomains and preload. Some whole top-level domains are preloaded: every .dev and .app site is HTTPS-only by definition.

Why it's a one-way door

Getting on the list takes a browser release. Getting off takes a removal request and another release, and until users update, their browsers still enforce it. That is months, not minutes. includeSubDomains is the part that bites, because it covers hosts you forgot:

 example-bank.com            preloaded, includeSubDomains
 ├─ www.example-bank.com     HTTPS                   ok
 ├─ api.example-bank.com     HTTPS                   ok
 ├─ reports.example-bank.com HTTP only (old vendor)  ✗ unreachable
 └─ printer-3f.example-bank.com  plain HTTP admin UI ✗ unreachable

Worked example: rolling it out safely

A ramp that's easy to undo:

  1. Inventory every subdomain, using your DNS zones and certificate transparency logs (L11).
  2. Send max-age=300 (5 minutes) without includeSubDomains. Watch for breakage.
  3. Go to max-age=86400, then a week, then add includeSubDomains once the inventory is clean.
  4. Only then use max-age=31536000; includeSubDomains; preload and submit.

To back out before preloading, send max-age=0: each browser forgets the host on its next HTTPS visit. After preloading, max-age=0 doesn't help until the removal ships.

HSTS for APIs

Machine clients mostly ignore HSTS. curl can honour it only if you pass --hsts <file> (7.74.0 and later); Java and Python clients don't. So for an API, the real control is not listening on HTTP. If http://api.example-bank.com/v1/... returns a redirect, the client has already sent its bearer token in plaintext before learning it should have used HTTPS. Many teams therefore refuse HTTP on API hosts (close port 80, or return an error) instead of redirecting, while still sending HSTS for the occasional browser. On AWS, CloudFront has a managed SecurityHeadersPolicy that adds HSTS and friends; an ALB doesn't add response headers, so Kong or the service has to.

🧠 Extra takeaways

  • HSTS is accepted only over valid HTTPS and only protects browsers after their first visit.
  • Preloading removes the first-visit gap but takes months to undo.
  • Ramp max-age up slowly and inventory subdomains before includeSubDomains.
  • For APIs, refuse plain HTTP instead of redirecting: the token is already exposed by the redirect.
Deep dive 2

Testing your own CORS policy on localhost

CORS bugs hide in the "is this origin allowed?" function. It's usually a few lines, easy to get wrong, and easy to test once you know which origins to try. Here's a deliberately buggy server you can run on your own machine.

The lab

Save this as cors_lab.py (Python 3 standard library only, listens on 127.0.0.1 only):

# cors_lab.py: a deliberately buggy CORS policy. Localhost only.
from http.server import BaseHTTPRequestHandler, HTTPServer

def allowed(origin):
    return origin.endswith("example.com") or origin == "null"

class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        origin = self.headers.get("Origin", "")
        self.send_response(200)
        if allowed(origin):
            self.send_header("Access-Control-Allow-Origin", origin)
            self.send_header("Access-Control-Allow-Credentials",
                             "true")
        self.send_header("Content-Type", "application/json")
        self.end_headers()
        self.wfile.write(b'{"accountId": "ACC-1001", "balance": 1.5}')

HTTPServer(("127.0.0.1", 8037), Handler).serve_forever()

Run python3 cors_lab.py in one terminal, and this loop in another:

U=http://127.0.0.1:8037/
for o in https://app.example.com https://evilexample.com \
         http://app.example.com null https://example.com.evil.net; do
  a=$(curl -s -o /dev/null -D - -H "Origin: $o" "$U" \
      | grep -i '^access-control-allow-origin' | cut -d' ' -f2 \
      | tr -d '\r')
  printf '%-30s ACAO: %s\n' "$o" "${a:-(none)}"
done

Real output:

https://app.example.com        ACAO: https://app.example.com
https://evilexample.com        ACAO: https://evilexample.com
http://app.example.com         ACAO: http://app.example.com
null                           ACAO: null
https://example.com.evil.net   ACAO: (none)

Reading the results

 origin tried                    should be   got      bug
 https://app.example.com         allowed     allowed  -
 https://evilexample.com         blocked     allowed  suffix match
 http://app.example.com          blocked     allowed  scheme ignored
 null                            blocked     allowed  null trusted
 https://example.com.evil.net    blocked     blocked  (a prefix
                                                       check fails)

Each row is a different attacker. Anyone can register evilexample.com. The http:// page can be rewritten by anyone on the user's network. null comes from a sandboxed iframe the attacker controls. The last row didn't fail here, but a startsWith("https://example.com") check would let it through: that's why the list of origins to try includes both a suffix and a prefix trick.

The fix

ALLOWED = {"https://app.example.com",
           "https://portal.example.com"}

def allowed(origin):
    return origin in ALLOWED          # exact match, scheme included

Also send Vary: Origin on every response whose CORS headers depend on the request's origin, so a cache in front of you doesn't replay one origin's answer to another. Spring's CorsConfiguration with allowedOrigins (exact strings) and Kong's origins with literal entries both do exact matching; the risky variants are allowedOriginPatterns in Spring and regex entries in Kong. Rerun the loop after the fix: only the first row should get a header. Stop the server with Ctrl-C.

The same five origins work against a dev route of your own behind a gateway such as Kong. Add one more: https://evil.example.net, which should never be allowed anywhere.

🧠 Extra takeaways

  • Test origin checks with: an exact match, a suffix trick, a prefix trick, http://, and null.
  • Exact set membership beats string tricks and regexes.
  • Every response with origin-dependent CORS headers needs Vary: Origin.
  • Pattern features (Spring allowedOriginPatterns, Kong regex entries) deserve extra review.

The quiz for this lesson and the Tests section need JavaScript.