diff options
Diffstat (limited to 'cmd/mirum-server/server_web.go')
| -rw-r--r-- | cmd/mirum-server/server_web.go | 396 | +0 −396 |
1 files changed, 0 insertions, 396 deletions
diff --git a/cmd/mirum-server/server_web.go b/cmd/mirum-server/server_web.go deleted file mode 100644 --- a/cmd/mirum-server/server_web.go +++ /dev/null @@ -1,396 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -package main - -import ( - "context" - "crypto/rand" - "crypto/subtle" - "crypto/tls" - "encoding/base64" - "errors" - "io" - "log/slog" - "net" - "net/http" - "runtime/debug" - "strings" - - "github.com/go-chi/chi/v5" - "github.com/go-chi/chi/v5/middleware" - "github.com/go-chi/httprate" - - "dimidiumlabs/mirum/cmd/mirum-server/apipb" - "dimidiumlabs/mirum/internal/config" - "dimidiumlabs/mirum/internal/forges" -) - -// __Host- prefixed cookies can only be set with Secure, Path=/, and no -// Domain attribute. Browsers silently reject violations, so subdomain and -// network attackers cannot forge them. -const ( - sessionCookie = "__Host-session" - csrfCookie = "__Host-csrf" -) - -func NewWebServer(ctx context.Context, srv *server, consolePath string, consoleHandler http.Handler) *http.Server { - h := &webHandler{ - srv: srv, - assets: newAssetResolver(), - } - - r := chi.NewRouter() - - r.Use(middleware.CleanPath) - r.Use(middleware.StripSlashes) - r.Use(middleware.RequestID) - r.Use(middleware.Logger) - r.Use(h.recoverer) - r.Use(middleware.Compress(5)) - r.Use(middleware.Heartbeat("/ping")) - r.Use(middleware.Timeout(config.WebRequestTimeout)) - r.Use(middleware.RequestSize(config.WebMaxBodyBytes)) - r.Use(trustedProxyMiddleware(srv.cfg.TrustedProxies)) - - r.Use(func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Security-Policy", csp) - w.Header().Set("X-Content-Type-Options", "nosniff") - w.Header().Set("Referrer-Policy", "no-referrer") - w.Header().Set("Cross-Origin-Opener-Policy", "same-origin") - w.Header().Set("Cross-Origin-Resource-Policy", "same-origin") - w.Header().Set("Permissions-Policy", "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()") - - if srv.cfg.WebTls != nil { - w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload") - } - - next.ServeHTTP(w, r) - }) - }) - - // The authorization session sets the user to ctx - r.Use(h.SessionMiddleware) - - r.With(middleware.SetHeader("Cache-Control", "public, max-age=31536000, immutable")). - Mount("/assets", assetsHandler()) - - r.Get("/", authonly(h.index)) - r.Get("/about/licenses", h.licenses) - r.Post("/webhook", h.webhook) - - r.Route("/auth", func(r chi.Router) { - r.Use(middleware.NoCache) - r.Use(httprate.LimitByIP(config.AuthRateLimit, config.AuthRateWindow)) - r.Use(middleware.RequestSize(config.AuthMaxBodyBytes)) - - r.Get("/login", h.loginPage) - r.Post("/login", h.login) - r.Post("/logout", h.logout) - }) - - r.With(middleware.NoCache, httprate.LimitByIP(config.APIRateLimit, config.APIRateWindow)). - Mount("/api/v1", http.StripPrefix("/api/v1", consoleHandler)) - - r.NotFound(func(w http.ResponseWriter, r *http.Request) { - h.renderError(w, r, http.StatusNotFound) - }) - r.MethodNotAllowed(func(w http.ResponseWriter, r *http.Request) { - h.renderError(w, r, http.StatusMethodNotAllowed) - }) - - var tlsCfg *tls.Config - if srv.cfg.WebTls != nil { - certs := newCertReloader(srv.cfg.WebTls.Cert, srv.cfg.WebTls.Key) - tlsCfg = &tls.Config{ - MinVersion: tls.VersionTLS13, - GetCertificate: certs.GetCertificate, - } - } - - return &http.Server{ - Handler: r, - TLSConfig: tlsCfg, - BaseContext: func(_ net.Listener) context.Context { - return ctx - }, - } -} - -type actorKey struct{} - -type webHandler struct { - srv *server - assets *assetResolver -} - -// ActorFromContext returns the authenticated actor, or AnonActor if none. -func ActorFromContext(ctx context.Context) Actor { - if v, ok := ctx.Value(actorKey{}).(Actor); ok { - return v - } - return AnonActor() -} - -// SessionMiddleware resolves the session cookie and puts the Actor in context. -func (h *webHandler) SessionMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if c, err := r.Cookie(sessionCookie); err == nil { - if actor, err := h.srv.db.UserSessionGet(r.Context(), SystemActor(), c.Value); err == nil { - ctx := context.WithValue(r.Context(), actorKey{}, actor) - r = r.WithContext(ctx) - } - } - next.ServeHTTP(w, r) - }) -} - -type authedHandler func(w http.ResponseWriter, r *http.Request, actor Actor) - -func authonly(next authedHandler) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - actor := ActorFromContext(r.Context()) - if actor.Kind() == KindAnon { - http.Redirect(w, r, "/auth/login", http.StatusSeeOther) - return - } - next(w, r, actor) - } -} - -func (h *webHandler) index(w http.ResponseWriter, r *http.Request, actor Actor) { - h.assets.renderPage(w, "dashboard", http.StatusOK, map[string]any{ - "user": map[string]string{"email": actor.Email()}, - "csrf": csrfToken(w, r), - }) -} - -// renderError serves the error page with the given HTTP status. -func (h *webHandler) renderError(w http.ResponseWriter, r *http.Request, status int) { - h.assets.renderPage(w, "error", status, map[string]any{"status": status}) -} - -// recoverer catches panics, logs them, and renders the 500 page so the -// client sees something more useful than chi's plaintext default. The -// http.ErrAbortHandler sentinel is re-raised so net/http's server -// machinery can recognise an intentional handler abort. -func (h *webHandler) recoverer(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - defer func() { - rvr := recover() - if rvr == nil { - return - } - if rvr == http.ErrAbortHandler { - panic(rvr) - } - slog.Error("panic", - "err", rvr, - "path", r.URL.Path, - "stack", string(debug.Stack()), - ) - h.renderError(w, r, http.StatusInternalServerError) - }() - next.ServeHTTP(w, r) - }) -} - -func (h *webHandler) webhook(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - if err != nil { - http.Error(w, "read body", http.StatusBadRequest) - return - } - - ev, err := h.srv.forge.Webhook(r, body) - if errors.Is(err, forges.ErrInvalidSignature) { - http.Error(w, "invalid signature", http.StatusUnauthorized) - return - } - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - if ev == nil { - w.WriteHeader(http.StatusNoContent) - return - } - - if _, err := h.srv.enqueue(r.Context(), ev); err != nil { - http.Error(w, "server shutting down", http.StatusServiceUnavailable) - return - } - w.WriteHeader(http.StatusAccepted) -} - -func (h *webHandler) loginPage(w http.ResponseWriter, r *http.Request) { - h.renderLogin(w, r, http.StatusOK, apipb.ErrorReason_ERROR_REASON_UNSPECIFIED) -} - -// renderLogin is the single entry point for every login-flow outcome that -// lands back on the login page. Reason == UNSPECIFIED means no error banner. -// No caller writes error text itself — the client maps reason → copy. -func (h *webHandler) renderLogin(w http.ResponseWriter, r *http.Request, status int, reason apipb.ErrorReason) { - data := map[string]any{"csrf": csrfToken(w, r)} - if reason != apipb.ErrorReason_ERROR_REASON_UNSPECIFIED { - data["errorReason"] = int32(reason) - } - h.assets.renderPage(w, "login", status, data) -} - -func (h *webHandler) login(w http.ResponseWriter, r *http.Request) { - if !csrfOK(r) { - clearCookie(w, csrfCookie) - h.renderLogin(w, r, http.StatusForbidden, apipb.ErrorReason_ERROR_REASON_INVALID_CSRF) - return - } - - email := r.FormValue("email") - password := r.FormValue("password") - - userID, err := h.srv.db.UserVerifyPassword(r.Context(), SystemActor(), email, password, []byte(h.srv.cfg.Pepper)) - if err != nil { - h.renderLogin(w, r, http.StatusUnauthorized, apipb.ErrorReason_ERROR_REASON_INVALID_CREDENTIALS) - return - } - - token, err := h.srv.db.UserSessionCreate(r.Context(), SystemActor(), userID) - if err != nil { - slog.Error("create session failed", "err", err) - h.renderLogin(w, r, http.StatusInternalServerError, apipb.ErrorReason_ERROR_REASON_INTERNAL) - return - } - - http.SetCookie(w, &http.Cookie{ - Name: sessionCookie, - Value: token, - Path: "/", - HttpOnly: true, - Secure: true, - SameSite: http.SameSiteLaxMode, - MaxAge: int(config.SessionTTL.Seconds()), - }) - http.Redirect(w, r, "/", http.StatusSeeOther) -} - -func (h *webHandler) logout(w http.ResponseWriter, r *http.Request) { - if !csrfOK(r) { - // Forged logout attempt — ignore silently. Session stays valid, - // user ends up wherever / takes them. - http.Redirect(w, r, "/", http.StatusSeeOther) - return - } - if c, err := r.Cookie(sessionCookie); err == nil { - if err := h.srv.db.UserSessionDelete(r.Context(), SystemActor(), c.Value); err != nil { - slog.Warn("logout: failed to delete session", "err", err) - } - } - clearCookie(w, sessionCookie) - clearCookie(w, csrfCookie) - http.Redirect(w, r, "/auth/login", http.StatusSeeOther) -} - -// csrfToken returns the current CSRF token, setting a cookie if absent. -func csrfToken(w http.ResponseWriter, r *http.Request) string { - if c, err := r.Cookie(csrfCookie); err == nil && c.Value != "" { - return c.Value - } - b := make([]byte, 32) - if _, err := rand.Read(b); err != nil { - panic("crypto/rand failed: " + err.Error()) - } - token := base64.RawURLEncoding.EncodeToString(b) - http.SetCookie(w, &http.Cookie{ - Name: csrfCookie, - Value: token, - Path: "/", - HttpOnly: true, - Secure: true, - SameSite: http.SameSiteStrictMode, - MaxAge: int(config.SessionTTL.Seconds()), - }) - return token -} - -// csrfOK checks that the form field or X-CSRF-Token header matches the -// cookie (double-submit). Form posts use the hidden "csrf" field; API calls -// from the SPA pass the token via the X-CSRF-Token header. -func csrfOK(r *http.Request) bool { - cookie, err := r.Cookie(csrfCookie) - if err != nil || cookie.Value == "" { - return false - } - token := r.FormValue("csrf") - if token == "" { - token = r.Header.Get("X-CSRF-Token") - } - return subtle.ConstantTimeCompare([]byte(cookie.Value), []byte(token)) == 1 -} - -func clearCookie(w http.ResponseWriter, name string) { - http.SetCookie(w, &http.Cookie{ - Name: name, - Value: "", - Path: "/", - HttpOnly: true, - Secure: true, - MaxAge: -1, - }) -} - -// trustedProxyMiddleware resolves the real client IP from X-Forwarded-For, -// walking right-to-left and stopping at the first untrusted hop. -// Empty cidrs = trust RemoteAddr only (safe default). -func trustedProxyMiddleware(cidrs []string) func(http.Handler) http.Handler { - nets := make([]*net.IPNet, 0, len(cidrs)) - for _, c := range cidrs { - _, n, err := net.ParseCIDR(c) - if err != nil { - panic("invalid trusted_proxies CIDR: " + c) - } - nets = append(nets, n) - } - - isTrusted := func(ip net.IP) bool { - for _, n := range nets { - if n.Contains(ip) { - return true - } - } - return false - } - - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if len(nets) == 0 { - next.ServeHTTP(w, r) - return - } - - host, _, _ := net.SplitHostPort(r.RemoteAddr) - ip := net.ParseIP(host) - if ip == nil || !isTrusted(ip) { - // RemoteAddr is not a trusted proxy — use as-is. - next.ServeHTTP(w, r) - return - } - - // Walk X-Forwarded-For right to left. - xff := strings.Split(r.Header.Get("X-Forwarded-For"), ",") - for i := len(xff) - 1; i >= 0; i-- { - candidate := strings.TrimSpace(xff[i]) - ip = net.ParseIP(candidate) - if ip == nil { - break // garbage — stop, don't trust anything further left - } - if !isTrusted(ip) { - r.RemoteAddr = candidate + ":0" - break - } - } - - next.ServeHTTP(w, r) - }) - } -} |
