package notices import ( "crypto/rand" "crypto/subtle" "embed" "encoding/hex" "fmt" "html/template" "log/slog" "net/http" "net/url" "sort" "strconv" "strings" ) //go:embed admin.html var adminFS embed.FS // HandlerConfig is everything the admin surface needs that this package // cannot discover for itself. Repos is the visible repository list, used to // populate the scope dropdown. type HandlerConfig struct { Store *Store Repos []string SiteName string UserHeader string // e.g. "X-Authentik-Username"; logging only, never authz // GroupHeader and RequiredGroup gate the admin surface itself, on top of // whatever authentication the reverse proxy already did. Authentik's // forward_auth can be shared by every /admin/* route (so several people // can log in for share links), while only members of RequiredGroup may // reach notices editing. RequiredGroup empty disables the check -- every // request that reaches this handler is authorized, i.e. today's // behavior. GroupHeader defaults to "X-Authentik-Groups", which // Authentik sends as a "|"-delimited list of the user's groups. GroupHeader string RequiredGroup string Logger *slog.Logger } type adminRow struct { Scope, Type, Title, Body, Link, LinkText string } type adminView struct { Site, CSRF, Error string Saved bool Repos []string Rows []adminRow } const ( csrfCookie = "gsg_admin_csrf" maxFormBytes = 1 << 20 ) type handler struct { cfg HandlerConfig tmpl *template.Template } // NewHandler builds the admin mux. It is deliberately separate from the // read-only server: the only write path in gsg lives here, and it writes a // config file, never a repository. func NewHandler(cfg HandlerConfig) (http.Handler, error) { if cfg.Store == nil { return nil, fmt.Errorf("notices admin: no store") } if cfg.Logger == nil { cfg.Logger = slog.Default() } if cfg.UserHeader == "" { cfg.UserHeader = "X-Authentik-Username" } if cfg.GroupHeader == "" { cfg.GroupHeader = "X-Authentik-Groups" } tmpl, err := template.ParseFS(adminFS, "admin.html") if err != nil { return nil, fmt.Errorf("notices admin template: %w", err) } h := &handler{cfg: cfg, tmpl: tmpl} mux := http.NewServeMux() // Method-qualified patterns: anything else (PUT, DELETE, or a stray // GET/POST to another path) is refused by the mux, not silently handled. mux.HandleFunc("GET /admin/{$}", h.index) mux.HandleFunc("GET /admin/notices", h.get) mux.HandleFunc("POST /admin/notices", h.post) if cfg.RequiredGroup == "" { return mux, nil } return h.requireGroup(mux), nil } // requireGroup wraps next so that every admin route first checks // RequiredGroup, before any handler runs (including the CSRF cookie setup // in get/index -- a rejected caller gets no state from this handler at // all). Authentication already happened upstream at the reverse proxy; this // is authorization on top of it, using a group claim the proxy forwards, so // that reaching the (shared) /admin/* forward_auth is not by itself enough // to edit notices. func (h *handler) requireGroup(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !h.inRequiredGroup(r) { h.cfg.Logger.Warn("notices admin: group check rejected", "user", r.Header.Get(h.cfg.UserHeader), "groups", r.Header.Get(h.cfg.GroupHeader)) http.Error(w, "forbidden", http.StatusForbidden) return } next.ServeHTTP(w, r) }) } // inRequiredGroup reports whether r carries cfg.RequiredGroup in its // GroupHeader. Authentik delimits multiple groups with "|"; entries are // trimmed so incidental whitespace around the delimiter doesn't cause a // false negative. func (h *handler) inRequiredGroup(r *http.Request) bool { for _, g := range strings.Split(r.Header.Get(h.cfg.GroupHeader), "|") { if strings.TrimSpace(g) == h.cfg.RequiredGroup { return true } } return false } func (h *handler) index(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/admin/notices", http.StatusSeeOther) } func (h *handler) get(w http.ResponseWriter, r *http.Request) { h.secure(w) tok, err := ensureCSRF(w, r) if err != nil { http.Error(w, "cannot start session", http.StatusInternalServerError) return } h.render(w, http.StatusOK, tok, "", r.URL.Query().Get("saved") == "1") } func (h *handler) post(w http.ResponseWriter, r *http.Request) { h.secure(w) // Header checks first: they need no body. Then bound and parse the // body, then compare the CSRF token against the cookie. Parsing before // the token check is safe because the read is capped. if !sameOrigin(r) { h.cfg.Logger.Warn("notices admin: origin rejected", "host", r.Host, "origin", r.Header.Get("Origin"), "sec-fetch-site", r.Header.Get("Sec-Fetch-Site")) http.Error(w, "forbidden", http.StatusForbidden) return } r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes) if err := r.ParseForm(); err != nil { http.Error(w, "bad form", http.StatusBadRequest) return } if !checkCSRF(r) { h.cfg.Logger.Warn("notices admin: csrf rejected", "user", r.Header.Get(h.cfg.UserHeader)) http.Error(w, "forbidden", http.StatusForbidden) return } doc, err := docFromForm(r) if err == nil { err = h.cfg.Store.Set(doc) } if err != nil { tok, _ := ensureCSRF(w, r) h.render(w, http.StatusBadRequest, tok, err.Error(), false) return } h.cfg.Logger.Info("notices updated", "user", r.Header.Get(h.cfg.UserHeader), "global", len(doc.Global), "repos", len(doc.Repos)) http.Redirect(w, r, "/admin/notices?saved=1", http.StatusSeeOther) } // secure sets the admin response headers. style-src 'self' only: the page // ships no inline script or style, so the CSP can stay tight. func (h *handler) secure(w http.ResponseWriter) { hdr := w.Header() hdr.Set("X-Content-Type-Options", "nosniff") hdr.Set("X-Frame-Options", "DENY") hdr.Set("Content-Security-Policy", "default-src 'none'; style-src 'self'; script-src 'self'; img-src 'self'; form-action 'self'; base-uri 'none'") } func (h *handler) render(w http.ResponseWriter, status int, csrf, errMsg string, saved bool) { snap := h.cfg.Store.Snapshot() rows := make([]adminRow, 0, len(snap.Doc.Global)+2) for _, n := range snap.Doc.Global { rows = append(rows, adminRow{"global", n.Type, n.Title, n.Body, n.Link, n.LinkText}) } slugs := make([]string, 0, len(snap.Doc.Repos)) for slug := range snap.Doc.Repos { slugs = append(slugs, slug) } sort.Strings(slugs) for _, slug := range slugs { for _, n := range snap.Doc.Repos[slug] { rows = append(rows, adminRow{slug, n.Type, n.Title, n.Body, n.Link, n.LinkText}) } } // One blank row is the no-JS "add" affordance: fill it and save. The // admin.js helper clones it for the Add button. rows = append(rows, adminRow{"global", TypeInfo, "", "", "", ""}) w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(status) if err := h.tmpl.Execute(w, adminView{ Site: h.cfg.SiteName, CSRF: csrf, Error: errMsg, Saved: saved, Repos: h.cfg.Repos, Rows: rows, }); err != nil { h.cfg.Logger.Error("notices admin render", "err", err) } } // docFromForm rebuilds the document from the rendered row list. Rows are // parallel slices (scope[], type[], ...); deletion is a sparse del[] list // of row indices. A row with every field blank is treated as unused -- so // clearing a row removes it. PostForm, not Form, so a query string cannot // inject or override fields. func docFromForm(r *http.Request) (Document, error) { form := r.PostForm at := func(name string, i int) string { if v := form[name]; i < len(v) { return v[i] } return "" } del := map[int]bool{} for _, s := range form["del"] { if i, err := strconv.Atoi(s); err == nil { del[i] = true } } n := len(form["scope"]) doc := Document{Repos: map[string][]Notice{}} for i := 0; i < n; i++ { if del[i] { continue } nt := Notice{ Type: at("type", i), Title: at("title", i), Body: at("body", i), Link: at("link", i), LinkText: at("link_text", i), } if nt.Type == "" && nt.Title == "" && nt.Body == "" && nt.Link == "" { continue } scope := at("scope", i) if scope == "" || scope == "global" { doc.Global = append(doc.Global, nt) } else { doc.Repos[scope] = append(doc.Repos[scope], nt) } } return doc, nil // Store.Set validates } func ensureCSRF(w http.ResponseWriter, r *http.Request) (string, error) { if c, err := r.Cookie(csrfCookie); err == nil && len(c.Value) >= 32 { return c.Value, nil } b := make([]byte, 32) if _, err := rand.Read(b); err != nil { return "", err } tok := hex.EncodeToString(b) http.SetCookie(w, &http.Cookie{ Name: csrfCookie, Value: tok, Path: "/admin", HttpOnly: true, Secure: isHTTPS(r), SameSite: http.SameSiteStrictMode, MaxAge: 86400, }) return tok, nil } func checkCSRF(r *http.Request) bool { c, err := r.Cookie(csrfCookie) if err != nil { return false } got := r.PostForm.Get("csrf") return subtle.ConstantTimeCompare([]byte(c.Value), []byte(got)) == 1 } func isHTTPS(r *http.Request) bool { return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") } // sameOrigin blocks a cross-site POST even if the auth cookie were sent. // Forward auth proves the browser holds a session; it does not prove the // request came from our own page. func sameOrigin(r *http.Request) bool { switch r.Header.Get("Sec-Fetch-Site") { case "", "same-origin", "none": default: return false } if o := r.Header.Get("Origin"); o != "" { u, err := url.Parse(o) if err != nil || !strings.EqualFold(u.Host, r.Host) { return false } } return true }