v0.333.0: the file manager's own token ends with the dashboard sign-out (R-1155); the recovery-code page says the code becomes live after the check (R-1161)
gates / gates (push) Successful in 49s

This commit is contained in:
2026-10-19 11:30:59 +02:00
parent a741bc1d0f
commit c809dd3bc6
10 changed files with 172 additions and 8 deletions
+51 -1
View File
@@ -29,6 +29,9 @@ import (
// pass for the files cookie (`felhom_files`, host-only on files.<domain>, HttpOnly, Secure, Lax) and goes on to the
// folder. A valid files cookie → 200 WITH the box's secret header naming the household's FileBrowser user. No files
// cookie → 200 WITHOUT it: FileBrowser asks for its own password (`admin` — the fallback, and the operator's way).
// R-1155: with no valid files cookie, a request that carries a NON-admin FileBrowser token (the household user's,
// kept by a browser that lost only the files cookie, or copied out of one) is refused like a stale files cookie —
// so FileBrowser's own 2-hour token cannot outlive the dashboard session. `admin`'s token still passes.
// A files cookie that no longer opens (the dashboard session ended, the dashboard password changed) → both cookies
// cleared and the browser sent back to the dashboard, which asks for the sign-in again.
//
@@ -184,6 +187,50 @@ func (s *Server) filesCookieKey(r *http.Request) (present, ok bool) {
return true, s.filesSessionAlive(parts[0])
}
// carriesNonAdminFileBrowserToken: does the request carry a FileBrowser token (its cookie, a Bearer header, or the
// `auth` query parameter of the forwarded URI) whose permissions are not admin's? FileBrowser 1.5.6's token is an HS256
// JWT with the claims belongsTo, Permissions{admin,…}, iat, exp, iss (measured on demo-hp 2026-10-19); the user's NAME
// is not in it, so admin-or-not is the line: the box has `admin` (password) and the header user (never admin,
// infra.fileBrowserDefaultPermissions). The signature is NOT checked here and need not be: this check can only REFUSE,
// and a token forged to read admin:true is refused by FileBrowser itself. A token that does not parse passes on to
// FileBrowser, which refuses it. Pinned by TestFilesSSO_R1155_HouseholdTokenDiesWithTheSignOut.
func carriesNonAdminFileBrowserToken(r *http.Request, uri string) bool {
var toks []string
if c, err := r.Cookie(fileBrowserJWTCookie); err == nil && c.Value != "" {
toks = append(toks, c.Value)
}
if a := r.Header.Get("Authorization"); len(a) > 7 && strings.EqualFold(a[:7], "bearer ") {
toks = append(toks, strings.TrimSpace(a[7:]))
}
if u, err := url.Parse(uri); err == nil {
if v := u.Query().Get("auth"); v != "" {
toks = append(toks, v)
}
}
for _, t := range toks {
parts := strings.Split(t, ".")
if len(parts) != 3 {
continue
}
raw, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(parts[1], "="))
if err != nil {
continue
}
var claims struct {
Permissions *struct {
Admin bool `json:"admin"`
} `json:"Permissions"`
}
if json.Unmarshal(raw, &claims) != nil || claims.Permissions == nil {
continue
}
if !claims.Permissions.Admin {
return true
}
}
return false
}
// safeFilesPath accepts a FileBrowser path to go to after the pass: "/" or under /files/ — never another host, and never
// a dot-segment that a browser would resolve out of /files/ (onto FileBrowser's API, say).
func safeFilesPath(p string) string {
@@ -322,9 +369,12 @@ func (s *Server) ServeFilesAuth(w http.ResponseWriter, r *http.Request) {
case ok:
w.Header().Set(header, infra.FileBrowserSSOUser)
w.WriteHeader(http.StatusOK)
case !present:
case !present && !carriesNonAdminFileBrowserToken(r, uri):
w.WriteHeader(http.StatusOK) // FileBrowser's own sign-in (traefik has removed any client copy of the header)
default:
if !present {
s.logger.Printf("[WARN] [web] files sign-in: a non-admin FileBrowser token came with no files cookie — refused (R-1155), visitor %s", clientIP(r))
}
s.clearFilesCookies(w)
if browser {
w.Header().Set("Cache-Control", "no-store")