first commit
This commit is contained in:
104
go-api/internal/httpserver/cors.go
Normal file
104
go-api/internal/httpserver/cors.go
Normal file
@@ -0,0 +1,104 @@
|
||||
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)
|
||||
|
||||
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") != ""
|
||||
}
|
||||
Reference in New Issue
Block a user