middleware.go RawHistoryFolder
1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package httpserver
4
5import (
6 "net/http"
7 "path"
8 "strings"
9 "time"
10
11 "github.com/bnema/zerowrap"
12)
13
14// contentSecurityPolicy allows scripts from the origin plus 'unsafe-eval'
15// for htmx attribute expressions; inline scripts stay forbidden.
16const contentSecurityPolicy = "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'"
17
18// withSecurityHeaders sets the security headers on every response, before
19// the handler runs, so that errors and 404s carry them too.
20func withSecurityHeaders(next http.Handler) http.Handler {
21 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
22 h := w.Header()
23 h.Set("Content-Security-Policy", contentSecurityPolicy)
24 h.Set("X-Frame-Options", "DENY")
25 h.Set("X-Content-Type-Options", "nosniff")
26 h.Set("Referrer-Policy", "same-origin")
27 next.ServeHTTP(w, r)
28 })
29}
30
31// statusRecorder remembers the status code of a response.
32type statusRecorder struct {
33 http.ResponseWriter
34 status int
35}
36
37func (s *statusRecorder) WriteHeader(code int) {
38 if s.status == 0 {
39 s.status = code
40 }
41 s.ResponseWriter.WriteHeader(code)
42}
43
44func (s *statusRecorder) Write(b []byte) (int, error) {
45 if s.status == 0 {
46 s.status = http.StatusOK
47 }
48 return s.ResponseWriter.Write(b)
49}
50
51// Flush lets streaming handlers (Connect server streams require an
52// http.Flusher) push their messages out as they are written.
53func (s *statusRecorder) Flush() {
54 if s.status == 0 {
55 s.status = http.StatusOK
56 }
57 if f, ok := s.ResponseWriter.(http.Flusher); ok {
58 f.Flush()
59 }
60}
61
62// Unwrap lets http.ResponseController reach Flush and friends.
63func (s *statusRecorder) Unwrap() http.ResponseWriter { return s.ResponseWriter }
64
65// loginPathPrefix is where the web serves one-time login links: the rest of
66// the path is a secret.
67const loginPathPrefix = "/-/login/"
68
69// loggedPath is the path of a request as the log may show it. The check is
70// wide on purpose: a path that only reaches the link after cleaning
71// ("/x/../-/login/…", "//-/login/…") or holds the prefix further in is cut too.
72func loggedPath(p string) string {
73 if strings.HasPrefix(path.Clean(p), loginPathPrefix) || strings.Contains(p, loginPathPrefix) {
74 return loginPathPrefix + "…"
75 }
76 return p
77}
78
79// withRequestLog logs method, path, status and duration at debug level. It
80// never logs the query string or any header: they can carry credentials. The
81// secret of a login link is cut from the path.
82func withRequestLog(next http.Handler) http.Handler {
83 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
84 start := time.Now()
85 rec := &statusRecorder{ResponseWriter: w}
86 next.ServeHTTP(rec, r)
87 status := rec.status
88 if status == 0 {
89 status = http.StatusOK
90 }
91 log := zerowrap.FromCtx(r.Context())
92 log.Debug().
93 Str("method", r.Method).
94 Str("path", loggedPath(r.URL.Path)).
95 Int("status", status).
96 Dur("duration", time.Since(start)).
97 Msg("request")
98 })
99}