Files
krow_backend/go-api/internal/httpserver/cors.go
2026-08-25 16:37:05 +05:30

124 lines
5.1 KiB
Go

package httpserver
import (
"net/http"
"strconv"
"strings"
)
// Cross-origin access, for local development.
//
// In Phase 2D the frontend fetches this API directly from the Vite dev server,
// which is a different origin (http://localhost:5173 → http://127.0.0.1:8080).
// Without these headers the browser makes the request and then refuses to let
// the page read the response, which surfaces in the app as an opaque "Failed to
// fetch" with a perfectly healthy 200 in the server log.
//
// This is a transport concern only. No endpoint, request shape, response shape
// or status code in docs/api-contract.md changes because of it.
// corsMaxAge is how long a browser may cache a preflight result. Ten minutes
// keeps preflight off the hot path without making an allowlist change take an
// awkwardly long time to be noticed in development.
const corsMaxAge = 600
// allowedCORSMethods is every method the router actually registers, plus
// OPTIONS for the preflight itself. It is a fixed list rather than something
// derived per path: the browser asks about one method at a time and only needs
// to know it is permitted in general.
var allowedCORSMethods = []string{
http.MethodGet, http.MethodPost, http.MethodPatch,
http.MethodDelete, http.MethodOptions,
}
// cors answers preflights and marks cross-origin responses as readable.
//
// Origins are matched exactly against the allowlist and echoed back one at a
// time — never "*" — so adding credentials later does not require rewriting
// this. A request whose Origin is not on the list is served normally, with no
// CORS headers: the API does not refuse it, the browser simply will not hand
// the response to the page. That distinction matters, because curl, the health
// checker and any server-to-server caller send no Origin at all and must not be
// affected by this middleware.
//
// With an empty allowlist the middleware is not installed at all (see New), so
// the same-origin deployment pays nothing for it.
func cors(origins []string) func(http.Handler) http.Handler {
allowed := make(map[string]bool, len(origins))
for _, o := range origins {
allowed[o] = true
}
methods := strings.Join(allowedCORSMethods, ", ")
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
// Vary on Origin whether or not this particular origin matched: the
// response differs by Origin, so a cache that ignored it could hand
// one origin's headers to another.
w.Header().Add("Vary", "Origin")
if origin == "" || !allowed[origin] {
if isPreflight(r) {
// A preflight is never a real request. Answering it with
// the router's 404 for "OPTIONS /api/v1/…" would be
// misleading; 403 says plainly that the origin was refused.
w.WriteHeader(http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
return
}
w.Header().Set("Access-Control-Allow-Origin", origin)
// The frontend sends `credentials: "include"`, and a browser
// discards any response to such a request that does not carry this
// header — preflight included. Safe only because the origin was
// matched exactly above and is echoed back one at a time; "*" is
// never sent, which is the pairing the spec forbids.
w.Header().Set("Access-Control-Allow-Credentials", "true")
// Authentication is a cookie, so the browser will neither send it
// nor expose the response without this. It is set for allowlisted
// origins only, and the origin above is always a specific one —
// the pairing of Allow-Credentials with "*" is rejected outright by
// browsers, which is the second reason this middleware never echoes
// a wildcard.
//
// Both the preflight and the actual response need it: the preflight
// decides whether the browser is willing to SEND the cookie, and the
// actual response decides whether the page may READ the result.
// Setting it here, before the preflight branch, covers both.
w.Header().Set("Access-Control-Allow-Credentials", "true")
if isPreflight(r) {
w.Header().Add("Vary", "Access-Control-Request-Method")
w.Header().Add("Vary", "Access-Control-Request-Headers")
w.Header().Set("Access-Control-Allow-Methods", methods)
// Echo the requested headers rather than listing them. The
// frontend sends only Content-Type today; echoing means a
// future header does not need a change here to be allowed from
// an origin that is already trusted.
if h := r.Header.Get("Access-Control-Request-Headers"); h != "" {
w.Header().Set("Access-Control-Allow-Headers", h)
} else {
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
}
w.Header().Set("Access-Control-Max-Age", strconv.Itoa(corsMaxAge))
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
}
// isPreflight identifies the browser's OPTIONS probe. A bare OPTIONS with no
// Access-Control-Request-Method is not a preflight and is left to the router.
func isPreflight(r *http.Request) bool {
return r.Method == http.MethodOptions &&
r.Header.Get("Access-Control-Request-Method") != ""
}