312 lines
12 KiB
Go
312 lines
12 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"runtime/debug"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// withRequestID assigns a request id (honoring a WELL-FORMED inbound X-Request-Id)
|
|
// and echoes it on the response and into the context for the error envelope.
|
|
//
|
|
// A caller-supplied id is honored for cross-service tracing, but only after
|
|
// validation: the id is echoed to the client, embedded in the error envelope, AND
|
|
// persisted verbatim into audit_logs.request_id, so an unvalidated one is an
|
|
// audit-integrity vector — an arbitrarily long value bloats the audit row and a
|
|
// stray control byte could smuggle a forged line into a log sink. A rejected id is
|
|
// replaced with a fresh server-minted one: that one request loses its inbound trace
|
|
// link, which is strictly better than storing attacker-controlled text.
|
|
func withRequestID(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
id := r.Header.Get("X-Request-Id")
|
|
if !validRequestID(id) {
|
|
id = newRequestID()
|
|
}
|
|
w.Header().Set("X-Request-Id", id)
|
|
ctx := context.WithValue(r.Context(), ctxKeyRequestID, id)
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
})
|
|
}
|
|
|
|
// maxRequestIDLen caps an inbound X-Request-Id we are willing to echo and persist.
|
|
// 64 characters comfortably fits a UUID or a typical distributed-trace id while
|
|
// bounding what reaches audit_logs.request_id.
|
|
const maxRequestIDLen = 64
|
|
|
|
// validRequestID reports whether an inbound X-Request-Id is safe to echo and store:
|
|
// non-empty, within maxRequestIDLen, and restricted to an unambiguous, log-safe
|
|
// charset (ASCII alphanumerics plus '-', '_', '.'). The byte-length check bounds it
|
|
// regardless of encoding, and the charset excludes whitespace, CR/LF, and every
|
|
// other control or multibyte rune, so nothing that survives can pollute a log line
|
|
// or the audit row.
|
|
func validRequestID(id string) bool {
|
|
if id == "" || len(id) > maxRequestIDLen {
|
|
return false
|
|
}
|
|
for _, c := range id {
|
|
switch {
|
|
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9':
|
|
case c == '-', c == '_', c == '.':
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// withRecover turns a panicking handler into a 500 envelope instead of a
|
|
// dropped connection.
|
|
//
|
|
// The client gets an opaque "internal error", but the panic value and stack are
|
|
// logged FIRST, keyed by the same request_id — the same contract writeError keeps
|
|
// for unmapped errors (see errors.go). Without it a recovered panic is an
|
|
// untraceable 500: an operator holding "internal error" has nothing to grep for,
|
|
// and diagnosis degrades into guessing against a live install.
|
|
func withRecover(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
defer func() {
|
|
if rec := recover(); rec != nil {
|
|
if rec == http.ErrAbortHandler {
|
|
// A deliberate abort of a committed response (see
|
|
// streamSubmissionContext): let net/http cut the connection.
|
|
panic(rec)
|
|
}
|
|
log.Printf("api: %s %s: panic (request_id=%s): %v\n%s",
|
|
r.Method, r.URL.Path, requestIDFromContext(r.Context()), rec, debug.Stack())
|
|
writeError(w, r, newError(http.StatusInternalServerError, "panic", "internal error"))
|
|
}
|
|
}()
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// withSecurityHeaders sets the response headers every API answer carries. The
|
|
// API serves JSON, event streams and downloads, never a page, so its CSP allows
|
|
// nothing and no frame may embed it. HSTS goes out on what the Cloudflare edge
|
|
// served over HTTPS (viaTLSEdge).
|
|
func withSecurityHeaders(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(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("Referrer-Policy", "no-referrer")
|
|
h.Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
|
|
if viaTLSEdge(r) {
|
|
h.Set("Strict-Transport-Security", "max-age=31536000")
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// viaTLSEdge reports a request the Cloudflare edge took over HTTPS. The tunnel
|
|
// reaches the origin over TLS as well (the self-signed NodePort listener), so
|
|
// r.TLS says nothing about what the browser saw; the edge's X-Forwarded-Proto
|
|
// does. A browser that comes straight to the self-signed listener sends no such
|
|
// header, and would drop the policy anyway: HSTS that arrives over a certificate
|
|
// error, or for an IP-literal host, is ignored (RFC 6797 §8.1).
|
|
func viaTLSEdge(r *http.Request) bool {
|
|
return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
|
|
}
|
|
|
|
// rejectCrossSiteWrites refuses a state-changing request a browser sent from
|
|
// another site. The session cookie is SameSite=Lax, which keeps it off cross-site
|
|
// POSTs but still sends it from a sibling subdomain (same-site), and a player's
|
|
// server can serve pages under the install's root domain. Browsers say where a
|
|
// request came from in Sec-Fetch-Site; one too old for that still sends Origin on
|
|
// a POST. A request with neither is not from a browser (the plugins, the CLI) and
|
|
// carries no ambient cookie to abuse.
|
|
func rejectCrossSiteWrites(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
|
default:
|
|
if !sameOriginRequest(r) {
|
|
writeError(w, r, newError(http.StatusForbidden, "cross_site",
|
|
"requests from another site may not change anything here"))
|
|
return
|
|
}
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func sameOriginRequest(r *http.Request) bool {
|
|
switch r.Header.Get("Sec-Fetch-Site") {
|
|
case "same-origin", "none":
|
|
return true
|
|
case "":
|
|
default: // same-site, cross-site
|
|
return false
|
|
}
|
|
origin := r.Header.Get("Origin")
|
|
if origin == "" {
|
|
return true
|
|
}
|
|
u, err := url.Parse(origin)
|
|
return err == nil && u.Host != "" && strings.EqualFold(u.Host, r.Host)
|
|
}
|
|
|
|
// Request bodies get a read deadline that grows with what has arrived: bodyGrace
|
|
// to get going, plus the time the bytes so far would take at bodyMinRate. A JSON
|
|
// body (1 MiB at most) has well under a minute; a 1 GiB build context uploads as
|
|
// long as it keeps averaging bodyMinRate; a client trickling a byte now and then
|
|
// to hold a connection and a goroutine is cut off. The server sets no ReadTimeout
|
|
// (it would cut the long SSE responses), so without this nothing bounded the body.
|
|
// Vars only so a test can shrink them.
|
|
var (
|
|
bodyGrace = 30 * time.Second
|
|
bodyMinRate = float64(16 << 10) // bytes per second
|
|
)
|
|
|
|
func withBodyDeadline(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Body == nil || r.Body == http.NoBody {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
rc := http.NewResponseController(w)
|
|
start := time.Now()
|
|
if err := rc.SetReadDeadline(start.Add(bodyGrace)); err != nil {
|
|
next.ServeHTTP(w, r) // a writer without deadlines (tests)
|
|
return
|
|
}
|
|
r.Body = &deadlineBody{ReadCloser: r.Body, rc: rc, start: start}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// deadlineBody moves the connection's read deadline as the body arrives, and
|
|
// clears it once the body ends: from then on net/http reads the connection only
|
|
// to notice a disconnect, and a deadline meant for the body would cancel the
|
|
// request's context under a handler that is still working.
|
|
type deadlineBody struct {
|
|
io.ReadCloser
|
|
rc *http.ResponseController
|
|
start time.Time
|
|
read int64
|
|
done bool
|
|
}
|
|
|
|
func (b *deadlineBody) Read(p []byte) (int, error) {
|
|
n, err := b.ReadCloser.Read(p)
|
|
b.read += int64(n)
|
|
switch {
|
|
case b.done:
|
|
case err != nil:
|
|
b.done = true
|
|
_ = b.rc.SetReadDeadline(time.Time{})
|
|
case n > 0:
|
|
allowed := bodyGrace + time.Duration(float64(b.read)/bodyMinRate*float64(time.Second))
|
|
_ = b.rc.SetReadDeadline(b.start.Add(allowed))
|
|
}
|
|
return n, err
|
|
}
|
|
|
|
// requireInternal enforces service-token auth for the internal face and stashes
|
|
// the caller the token belongs to, which callersOnly checks against the route.
|
|
// It never applies Zero Trust (spec §14 red line).
|
|
func (a *API) requireInternal(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
caller, err := a.Internal.Authenticate(r)
|
|
if err != nil {
|
|
writeError(w, r, errUnauthorized)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKeyCaller, caller)))
|
|
})
|
|
}
|
|
|
|
// callersOnly refuses an internal route to a caller it does not list: the token
|
|
// is genuine, it just belongs to a machine this route does not serve.
|
|
func callersOnly(callers []Caller, next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
caller := callerFromContext(r.Context())
|
|
for _, c := range callers {
|
|
if c == caller {
|
|
next(w, r)
|
|
return
|
|
}
|
|
}
|
|
writeError(w, r, errWrongCaller)
|
|
}
|
|
}
|
|
|
|
// requireExternal enforces Access-JWT auth for the external face and stashes the
|
|
// resolved Principal in the request context.
|
|
func (a *API) requireExternal(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
p, err := a.External.Authenticate(r)
|
|
if errors.Is(err, errAuthBackend) {
|
|
// Session store unreachable — an outage, not a missing credential.
|
|
writeError(w, r, errAuthUnavailable)
|
|
return
|
|
}
|
|
if err != nil || p == nil {
|
|
writeError(w, r, errUnauthorized)
|
|
return
|
|
}
|
|
// op.console door gate: the operator console is staff-only, so a request that
|
|
// arrives on the admin host from a non-admin principal is refused HERE, before
|
|
// any handler. Authentication alone (a player's passkey/email/bind session) is
|
|
// not access — internal permission is verified on top of it, so possessing a
|
|
// valid credential never "lets you in" to op.console. On the player console
|
|
// (console.<root_domain>) hostIsAdminConsole is false, so this is inert; in
|
|
// production Cloudflare Access already blocks non-staff at the edge and this is
|
|
// the defense-in-depth backstop for the passwordless (no-Zero-Trust) face.
|
|
if hostIsAdminConsole(r, a.RootDomain, a.AdminHostname) && !p.IsAdmin() {
|
|
writeError(w, r, errForbidden)
|
|
return
|
|
}
|
|
ctx := context.WithValue(r.Context(), ctxKeyPrincipal, p)
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
})
|
|
}
|
|
|
|
// adminOnly gates an external-face handler on the admin Zero-Trust path. The
|
|
// Access middleware has already authenticated; this enforces that admin-tier
|
|
// operations both carry a staff role (admin or owner) AND arrived via admin.*
|
|
// (spec §14).
|
|
func (a *API) adminOnly(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if p := principalFromContext(r.Context()); !p.IsAdmin() {
|
|
writeError(w, r, errForbidden)
|
|
return
|
|
}
|
|
next(w, r)
|
|
}
|
|
}
|
|
|
|
// ownerOnly gates a handler on the owner role — the single platform-level
|
|
// identity above admin. It is stricter than adminOnly: a plain admin with
|
|
// role=admin and valid admin Access path is still refused here. The owner
|
|
// arrives through the same admin Zero-Trust path, so adminOnly is not a
|
|
// prerequisite (the two guards are orthogonal).
|
|
func (a *API) ownerOnly(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if p := principalFromContext(r.Context()); !p.IsOwner() {
|
|
writeError(w, r, errForbidden)
|
|
return
|
|
}
|
|
next(w, r)
|
|
}
|
|
}
|
|
|
|
// newRequestID returns a short random hex id. crypto/rand never fails on the
|
|
// platforms we target; on the impossible error path we fall back to a constant
|
|
// so a request still gets a (non-unique) id rather than crashing.
|
|
func newRequestID() string {
|
|
var b [8]byte
|
|
if _, err := rand.Read(b[:]); err != nil {
|
|
return "req-unknown"
|
|
}
|
|
return hex.EncodeToString(b[:])
|
|
}
|