package httpapi import ( "crypto/hmac" "crypto/rand" "crypto/sha256" "crypto/subtle" "embed" "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" "io/fs" "mime" "net/http" "path" "strings" "time" ) const ( adminSessionCookie = "gpo_admin_session" adminSessionTTL = 8 * time.Hour ) //go:embed ui/* var embeddedUI embed.FS type adminSession struct { ExpiresAt int64 `json:"exp"` CSRF string `json:"csrf"` Nonce string `json:"nonce"` } func (s *Server) registerWebUI() { s.mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) return } http.Redirect(w, r, "/ui/", http.StatusTemporaryRedirect) }) s.mux.HandleFunc("POST /ui/api/session", s.createWebSession) s.mux.HandleFunc("GET /ui/api/session", s.getWebSession) s.mux.HandleFunc("DELETE /ui/api/session", s.deleteWebSession) s.mux.HandleFunc("GET /ui", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/ui/", http.StatusTemporaryRedirect) }) s.mux.HandleFunc("GET /ui/{$}", s.serveUIIndex) s.mux.HandleFunc("GET /ui/{asset...}", s.serveUIAsset) } func (s *Server) serveUIIndex(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/ui/" { http.NotFound(w, r) return } s.serveEmbeddedFile(w, r, "ui/index.html") } func (s *Server) serveUIAsset(w http.ResponseWriter, r *http.Request) { asset := path.Clean(r.PathValue("asset")) if asset == "." || strings.HasPrefix(asset, "../") || strings.Contains(asset, "\\") { http.NotFound(w, r) return } if asset != "app.js" && asset != "styles.css" { http.NotFound(w, r) return } s.serveEmbeddedFile(w, r, "ui/"+asset) } func (s *Server) serveEmbeddedFile(w http.ResponseWriter, r *http.Request, name string) { data, err := fs.ReadFile(embeddedUI, name) if err != nil { http.NotFound(w, r) return } contentType := mime.TypeByExtension(path.Ext(name)) if contentType == "" { contentType = http.DetectContentType(data) } w.Header().Set("Content-Type", contentType) if strings.HasSuffix(name, ".html") { w.Header().Set("Cache-Control", "no-store") } else { w.Header().Set("Cache-Control", "no-cache") } w.WriteHeader(http.StatusOK) _, _ = w.Write(data) } func (s *Server) createWebSession(w http.ResponseWriter, r *http.Request) { var request struct { Token string `json:"token"` } if err := decodeJSON(w, r, &request, 64<<10); err != nil { writeError(w, http.StatusBadRequest, err) return } if !constantTimeEqual(request.Token, s.cfg.AdminToken) { writeError(w, http.StatusUnauthorized, errors.New("invalid credentials")) return } csrf, err := randomToken(32) if err != nil { writeError(w, http.StatusInternalServerError, err) return } nonce, err := randomToken(16) if err != nil { writeError(w, http.StatusInternalServerError, err) return } session := adminSession{ExpiresAt: time.Now().Add(adminSessionTTL).Unix(), CSRF: csrf, Nonce: nonce} value, err := s.signAdminSession(session) if err != nil { writeError(w, http.StatusInternalServerError, err) return } http.SetCookie(w, &http.Cookie{ Name: adminSessionCookie, Value: value, Path: "/", MaxAge: int(adminSessionTTL.Seconds()), Expires: time.Unix(session.ExpiresAt, 0), HttpOnly: true, Secure: requestIsHTTPS(r), SameSite: http.SameSiteStrictMode, }) w.Header().Set("Cache-Control", "no-store") writeJSON(w, http.StatusOK, map[string]any{ "csrf_token": csrf, "expires_at": time.Unix(session.ExpiresAt, 0).UTC(), "version": s.cfg.ServerVersion, }) } func (s *Server) getWebSession(w http.ResponseWriter, r *http.Request) { session, err := s.readAdminSession(r) if err != nil { writeError(w, http.StatusUnauthorized, errors.New("not authenticated")) return } w.Header().Set("Cache-Control", "no-store") writeJSON(w, http.StatusOK, map[string]any{ "csrf_token": session.CSRF, "expires_at": time.Unix(session.ExpiresAt, 0).UTC(), "version": s.cfg.ServerVersion, }) } func (s *Server) deleteWebSession(w http.ResponseWriter, r *http.Request) { if session, err := s.readAdminSession(r); err == nil { provided := r.Header.Get("X-CSRF-Token") if !constantTimeEqual(provided, session.CSRF) { writeError(w, http.StatusForbidden, errors.New("invalid CSRF token")) return } } http.SetCookie(w, &http.Cookie{ Name: adminSessionCookie, Value: "", Path: "/", MaxAge: -1, Expires: time.Unix(1, 0), HttpOnly: true, Secure: requestIsHTTPS(r), SameSite: http.SameSiteStrictMode, }) w.WriteHeader(http.StatusNoContent) } func (s *Server) requireAdmin(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { auth := r.Header.Get("Authorization") w.Header().Set("Cache-Control", "no-store") if auth != "" { if !strings.HasPrefix(auth, "Bearer ") || !constantTimeEqual(strings.TrimPrefix(auth, "Bearer "), s.cfg.AdminToken) { writeError(w, http.StatusForbidden, errors.New("invalid bearer token")) return } next.ServeHTTP(w, r) return } session, err := s.readAdminSession(r) if err != nil { writeError(w, http.StatusUnauthorized, errors.New("not authenticated")) return } if methodNeedsCSRF(r.Method) && !constantTimeEqual(r.Header.Get("X-CSRF-Token"), session.CSRF) { writeError(w, http.StatusForbidden, errors.New("invalid CSRF token")) return } next.ServeHTTP(w, r) }) } func (s *Server) signAdminSession(session adminSession) (string, error) { payload, err := json.Marshal(session) if err != nil { return "", err } encoded := base64.RawURLEncoding.EncodeToString(payload) mac := hmac.New(sha256.New, []byte(s.cfg.AdminToken)) _, _ = mac.Write([]byte("gpo-web-session-v1\x00")) _, _ = mac.Write([]byte(encoded)) return encoded + "." + hex.EncodeToString(mac.Sum(nil)), nil } func (s *Server) readAdminSession(r *http.Request) (adminSession, error) { cookie, err := r.Cookie(adminSessionCookie) if err != nil { return adminSession{}, err } encoded, signature, ok := strings.Cut(cookie.Value, ".") if !ok || encoded == "" || signature == "" { return adminSession{}, errors.New("malformed session") } sig, err := hex.DecodeString(signature) if err != nil { return adminSession{}, errors.New("malformed session signature") } mac := hmac.New(sha256.New, []byte(s.cfg.AdminToken)) _, _ = mac.Write([]byte("gpo-web-session-v1\x00")) _, _ = mac.Write([]byte(encoded)) if !hmac.Equal(sig, mac.Sum(nil)) { return adminSession{}, errors.New("invalid session signature") } payload, err := base64.RawURLEncoding.DecodeString(encoded) if err != nil { return adminSession{}, errors.New("malformed session payload") } var session adminSession if err := json.Unmarshal(payload, &session); err != nil { return adminSession{}, errors.New("malformed session payload") } if session.ExpiresAt <= time.Now().Unix() || session.CSRF == "" || session.Nonce == "" { return adminSession{}, errors.New("expired session") } return session, nil } func randomToken(size int) (string, error) { buffer := make([]byte, size) if _, err := rand.Read(buffer); err != nil { return "", fmt.Errorf("generate random token: %w", err) } return base64.RawURLEncoding.EncodeToString(buffer), nil } func constantTimeEqual(provided, expected string) bool { if len(provided) != len(expected) { return false } return subtle.ConstantTimeCompare([]byte(provided), []byte(expected)) == 1 } func methodNeedsCSRF(method string) bool { return method != http.MethodGet && method != http.MethodHead && method != http.MethodOptions } func requestIsHTTPS(r *http.Request) bool { if r.TLS != nil { return true } return strings.EqualFold(strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Proto"), ",")[0]), "https") } func (s *Server) securityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Referrer-Policy", "no-referrer") w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("Cross-Origin-Opener-Policy", "same-origin") w.Header().Set("Cross-Origin-Resource-Policy", "same-origin") w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") if strings.HasPrefix(r.URL.Path, "/ui") || r.URL.Path == "/" { w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'") } next.ServeHTTP(w, r) }) }