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 }