Migration
This is my new public git source viewer. Source coming soon. Please contact with concerns/bugs.
Contact me
internal/notices/handler.go
38fa4e6cd9bb9dbf0ae23ef02e50b1c75cd84ebb
· 7.4 KB · 264 lines
raw
| 1 | package notices |
| 2 | |
| 3 | import ( |
| 4 | "crypto/rand" |
| 5 | "crypto/subtle" |
| 6 | "embed" |
| 7 | "encoding/hex" |
| 8 | "fmt" |
| 9 | "html/template" |
| 10 | "log/slog" |
| 11 | "net/http" |
| 12 | "net/url" |
| 13 | "sort" |
| 14 | "strconv" |
| 15 | "strings" |
| 16 | ) |
| 17 | |
| 18 | //go:embed admin.html |
| 19 | var adminFS embed.FS |
| 20 | |
| 21 | // HandlerConfig is everything the admin surface needs that this package |
| 22 | // cannot discover for itself. Repos is the visible repository list, used to |
| 23 | // populate the scope dropdown. |
| 24 | type HandlerConfig struct { |
| 25 | Store *Store |
| 26 | Repos []string |
| 27 | SiteName string |
| 28 | UserHeader string // e.g. "X-Authentik-Username"; logging only, never authz |
| 29 | Logger *slog.Logger |
| 30 | } |
| 31 | |
| 32 | type adminRow struct { |
| 33 | Scope, Type, Title, Body, Link, LinkText string |
| 34 | } |
| 35 | |
| 36 | type adminView struct { |
| 37 | Site, CSRF, Error string |
| 38 | Saved bool |
| 39 | Repos []string |
| 40 | Rows []adminRow |
| 41 | } |
| 42 | |
| 43 | const ( |
| 44 | csrfCookie = "gsg_admin_csrf" |
| 45 | maxFormBytes = 1 << 20 |
| 46 | ) |
| 47 | |
| 48 | type handler struct { |
| 49 | cfg HandlerConfig |
| 50 | tmpl *template.Template |
| 51 | } |
| 52 | |
| 53 | // NewHandler builds the admin mux. It is deliberately separate from the |
| 54 | // read-only server: the only write path in gsg lives here, and it writes a |
| 55 | // config file, never a repository. |
| 56 | func NewHandler(cfg HandlerConfig) (http.Handler, error) { |
| 57 | if cfg.Store == nil { |
| 58 | return nil, fmt.Errorf("notices admin: no store") |
| 59 | } |
| 60 | if cfg.Logger == nil { |
| 61 | cfg.Logger = slog.Default() |
| 62 | } |
| 63 | if cfg.UserHeader == "" { |
| 64 | cfg.UserHeader = "X-Authentik-Username" |
| 65 | } |
| 66 | tmpl, err := template.ParseFS(adminFS, "admin.html") |
| 67 | if err != nil { |
| 68 | return nil, fmt.Errorf("notices admin template: %w", err) |
| 69 | } |
| 70 | h := &handler{cfg: cfg, tmpl: tmpl} |
| 71 | |
| 72 | mux := http.NewServeMux() |
| 73 | // Method-qualified patterns: anything else (PUT, DELETE, or a stray |
| 74 | // GET/POST to another path) is refused by the mux, not silently handled. |
| 75 | mux.HandleFunc("GET /admin/{$}", h.index) |
| 76 | mux.HandleFunc("GET /admin/notices", h.get) |
| 77 | mux.HandleFunc("POST /admin/notices", h.post) |
| 78 | return mux, nil |
| 79 | } |
| 80 | |
| 81 | func (h *handler) index(w http.ResponseWriter, r *http.Request) { |
| 82 | http.Redirect(w, r, "/admin/notices", http.StatusSeeOther) |
| 83 | } |
| 84 | |
| 85 | func (h *handler) get(w http.ResponseWriter, r *http.Request) { |
| 86 | h.secure(w) |
| 87 | tok, err := ensureCSRF(w, r) |
| 88 | if err != nil { |
| 89 | http.Error(w, "cannot start session", http.StatusInternalServerError) |
| 90 | return |
| 91 | } |
| 92 | h.render(w, http.StatusOK, tok, "", r.URL.Query().Get("saved") == "1") |
| 93 | } |
| 94 | |
| 95 | func (h *handler) post(w http.ResponseWriter, r *http.Request) { |
| 96 | h.secure(w) |
| 97 | |
| 98 | // Header checks first: they need no body. Then bound and parse the |
| 99 | // body, then compare the CSRF token against the cookie. Parsing before |
| 100 | // the token check is safe because the read is capped. |
| 101 | if !sameOrigin(r) { |
| 102 | h.cfg.Logger.Warn("notices admin: origin rejected", |
| 103 | "host", r.Host, "origin", r.Header.Get("Origin"), |
| 104 | "sec-fetch-site", r.Header.Get("Sec-Fetch-Site")) |
| 105 | http.Error(w, "forbidden", http.StatusForbidden) |
| 106 | return |
| 107 | } |
| 108 | r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes) |
| 109 | if err := r.ParseForm(); err != nil { |
| 110 | http.Error(w, "bad form", http.StatusBadRequest) |
| 111 | return |
| 112 | } |
| 113 | if !checkCSRF(r) { |
| 114 | h.cfg.Logger.Warn("notices admin: csrf rejected", |
| 115 | "user", r.Header.Get(h.cfg.UserHeader)) |
| 116 | http.Error(w, "forbidden", http.StatusForbidden) |
| 117 | return |
| 118 | } |
| 119 | |
| 120 | doc, err := docFromForm(r) |
| 121 | if err == nil { |
| 122 | err = h.cfg.Store.Set(doc) |
| 123 | } |
| 124 | if err != nil { |
| 125 | tok, _ := ensureCSRF(w, r) |
| 126 | h.render(w, http.StatusBadRequest, tok, err.Error(), false) |
| 127 | return |
| 128 | } |
| 129 | |
| 130 | h.cfg.Logger.Info("notices updated", |
| 131 | "user", r.Header.Get(h.cfg.UserHeader), |
| 132 | "global", len(doc.Global), "repos", len(doc.Repos)) |
| 133 | http.Redirect(w, r, "/admin/notices?saved=1", http.StatusSeeOther) |
| 134 | } |
| 135 | |
| 136 | // secure sets the admin response headers. style-src 'self' only: the page |
| 137 | // ships no inline script or style, so the CSP can stay tight. |
| 138 | func (h *handler) secure(w http.ResponseWriter) { |
| 139 | hdr := w.Header() |
| 140 | hdr.Set("X-Content-Type-Options", "nosniff") |
| 141 | hdr.Set("X-Frame-Options", "DENY") |
| 142 | hdr.Set("Content-Security-Policy", |
| 143 | "default-src 'none'; style-src 'self'; script-src 'self'; img-src 'self'; form-action 'self'; base-uri 'none'") |
| 144 | } |
| 145 | |
| 146 | func (h *handler) render(w http.ResponseWriter, status int, csrf, errMsg string, saved bool) { |
| 147 | snap := h.cfg.Store.Snapshot() |
| 148 | rows := make([]adminRow, 0, len(snap.Doc.Global)+2) |
| 149 | for _, n := range snap.Doc.Global { |
| 150 | rows = append(rows, adminRow{"global", n.Type, n.Title, n.Body, n.Link, n.LinkText}) |
| 151 | } |
| 152 | slugs := make([]string, 0, len(snap.Doc.Repos)) |
| 153 | for slug := range snap.Doc.Repos { |
| 154 | slugs = append(slugs, slug) |
| 155 | } |
| 156 | sort.Strings(slugs) |
| 157 | for _, slug := range slugs { |
| 158 | for _, n := range snap.Doc.Repos[slug] { |
| 159 | rows = append(rows, adminRow{slug, n.Type, n.Title, n.Body, n.Link, n.LinkText}) |
| 160 | } |
| 161 | } |
| 162 | // One blank row is the no-JS "add" affordance: fill it and save. The |
| 163 | // admin.js helper clones it for the Add button. |
| 164 | rows = append(rows, adminRow{"global", TypeInfo, "", "", "", ""}) |
| 165 | |
| 166 | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 167 | w.WriteHeader(status) |
| 168 | if err := h.tmpl.Execute(w, adminView{ |
| 169 | Site: h.cfg.SiteName, CSRF: csrf, Error: errMsg, Saved: saved, |
| 170 | Repos: h.cfg.Repos, Rows: rows, |
| 171 | }); err != nil { |
| 172 | h.cfg.Logger.Error("notices admin render", "err", err) |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | // docFromForm rebuilds the document from the rendered row list. Rows are |
| 177 | // parallel slices (scope[], type[], ...); deletion is a sparse del[] list |
| 178 | // of row indices. A row with every field blank is treated as unused -- so |
| 179 | // clearing a row removes it. PostForm, not Form, so a query string cannot |
| 180 | // inject or override fields. |
| 181 | func docFromForm(r *http.Request) (Document, error) { |
| 182 | form := r.PostForm |
| 183 | at := func(name string, i int) string { |
| 184 | if v := form[name]; i < len(v) { |
| 185 | return v[i] |
| 186 | } |
| 187 | return "" |
| 188 | } |
| 189 | del := map[int]bool{} |
| 190 | for _, s := range form["del"] { |
| 191 | if i, err := strconv.Atoi(s); err == nil { |
| 192 | del[i] = true |
| 193 | } |
| 194 | } |
| 195 | n := len(form["scope"]) |
| 196 | doc := Document{Repos: map[string][]Notice{}} |
| 197 | for i := 0; i < n; i++ { |
| 198 | if del[i] { |
| 199 | continue |
| 200 | } |
| 201 | nt := Notice{ |
| 202 | Type: at("type", i), Title: at("title", i), Body: at("body", i), |
| 203 | Link: at("link", i), LinkText: at("link_text", i), |
| 204 | } |
| 205 | if nt.Type == "" && nt.Title == "" && nt.Body == "" && nt.Link == "" { |
| 206 | continue |
| 207 | } |
| 208 | scope := at("scope", i) |
| 209 | if scope == "" || scope == "global" { |
| 210 | doc.Global = append(doc.Global, nt) |
| 211 | } else { |
| 212 | doc.Repos[scope] = append(doc.Repos[scope], nt) |
| 213 | } |
| 214 | } |
| 215 | return doc, nil // Store.Set validates |
| 216 | } |
| 217 | |
| 218 | func ensureCSRF(w http.ResponseWriter, r *http.Request) (string, error) { |
| 219 | if c, err := r.Cookie(csrfCookie); err == nil && len(c.Value) >= 32 { |
| 220 | return c.Value, nil |
| 221 | } |
| 222 | b := make([]byte, 32) |
| 223 | if _, err := rand.Read(b); err != nil { |
| 224 | return "", err |
| 225 | } |
| 226 | tok := hex.EncodeToString(b) |
| 227 | http.SetCookie(w, &http.Cookie{ |
| 228 | Name: csrfCookie, Value: tok, Path: "/admin", |
| 229 | HttpOnly: true, Secure: isHTTPS(r), SameSite: http.SameSiteStrictMode, |
| 230 | MaxAge: 86400, |
| 231 | }) |
| 232 | return tok, nil |
| 233 | } |
| 234 | |
| 235 | func checkCSRF(r *http.Request) bool { |
| 236 | c, err := r.Cookie(csrfCookie) |
| 237 | if err != nil { |
| 238 | return false |
| 239 | } |
| 240 | got := r.PostForm.Get("csrf") |
| 241 | return subtle.ConstantTimeCompare([]byte(c.Value), []byte(got)) == 1 |
| 242 | } |
| 243 | |
| 244 | func isHTTPS(r *http.Request) bool { |
| 245 | return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") |
| 246 | } |
| 247 | |
| 248 | // sameOrigin blocks a cross-site POST even if the auth cookie were sent. |
| 249 | // Forward auth proves the browser holds a session; it does not prove the |
| 250 | // request came from our own page. |
| 251 | func sameOrigin(r *http.Request) bool { |
| 252 | switch r.Header.Get("Sec-Fetch-Site") { |
| 253 | case "", "same-origin", "none": |
| 254 | default: |
| 255 | return false |
| 256 | } |
| 257 | if o := r.Header.Get("Origin"); o != "" { |
| 258 | u, err := url.Parse(o) |
| 259 | if err != nil || !strings.EqualFold(u.Host, r.Host) { |
| 260 | return false |
| 261 | } |
| 262 | } |
| 263 | return true |
| 264 | } |