Migration This is my new public git source viewer. Source coming soon. Please contact with concerns/bugs. Contact me

internal/notices/handler.go

4bda28df3ab3dbfcc789dcbbb184cabc8cac9398 · 9.5 KB · 316 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
30 // GroupHeader and RequiredGroup gate the admin surface itself, on top of
31 // whatever authentication the reverse proxy already did. Authentik's
32 // forward_auth can be shared by every /admin/* route (so several people
33 // can log in for share links), while only members of RequiredGroup may
34 // reach notices editing. RequiredGroup empty disables the check -- every
35 // request that reaches this handler is authorized, i.e. today's
36 // behavior. GroupHeader defaults to "X-Authentik-Groups", which
37 // Authentik sends as a "|"-delimited list of the user's groups.
38 GroupHeader string
39 RequiredGroup string
40
41 Logger *slog.Logger
42 }
43
44 type adminRow struct {
45 Scope, Type, Title, Body, Link, LinkText string
46 }
47
48 type adminView struct {
49 Site, CSRF, Error string
50 Saved bool
51 Repos []string
52 Rows []adminRow
53 }
54
55 const (
56 csrfCookie = "gsg_admin_csrf"
57 maxFormBytes = 1 << 20
58 )
59
60 type handler struct {
61 cfg HandlerConfig
62 tmpl *template.Template
63 }
64
65 // NewHandler builds the admin mux. It is deliberately separate from the
66 // read-only server: the only write path in gsg lives here, and it writes a
67 // config file, never a repository.
68 func NewHandler(cfg HandlerConfig) (http.Handler, error) {
69 if cfg.Store == nil {
70 return nil, fmt.Errorf("notices admin: no store")
71 }
72 if cfg.Logger == nil {
73 cfg.Logger = slog.Default()
74 }
75 if cfg.UserHeader == "" {
76 cfg.UserHeader = "X-Authentik-Username"
77 }
78 if cfg.GroupHeader == "" {
79 cfg.GroupHeader = "X-Authentik-Groups"
80 }
81 tmpl, err := template.ParseFS(adminFS, "admin.html")
82 if err != nil {
83 return nil, fmt.Errorf("notices admin template: %w", err)
84 }
85 h := &handler{cfg: cfg, tmpl: tmpl}
86
87 mux := http.NewServeMux()
88 // Method-qualified patterns: anything else (PUT, DELETE, or a stray
89 // GET/POST to another path) is refused by the mux, not silently handled.
90 mux.HandleFunc("GET /admin/{$}", h.index)
91 mux.HandleFunc("GET /admin/notices", h.get)
92 mux.HandleFunc("POST /admin/notices", h.post)
93
94 if cfg.RequiredGroup == "" {
95 return mux, nil
96 }
97 return h.requireGroup(mux), nil
98 }
99
100 // requireGroup wraps next so that every admin route first checks
101 // RequiredGroup, before any handler runs (including the CSRF cookie setup
102 // in get/index -- a rejected caller gets no state from this handler at
103 // all). Authentication already happened upstream at the reverse proxy; this
104 // is authorization on top of it, using a group claim the proxy forwards, so
105 // that reaching the (shared) /admin/* forward_auth is not by itself enough
106 // to edit notices.
107 func (h *handler) requireGroup(next http.Handler) http.Handler {
108 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
109 if !h.inRequiredGroup(r) {
110 h.cfg.Logger.Warn("notices admin: group check rejected",
111 "user", r.Header.Get(h.cfg.UserHeader),
112 "groups", r.Header.Get(h.cfg.GroupHeader))
113 http.Error(w, "forbidden", http.StatusForbidden)
114 return
115 }
116 next.ServeHTTP(w, r)
117 })
118 }
119
120 // inRequiredGroup reports whether r carries cfg.RequiredGroup in its
121 // GroupHeader. Authentik delimits multiple groups with "|"; entries are
122 // trimmed so incidental whitespace around the delimiter doesn't cause a
123 // false negative.
124 func (h *handler) inRequiredGroup(r *http.Request) bool {
125 for _, g := range strings.Split(r.Header.Get(h.cfg.GroupHeader), "|") {
126 if strings.TrimSpace(g) == h.cfg.RequiredGroup {
127 return true
128 }
129 }
130 return false
131 }
132
133 func (h *handler) index(w http.ResponseWriter, r *http.Request) {
134 http.Redirect(w, r, "/admin/notices", http.StatusSeeOther)
135 }
136
137 func (h *handler) get(w http.ResponseWriter, r *http.Request) {
138 h.secure(w)
139 tok, err := ensureCSRF(w, r)
140 if err != nil {
141 http.Error(w, "cannot start session", http.StatusInternalServerError)
142 return
143 }
144 h.render(w, http.StatusOK, tok, "", r.URL.Query().Get("saved") == "1")
145 }
146
147 func (h *handler) post(w http.ResponseWriter, r *http.Request) {
148 h.secure(w)
149
150 // Header checks first: they need no body. Then bound and parse the
151 // body, then compare the CSRF token against the cookie. Parsing before
152 // the token check is safe because the read is capped.
153 if !sameOrigin(r) {
154 h.cfg.Logger.Warn("notices admin: origin rejected",
155 "host", r.Host, "origin", r.Header.Get("Origin"),
156 "sec-fetch-site", r.Header.Get("Sec-Fetch-Site"))
157 http.Error(w, "forbidden", http.StatusForbidden)
158 return
159 }
160 r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
161 if err := r.ParseForm(); err != nil {
162 http.Error(w, "bad form", http.StatusBadRequest)
163 return
164 }
165 if !checkCSRF(r) {
166 h.cfg.Logger.Warn("notices admin: csrf rejected",
167 "user", r.Header.Get(h.cfg.UserHeader))
168 http.Error(w, "forbidden", http.StatusForbidden)
169 return
170 }
171
172 doc, err := docFromForm(r)
173 if err == nil {
174 err = h.cfg.Store.Set(doc)
175 }
176 if err != nil {
177 tok, _ := ensureCSRF(w, r)
178 h.render(w, http.StatusBadRequest, tok, err.Error(), false)
179 return
180 }
181
182 h.cfg.Logger.Info("notices updated",
183 "user", r.Header.Get(h.cfg.UserHeader),
184 "global", len(doc.Global), "repos", len(doc.Repos))
185 http.Redirect(w, r, "/admin/notices?saved=1", http.StatusSeeOther)
186 }
187
188 // secure sets the admin response headers. style-src 'self' only: the page
189 // ships no inline script or style, so the CSP can stay tight.
190 func (h *handler) secure(w http.ResponseWriter) {
191 hdr := w.Header()
192 hdr.Set("X-Content-Type-Options", "nosniff")
193 hdr.Set("X-Frame-Options", "DENY")
194 hdr.Set("Content-Security-Policy",
195 "default-src 'none'; style-src 'self'; script-src 'self'; img-src 'self'; form-action 'self'; base-uri 'none'")
196 }
197
198 func (h *handler) render(w http.ResponseWriter, status int, csrf, errMsg string, saved bool) {
199 snap := h.cfg.Store.Snapshot()
200 rows := make([]adminRow, 0, len(snap.Doc.Global)+2)
201 for _, n := range snap.Doc.Global {
202 rows = append(rows, adminRow{"global", n.Type, n.Title, n.Body, n.Link, n.LinkText})
203 }
204 slugs := make([]string, 0, len(snap.Doc.Repos))
205 for slug := range snap.Doc.Repos {
206 slugs = append(slugs, slug)
207 }
208 sort.Strings(slugs)
209 for _, slug := range slugs {
210 for _, n := range snap.Doc.Repos[slug] {
211 rows = append(rows, adminRow{slug, n.Type, n.Title, n.Body, n.Link, n.LinkText})
212 }
213 }
214 // One blank row is the no-JS "add" affordance: fill it and save. The
215 // admin.js helper clones it for the Add button.
216 rows = append(rows, adminRow{"global", TypeInfo, "", "", "", ""})
217
218 w.Header().Set("Content-Type", "text/html; charset=utf-8")
219 w.WriteHeader(status)
220 if err := h.tmpl.Execute(w, adminView{
221 Site: h.cfg.SiteName, CSRF: csrf, Error: errMsg, Saved: saved,
222 Repos: h.cfg.Repos, Rows: rows,
223 }); err != nil {
224 h.cfg.Logger.Error("notices admin render", "err", err)
225 }
226 }
227
228 // docFromForm rebuilds the document from the rendered row list. Rows are
229 // parallel slices (scope[], type[], ...); deletion is a sparse del[] list
230 // of row indices. A row with every field blank is treated as unused -- so
231 // clearing a row removes it. PostForm, not Form, so a query string cannot
232 // inject or override fields.
233 func docFromForm(r *http.Request) (Document, error) {
234 form := r.PostForm
235 at := func(name string, i int) string {
236 if v := form[name]; i < len(v) {
237 return v[i]
238 }
239 return ""
240 }
241 del := map[int]bool{}
242 for _, s := range form["del"] {
243 if i, err := strconv.Atoi(s); err == nil {
244 del[i] = true
245 }
246 }
247 n := len(form["scope"])
248 doc := Document{Repos: map[string][]Notice{}}
249 for i := 0; i < n; i++ {
250 if del[i] {
251 continue
252 }
253 nt := Notice{
254 Type: at("type", i), Title: at("title", i), Body: at("body", i),
255 Link: at("link", i), LinkText: at("link_text", i),
256 }
257 if nt.Type == "" && nt.Title == "" && nt.Body == "" && nt.Link == "" {
258 continue
259 }
260 scope := at("scope", i)
261 if scope == "" || scope == "global" {
262 doc.Global = append(doc.Global, nt)
263 } else {
264 doc.Repos[scope] = append(doc.Repos[scope], nt)
265 }
266 }
267 return doc, nil // Store.Set validates
268 }
269
270 func ensureCSRF(w http.ResponseWriter, r *http.Request) (string, error) {
271 if c, err := r.Cookie(csrfCookie); err == nil && len(c.Value) >= 32 {
272 return c.Value, nil
273 }
274 b := make([]byte, 32)
275 if _, err := rand.Read(b); err != nil {
276 return "", err
277 }
278 tok := hex.EncodeToString(b)
279 http.SetCookie(w, &http.Cookie{
280 Name: csrfCookie, Value: tok, Path: "/admin",
281 HttpOnly: true, Secure: isHTTPS(r), SameSite: http.SameSiteStrictMode,
282 MaxAge: 86400,
283 })
284 return tok, nil
285 }
286
287 func checkCSRF(r *http.Request) bool {
288 c, err := r.Cookie(csrfCookie)
289 if err != nil {
290 return false
291 }
292 got := r.PostForm.Get("csrf")
293 return subtle.ConstantTimeCompare([]byte(c.Value), []byte(got)) == 1
294 }
295
296 func isHTTPS(r *http.Request) bool {
297 return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
298 }
299
300 // sameOrigin blocks a cross-site POST even if the auth cookie were sent.
301 // Forward auth proves the browser holds a session; it does not prove the
302 // request came from our own page.
303 func sameOrigin(r *http.Request) bool {
304 switch r.Header.Get("Sec-Fetch-Site") {
305 case "", "same-origin", "none":
306 default:
307 return false
308 }
309 if o := r.Header.Get("Origin"); o != "" {
310 u, err := url.Parse(o)
311 if err != nil || !strings.EqualFold(u.Host, r.Host) {
312 return false
313 }
314 }
315 return true
316 }