Files
groot d191b871de
All checks were successful
release-tag / release-image (push) Successful in 1m37s
init
2026-08-05 10:46:09 +02:00

287 lines
8.4 KiB
Go

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)
})
}