Files
cedricandClaude Opus 5.5 42f6137391 Safer defaults, read-only role, security headers and syslog TCP limits
- ALLOW_PURGE is now false by default; the UI shows a banner when there is
  no authentication.
- Read-only role: AUTH_VIEWER_USER/AUTH_VIEWER_PASS in local mode, or
  OIDC_ADMIN_GROUP in OIDC mode; changes get 403 and the admin settings
  are greyed out.
- Content-Security-Policy (inline scripts allowed by hash) and other
  security headers; cross-site changes are refused.
- Syslog TCP: at most SYSLOG_TCP_MAX_CONNS connections, closed after
  SYSLOG_TCP_IDLE of silence; HTTP idle timeout.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 16:30:40 +02:00

123 lines
3.6 KiB
Go

package main
import (
"context"
"crypto/sha256"
"encoding/base64"
"io/fs"
"net/http"
"net/url"
"regexp"
"strings"
)
// Request guards shared by every auth mode: security headers, a cross-site
// request check, and the read-only role.
type viewerKey struct{}
// asViewer marks the request as made by a read-only user.
func asViewer(r *http.Request) *http.Request {
return r.WithContext(context.WithValue(r.Context(), viewerKey{}, true))
}
func isViewer(r *http.Request) bool {
v, _ := r.Context().Value(viewerKey{}).(bool)
return v
}
func roleName(viewer bool) string {
if viewer {
return "viewer"
}
return "admin"
}
func isSafeMethod(m string) bool {
return m == http.MethodGet || m == http.MethodHead || m == http.MethodOptions
}
// readOnly refuses the API calls that change something (tags, sources, purge)
// to read-only users. Without authentication everyone is admin.
func readOnly(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if isViewer(r) && !isSafeMethod(r.Method) && strings.HasPrefix(r.URL.Path, "/api/") {
writeErr(w, http.StatusForbidden, &codedError{code: "read_only", msg: "read-only account: changes are reserved to administrators"})
return
}
next.ServeHTTP(w, r)
})
}
// crossSite tells whether a request that changes something comes from another
// site (a form or script on a third-party page), using the headers browsers
// add; tools such as curl send neither and are let through.
func crossSite(r *http.Request) bool {
switch r.Header.Get("Sec-Fetch-Site") {
case "same-origin", "none":
return false
case "":
default: // same-site, cross-site
return true
}
o := r.Header.Get("Origin")
if o == "" {
return false
}
u, err := url.Parse(o)
return err != nil || !strings.EqualFold(u.Host, r.Host)
}
// secure adds the security headers to every answer and refuses cross-site
// changes. Without authentication it also answers /auth/me, so the UI can
// warn that anyone on the network has full access.
func secure(next http.Handler, csp string, authOn bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "same-origin")
h.Set("Content-Security-Policy", csp)
if !isSafeMethod(r.Method) && crossSite(r) {
writeErr(w, http.StatusForbidden, &codedError{code: "cross_site", msg: "cross-site request refused"})
return
}
if !authOn && r.URL.Path == "/auth/me" {
writeJSON(w, http.StatusOK, map[string]string{"mode": "none", "role": "admin"})
return
}
next.ServeHTTP(w, r)
})
}
var inlineScript = regexp.MustCompile(`(?s)<script>(.*?)</script>`)
// contentSecurityPolicy allows the UI's own files, the inline scripts of the
// embedded pages (by hash) and the optional Bunny Fonts.
func contentSecurityPolicy(static fs.FS) string {
scripts := []string{"'self'"}
for _, page := range []string{"index.html", "login.html"} {
b, err := fs.ReadFile(static, page)
if err != nil {
continue
}
for _, m := range inlineScript.FindAllSubmatch(b, -1) {
sum := sha256.Sum256(m[1])
scripts = append(scripts, "'sha256-"+base64.StdEncoding.EncodeToString(sum[:])+"'")
}
}
return strings.Join([]string{
"default-src 'self'",
"script-src " + strings.Join(scripts, " "),
// Inline style attributes carry the tag and project colors.
"style-src 'self' 'unsafe-inline' https://fonts.bunny.net",
"font-src 'self' https://fonts.bunny.net",
"img-src 'self' data:",
"connect-src 'self'",
"object-src 'none'",
"base-uri 'none'",
"form-action 'self'",
"frame-ancestors 'none'",
}, "; ")
}