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

190 lines
5.5 KiB
Go

package main
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"log"
"net"
"net/http"
"net/url"
"os"
"strings"
"time"
)
// Local mode with AUTH_USER set: a login page (web/login.html) in the colors of the UI and a
// session kept in a signed cookie, like the OIDC mode. HTTP Basic credentials are still
// accepted so scripts calling the API keep working, but the browser popup is gone.
const loginPage = "/login.html"
var loginFailDelay = time.Second // slows down password guessing
type Local struct {
user, pass string
viewerUser string // optional read-only account
viewerPass string
ttl time.Duration
logo string // LOGIN_LOGO, served at /auth/logo
key []byte
next http.Handler
}
func newLocal(c authConfig) *Local {
// The key depends on the credentials: changing AUTH_USER or AUTH_PASS ends every session.
m := hmac.New(sha256.New, sessionKey(c.dataDir))
m.Write([]byte("local\x00" + c.user + "\x00" + c.pass + "\x00" + c.viewerUser + "\x00" + c.viewerPass))
if c.loginLogo != "" {
if _, err := os.Stat(c.loginLogo); err != nil {
log.Printf("auth: LOGIN_LOGO: %v", err)
}
}
log.Printf("local authentication enabled (user %s)", c.user)
if c.viewerUser != "" {
log.Printf("local read-only account enabled (user %s)", c.viewerUser)
}
return &Local{user: c.user, pass: c.pass, viewerUser: c.viewerUser, viewerPass: c.viewerPass, ttl: c.sessionTTL, logo: c.loginLogo, key: m.Sum(nil)}
}
func (l *Local) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/healthz", "/style.css":
l.next.ServeHTTP(w, r)
return
case "/auth/logo":
l.serveLogo(w, r)
return
case "/auth/login":
l.handleLogin(w, r)
return
case "/auth/logout":
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Path: "/", MaxAge: -1, HttpOnly: true, Secure: isHTTPS(r)})
http.Redirect(w, r, loginPage, http.StatusFound)
return
}
s, ok := l.sessionUser(r)
if !ok {
if u, p, basic := r.BasicAuth(); basic {
if viewer, valid := l.check(u, p); valid {
s, ok = session{User: u, Viewer: viewer}, true
}
}
}
if ok && s.Viewer {
r = asViewer(r)
}
switch {
case r.URL.Path == loginPage:
if ok {
http.Redirect(w, r, safeReturn(r.URL.Query().Get("r")), http.StatusFound)
return
}
w.Header().Set("Cache-Control", "no-store")
l.next.ServeHTTP(w, r)
case ok && r.URL.Path == "/auth/me":
writeJSON(w, http.StatusOK, map[string]string{"mode": "local", "user": s.User, "role": roleName(s.Viewer)})
case ok:
l.next.ServeHTTP(w, r)
case r.Method == http.MethodGet && !strings.HasPrefix(r.URL.Path, "/api/") && r.URL.Path != "/auth/me":
target := loginPage
if ret := r.URL.RequestURI(); ret != "/" {
target += "?" + url.Values{"r": {ret}}.Encode()
}
http.Redirect(w, r, target, http.StatusFound)
default:
writeAuthRequired(w)
}
}
func (l *Local) handleLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Redirect(w, r, loginPage, http.StatusFound)
return
}
user, pass := r.PostFormValue("user"), r.PostFormValue("pass")
ret := safeReturn(r.PostFormValue("r"))
viewer, valid := l.check(user, pass)
if !valid {
log.Printf("auth: failed login for %q from %s", user, clientIP(r))
time.Sleep(loginFailDelay)
q := url.Values{"e": {"1"}}
if ret != "/" {
q.Set("r", ret)
}
http.Redirect(w, r, loginPage+"?"+q.Encode(), http.StatusSeeOther)
return
}
log.Printf("auth: %s logged in from %s (%s)", user, clientIP(r), roleName(viewer))
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: signCookie(l.key, session{User: user, Exp: time.Now().Add(l.ttl).Unix(), Viewer: viewer}),
Path: "/",
MaxAge: int(l.ttl.Seconds()),
HttpOnly: true,
Secure: isHTTPS(r),
SameSite: http.SameSiteLaxMode,
})
http.Redirect(w, r, ret, http.StatusSeeOther)
}
func (l *Local) sessionUser(r *http.Request) (session, bool) {
var s session
c, err := r.Cookie(sessionCookie)
if err != nil || !verifyCookie(l.key, c.Value, &s) || time.Now().Unix() >= s.Exp {
return session{}, false
}
return s, true
}
// check validates a user and password: the admin account, or the read-only one
// (viewer=true) when AUTH_VIEWER_USER is set.
func (l *Local) check(user, pass string) (viewer, ok bool) {
if same(user, l.user) && same(pass, l.pass) {
return false, true
}
if l.viewerUser != "" && same(user, l.viewerUser) && same(pass, l.viewerPass) {
return true, true
}
return false, false
}
// same compares in constant time, so the answer time says nothing of the secret.
func same(a, b string) bool {
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}
// serveLogo sends LOGIN_LOGO; without it the login page hides the image.
func (l *Local) serveLogo(w http.ResponseWriter, r *http.Request) {
if l.logo == "" {
http.NotFound(w, r)
return
}
w.Header().Set("Cache-Control", "no-cache")
http.ServeFile(w, r, l.logo)
}
// safeReturn keeps the page to open after login inside logstream.
func safeReturn(ret string) string {
if !strings.HasPrefix(ret, "/") || strings.HasPrefix(ret, "//") || strings.HasPrefix(ret, "/\\") || ret == loginPage {
return "/"
}
return ret
}
// isHTTPS is true when the browser talks HTTPS, directly or through a reverse proxy.
func isHTTPS(r *http.Request) bool {
return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
}
func clientIP(r *http.Request) string {
if f := r.Header.Get("X-Forwarded-For"); f != "" {
return strings.TrimSpace(strings.Split(f, ",")[0])
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}