Session 09 ยท ๐Ÿ›ก๏ธ Protocol

Headers, CORS & TLS

Tell the browser exactly who may call you and how to treat your responses โ€” over an encrypted connection.

โœ“
9aCORS & preflight
โœ“
9bsecurity headers
โœ“
9cTLS & secure cookies

9a

The preflight: the browser asks first

Before a cross-origin PATCH with a token, the browser sends an OPTIONS and waits for permission.

internal/httpx/cors.go
func CORS(allowed []string, next http.Handler) http.Handler {
    fn := func(w http.ResponseWriter, r *http.Request) {
        origin := r.Header.Get("Origin")
        w.Header().Add("Vary", "Origin")
        if origin != "" && slices.Contains(allowed, origin) {
            h := w.Header()
            h.Set("Access-Control-Allow-Origin", origin)
            h.Set("Access-Control-Allow-Credentials", "true")
            if r.Method == http.MethodOptions {
                h.Set("Access-Control-Allow-Methods",
                    "GET, POST, PATCH, DELETE")
                h.Set("Access-Control-Allow-Headers",
                    "Authorization, Content-Type")
                h.Set("Access-Control-Max-Age", "600")
                w.WriteHeader(http.StatusNoContent)
                return
            }
        }
        if r.Method == http.MethodOptions {
            w.WriteHeader(http.StatusNoContent) // no CORS headers
            return
        }
        next.ServeHTTP(w, r)
    }
    return http.HandlerFunc(fn)
}

// allowlist comes from config (session 10)
h := CORS([]string{"https://app.example.com"}, mux)
go run ./demos/09 ยท two preflights
OPTIONS from https://app.example.com โ†’ 204
  Access-Control-Allow-Origin: https://app.example.com
  Access-Control-Allow-Credentials: true
  Access-Control-Allow-Methods: GET, POST, PATCH, DELETE
  Access-Control-Allow-Headers: Authorization, Content-Type
  Vary: Origin
OPTIONS from https://evil.example โ†’ 204
  Vary: Origin
# no Allow-Origin header โ†’ the browser refuses to send the PATCH
โœ—Allow-Origin: * with credentials is rejected by browsers โ€” and reflecting any Origin back is worse. Use an exact allowlist.
โ†’Vary: Origin stops a cache serving one origin's CORS answer to another.
โ†’CORS is not access control. The browser enforces it; curl ignores it. Auth still runs on every request.

9b

Five headers on every response

Each one switches off a browser behaviour an attacker would like to use.

Header
Value
Stops
X-Content-Type-Options
nosniff
guessing a JSON upload is a script
X-Frame-Options
DENY
clickjacking in an iframe
Content-Security-Policy
default-src 'none'
loading any script, style or frame
Referrer-Policy
no-referrer
leaking URLs with ids / tokens
Strict-Transport-Security
max-age=63072000
downgrade to plain HTTP
internal/httpx/headers.go
func SecureHeaders(next http.Handler) http.Handler {
    fn := func(w http.ResponseWriter, r *http.Request) {
        h := w.Header()
        h.Set("X-Content-Type-Options", "nosniff")
        h.Set("X-Frame-Options", "DENY")
        h.Set("Content-Security-Policy",
            "default-src 'none'; frame-ancestors 'none'")
        h.Set("Referrer-Policy", "no-referrer")
        h.Set("Strict-Transport-Security",
            "max-age=63072000; includeSubDomains")
        next.ServeHTTP(w, r)
    }
    return http.HandlerFunc(fn)
}
โ†’A JSON API can use the strictest CSP โ€” default-src 'none' โ€” because it serves no HTML.
โ†’Set headers before next.ServeHTTP โ€” once the body starts, headers are sent.

9c

TLS: somebody must terminate it

Either Go serves HTTPS itself, or a load balancer does and forwards plain HTTP on a private network.

Browserhttps://api.example.com
TLS 1.3
encrypted
Load balancerterminates TLS ยท adds X-Forwarded-For
HTTP
private net
Go API:8080
cmd/api/main.go ยท when Go terminates TLS itself
srv := &http.Server{
    Addr:              ":443",
    Handler:           h,
    ReadHeaderTimeout: 5 * time.Second, // slow-loris guard
    ReadTimeout:       10 * time.Second,
    WriteTimeout:      10 * time.Second,
    IdleTimeout:       60 * time.Second,
    TLSConfig:         &tls.Config{MinVersion: tls.VersionTLS12},
}
go http.ListenAndServe(":80", http.HandlerFunc(redirect))
log.Fatal(srv.ListenAndServeTLS("cert.pem", "key.pem"))

func redirect(w http.ResponseWriter, r *http.Request) {
    u := "https://" + r.Host + r.URL.RequestURI()
    http.Redirect(w, r, u, http.StatusMovedPermanently)
}
go run ./demos/09 ยท httptest TLS server + ./demos/09c
  proto: HTTP/1.1  tls: TLS 1.3  cipher: TLS_AES_128_GCM_SHA256
  plain http โ†’ 400 Client sent an HTTP request to an HTTPS server.
  untrusted cert: Get "https://127.0.0.1:39139/users/me": tls: failed to verify certificate: x509: certificate signed by unknown authority
301 https://api.example.com/users/me?x=1     # the :80 redirect
Cookie flag
Effect
For the refresh cookie
Secure
sent over HTTPS only
always
HttpOnly
invisible to JavaScript
always
SameSite=Strict
not sent cross-site
yes (Lax if you need links in)
Path=/auth/refresh
sent to one route only
yes
โ†’Go's http.Server has no timeouts by default โ€” always set them.
โ†’HSTS only counts when received over HTTPS; after that the browser refuses plain HTTP for max-age.
โœ—Never ship InsecureSkipVerify: true in a client โ€” it turns TLS off without saying so.
TASK

Lock down the edges

  • Wrap the mux: SecureHeaders(CORS(allowed, mux)), allowlist from an env var.
  • Test: preflight from the allowed origin gets Allow-Origin; any other origin doesn't.
  • Run locally with httptest.NewTLSServer and assert res.TLS.Version.

Recap

Session 09 in four lines

1CORS: exact origin allowlist, Vary: Origin, credentials only for listed origins.
2Five headers on every response: nosniff, DENY, strict CSP, no-referrer, HSTS.
3Terminate TLS (1.2+) in Go or at the load balancer; redirect :80 โ†’ :443.
4Server timeouts always; cookies Secure; HttpOnly; SameSite.