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