v0.332.0: one sign-in for the file manager — a dashboard pass, a host-only files cookie, a per-box secret header (R-1100, operator D16)
gates / gates (push) Successful in 47s

This commit is contained in:
2026-10-19 04:56:56 +02:00
parent ba4a6f060b
commit 9bdd8f8fe8
21 changed files with 1078 additions and 54 deletions
+3
View File
@@ -193,6 +193,9 @@ func (s *Server) renderAppDataTab(w http.ResponseWriter, r *http.Request, found
if flashErr := s.flashFrom(r, "flash_error"); flashErr != "" {
data["FlashError"] = flashErr
}
// R-1100: with the one sign-in, the folder links open FileBrowser without its own login, so the „asks for its own
// password" note is said only on a box without it. Pinned by TestFilesSSO_R1100_DataTabLoginNote.
data["FilesSSO"] = s.filesHeader() != ""
data["Title"] = found.Meta.DisplayName
data["TitleKey"], data["TitleArgs"] = "page.title.app_data", []interface{}{found.Meta.DisplayName}
s.executeTemplate(w, r, "app_data", data)
+3 -3
View File
@@ -99,7 +99,7 @@ func (s *Server) buildDataPathCards(st *stacks.Stack, lang string) []DataPathCar
// bind it) is TEXT, never a link — a link to a missing source opens an empty or broken view.
src, drive := s.fbSourceForApp(st.Name)
if src != "" && s.drivePresent(drive) {
link = fileBrowserLink(domain, src, dp.Path)
link = fileBrowserOpenLink(domain, src, dp.Path)
} else {
s.logger.Printf("[WARN] [web] app page %s: folder %q has no FileBrowser source on this box now (drive %q) — shown without a link", st.Name, dp.Path, drive)
}
@@ -185,7 +185,7 @@ func (s *Server) mediaFolderCards(st *stacks.Stack, domain, lang string, have []
}
card := DataPathCard{Label: s.msgLang(lang, "datapath.media.row", label, src), Role: stacks.RoleLibrary, IsMedia: true}
if present {
card.Link = fileBrowserLink(domain, src, path.Join("media", sub))
card.Link = fileBrowserOpenLink(domain, src, path.Join("media", sub))
if seen[card.Link] {
continue
}
@@ -255,7 +255,7 @@ func (s *Server) filesButtonLink(st *stacks.Stack, cards []DataPathCard) string
}
for _, sp := range s.mediaStoragePaths() {
if s.drivePresent(sp.Path) {
return fileBrowserLink(s.cfg.Customer.Domain, fbSourceName(sp), "media")
return fileBrowserOpenLink(s.cfg.Customer.Domain, fbSourceName(sp), "media")
}
}
return ""
@@ -1,6 +1,7 @@
package web
import (
"net/url"
"strings"
"testing"
@@ -42,7 +43,7 @@ func TestAppInfo_DataPathCardRenders(t *testing.T) {
}
// The deep link must reach the page INTACT — this is the assertion the brief asks for, and it is
// what would have caught the v0.150.0 class of bug (a key rendered where another belonged).
if !strings.Contains(html, "https://files.demo-felhom.eu/files/Beolvas%C3%A1s/paperless") {
if !strings.Contains(html, "https://felhom.demo-felhom.eu/__files/open?to="+url.QueryEscape("/files/Beolvas%C3%A1s/paperless")) {
t.Errorf("the FileBrowser deep link must render intact; body:\n%s", excerpt(html, "datapath"))
}
if !strings.Contains(html, "nem készül róla biztonsági mentés") {
+16 -2
View File
@@ -58,7 +58,21 @@ func fileBrowserLink(domain, sourceName, relPath string) string {
return b.String()
}
// importFolderLink is the deep link to a drop-zone app's folder inside the canonical import source.
// importFolderLink is the deep link to a drop-zone app's folder inside the canonical import source (through the
// dashboard's pass, R-1100).
func importFolderLink(domain, appDir string) string {
return fileBrowserLink(domain, infra.FileBrowserImportLabel, appDir)
return fileBrowserOpenLink(domain, infra.FileBrowserImportLabel, appDir)
}
// fileBrowserOpenLink is what a dashboard page links to for a FileBrowser folder since R-1100: the dashboard's
// GET /__files/open, which hands the household's signed-in browser a one-time pass to the file manager, so FileBrowser
// opens at the folder without its own login (files_sso.go). The folder is fileBrowserLink's path, carried in `to`
// (query-escaped as a whole; the path keeps its own PathEscape inside). Pinned by TestFileBrowserOpenLink_R1100_*.
func fileBrowserOpenLink(domain, sourceName, relPath string) string {
return filesOpenURL(domain, fileBrowserLink(domain, sourceName, relPath))
}
// filesOpenURL turns a files.<domain> link into the dashboard's pass link for the same FileBrowser path.
func filesOpenURL(domain, link string) string {
return "https://felhom." + domain + filesOpenPath + "?" + url.Values{"to": {filesPathOf(link, "files."+domain)}}.Encode()
}
@@ -82,6 +82,11 @@ func TestImportFolderLink_IsCanonical(t *testing.T) {
a := importFolderLink("demo-felhom.eu", "paperless")
b := importFolderLink("demo-felhom.eu", "calibre")
for _, l := range []string{a, b} {
u, err := url.Parse(l) // R-1100: through the dashboard's pass; the FileBrowser path rides `to`
if err != nil || u.Host != "felhom.demo-felhom.eu" || u.Path != filesOpenPath {
t.Fatalf("import link does not go through the dashboard's pass: %q", l)
}
l = u.Query().Get("to")
if !strings.Contains(l, "/files/"+url.PathEscape(infra.FileBrowserImportLabel)+"/") {
t.Errorf("import link must go through the canonical source: %q", l)
}
+314
View File
@@ -0,0 +1,314 @@
package web
import (
"crypto/hmac"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/infra"
)
// ── One sign-in for the file manager (R-1100, operator decision D16 = yes, 2026-10-19) ───────────────────────────────
//
// Design: felhom.eu audits/proxyauth-spike-2026-10-18/DESIGN-NOTE.md (measured) and `02` „Logins and folders". The
// setup gate's handshake (setup_gate.go, decision 46) with one change: the pass names a FileBrowser user.
//
// - „Fájlok ↗" goes to GET /__files/open?to=<FileBrowser path> on the DASHBOARD host. With the household's session it
// mints a pass — 60 s, one use, bound to files.<domain> and to that session — and sends the browser to
// https://files.<domain>/_felhom/sso?t=<pass>. Without a session: the dashboard's sign-in, then back here.
// - Every request to files.<domain> first asks GET /__felhom_gate/files (traefik forwardAuth). /_felhom/sso swaps the
// 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).
// 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.
//
// THE RULES. The dashboard cookie is never widened to the parent domain — every app subdomain would receive it (the
// setup gate's measured reason). The files cookie carries a value DERIVED from the session's fingerprint for this one
// host (the R-976 shape), never the session: lifted from FileBrowser's side it opens nothing else, and it dies with
// the session. The header's NAME is the box's secret (infra.LoadOrCreateFileBrowserSSOHeader) and only this answer
// and traefik's route carry it — an app container that reaches filebrowser:80 directly cannot forge it. The answer
// gives the header only for files.<domain> (X-Forwarded-Host, which traefik writes; the entrypoint removes a client's).
// Pinned by internal/web/files_sso_test.go.
const (
filesAuthPath = "/__felhom_gate/files"
filesCallbackURI = "/_felhom/sso"
filesOpenPath = "/__files/open"
filesCookieName = "felhom_files"
// fileBrowserJWTCookie is FileBrowser's own session cookie (backend/http/auth.go sessionCookieName at
// 1.5.6-stable). It is cleared with the files cookie, so a header user's FileBrowser token does not outlive the
// dashboard session it came from.
fileBrowserJWTCookie = "filebrowser_quantum_jwt"
)
type filesSSOState struct {
mu sync.Mutex
header string // cached once read: the name never changes once made
}
// fileBrowserSSO is this box's header sign-in (the same file the stacks manager renders FileBrowser's config and
// traefik's route from). An error = none, logged; the name itself is never logged.
func (s *Server) fileBrowserSSO() infra.FileBrowserSSO {
if s.cfg == nil {
return infra.FileBrowserSSO{}
}
sso, err := infra.FileBrowserSSOFor(s.cfg.Paths.DataDir, s.cfg.Customer.Domain)
if err != nil {
s.logger.Printf("[WARN] [web] files sign-in: none (R-1100): %v — FileBrowser asks for its own password", err)
return infra.FileBrowserSSO{}
}
return sso
}
// filesHeader is fileBrowserSSO's header name, read once (the forwardAuth answer runs on every FileBrowser request).
func (s *Server) filesHeader() string {
s.filesSSO.mu.Lock()
defer s.filesSSO.mu.Unlock()
if s.filesSSO.header == "" {
s.filesSSO.header = s.fileBrowserSSO().Header
}
return s.filesSSO.header
}
func (s *Server) filesHost() string {
if s.cfg == nil || s.cfg.Customer.Domain == "" {
return ""
}
return "files." + strings.ToLower(s.cfg.Customer.Domain)
}
// filesSessionKey is what files.<domain> ever sees of a dashboard session: a value derived from its fingerprint for
// this purpose and host (R-976's shape).
func (s *Server) filesSessionKey(fp string) string {
return s.gateMAC("filessess", s.filesHost(), fp)
}
// filesSessionAlive reports whether a live dashboard session has the key sk.
func (s *Server) filesSessionAlive(sk string) bool {
if sk == "" {
return false
}
now := time.Now()
s.sessionsMu.RLock()
defer s.sessionsMu.RUnlock()
for fp, se := range s.sessions {
if se != nil && now.Before(se.expiresAt) && hmac.Equal([]byte(s.filesSessionKey(fp)), []byte(sk)) {
return true
}
}
return false
}
type filesToken struct {
Host string `json:"h"`
SK string `json:"s"`
Exp int64 `json:"e"`
Nonce string `json:"n"`
RD string `json:"r"`
MAC string `json:"m"`
}
func (s *Server) mintFilesToken(sk, rd string) string {
nb := make([]byte, 12)
_, _ = rand.Read(nb)
t := filesToken{Host: s.filesHost(), SK: sk, Exp: s.gateNow().Add(gateTokenLife).Unix(), Nonce: hex.EncodeToString(nb), RD: rd}
t.MAC = s.gateMAC("filestoken", t.Host, t.SK, strconv.FormatInt(t.Exp, 10), t.Nonce, t.RD)
b, _ := json.Marshal(t)
return base64.RawURLEncoding.EncodeToString(b)
}
// takeFilesToken checks a pass for host, uses it up, and returns the session key and where the browser was going.
// A setup-gate or family token cannot pass: its MAC is over another kind.
func (s *Server) takeFilesToken(raw, host string) (sk, rd string, err error) {
b, err := base64.RawURLEncoding.DecodeString(raw)
if err != nil {
return "", "", errors.New("malformed")
}
var t filesToken
if json.Unmarshal(b, &t) != nil {
return "", "", errors.New("malformed")
}
if !hmac.Equal([]byte(t.MAC), []byte(s.gateMAC("filestoken", t.Host, t.SK, strconv.FormatInt(t.Exp, 10), t.Nonce, t.RD))) {
return "", "", errors.New("bad signature")
}
if t.Host != host {
return "", "", errors.New("for another host")
}
now := s.gateNow()
if now.Unix() > t.Exp {
return "", "", errors.New("expired")
}
s.gateKey()
s.gate.mu.Lock()
defer s.gate.mu.Unlock()
for n, until := range s.gate.used {
if now.After(until) {
delete(s.gate.used, n)
}
}
if _, seen := s.gate.used["fs:"+t.Nonce]; seen {
return "", "", errors.New("already used")
}
s.gate.used["fs:"+t.Nonce] = time.Unix(t.Exp, 0).Add(time.Second)
return t.SK, t.RD, nil
}
// filesCookieKey: "<session key>.<unix expiry>.<mac over host+key+expiry>" → the session key, if genuine for this host,
// unexpired, and its dashboard session still alive.
func (s *Server) filesCookieKey(r *http.Request) (present, ok bool) {
c, err := r.Cookie(filesCookieName)
if err != nil || c.Value == "" {
return false, false
}
parts := strings.Split(c.Value, ".")
if len(parts) != 3 {
return true, false
}
n, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil || s.gateNow().Unix() > n {
return true, false
}
if !hmac.Equal([]byte(parts[2]), []byte(s.gateMAC("filescookie", s.filesHost(), parts[0], parts[1]))) {
return true, false
}
return true, s.filesSessionAlive(parts[0])
}
// safeFilesPath accepts a FileBrowser path to go to after the pass: "/" or under /files/ — never another host.
func safeFilesPath(p string) string {
if p == "" || p == "/" {
return "/"
}
if !strings.HasPrefix(p, "/files/") || strings.HasPrefix(p, "//") || strings.ContainsAny(p, "\\\r\n") {
return "/"
}
return p
}
// filesPathOf is the FileBrowser path of an absolute files.<domain> link (fileBrowserLink's output).
func filesPathOf(link, host string) string {
u, err := url.Parse(link)
if err != nil || !strings.EqualFold(u.Host, host) {
return "/"
}
p := u.EscapedPath()
if u.RawQuery != "" {
p += "?" + u.RawQuery
}
return safeFilesPath(p)
}
func (s *Server) clearFilesCookies(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{Name: filesCookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode})
http.SetCookie(w, &http.Cookie{Name: fileBrowserJWTCookie, Value: "", Path: "/", Domain: s.filesHost(), MaxAge: -1, HttpOnly: true, Secure: true})
}
func filesRefuse(w http.ResponseWriter, code int) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(code)
_, _ = w.Write([]byte(`{"error":"sign in on the dashboard"}`))
}
// ServeFilesOpen is GET /__files/open?to=<FileBrowser path> on the dashboard host — where „Fájlok ↗" goes.
func (s *Server) ServeFilesOpen(w http.ResponseWriter, r *http.Request) {
to := safeFilesPath(r.URL.Query().Get("to"))
host := s.filesHost()
w.Header().Set("Cache-Control", "no-store")
if host == "" || r.Method != http.MethodGet {
http.Redirect(w, r, "/", http.StatusFound)
return
}
c, err := r.Cookie(sessionCookieName)
if err != nil || !s.isValidSession(c.Value) {
http.Redirect(w, r, "/login?"+url.Values{"next": {filesOpenPath + "?" + url.Values{"to": {to}}.Encode()}}.Encode(), http.StatusFound)
return
}
if s.filesHeader() == "" { // no header sign-in on this box: the plain link, FileBrowser asks for its password
http.Redirect(w, r, "https://"+host+to, http.StatusFound)
return
}
tok := s.mintFilesToken(s.filesSessionKey(sessionFingerprint(c.Value)), to)
http.Redirect(w, r, "https://"+host+filesCallbackURI+"?"+url.Values{"t": {tok}}.Encode(), http.StatusFound)
}
// ServeFilesAuth is traefik's forwardAuth answer for files.<domain>. It trusts only X-Forwarded-Host/-Uri/-Method,
// which traefik writes from the request it forwards.
func (s *Server) ServeFilesAuth(w http.ResponseWriter, r *http.Request) {
host := strings.ToLower(r.Header.Get("X-Forwarded-Host"))
if i := strings.LastIndex(host, ":"); i != -1 {
host = host[:i]
}
if host == "" || host != s.filesHost() {
s.logger.Printf("[WARN] [web] files sign-in: asked about %q, which is not the file manager — refused", host)
filesRefuse(w, http.StatusForbidden)
return
}
header := s.filesHeader()
if header == "" {
w.WriteHeader(http.StatusOK) // no header sign-in on this box; traefik's route has no middleware then anyway
return
}
uri := r.Header.Get("X-Forwarded-Uri")
if uri == "" {
uri = "/"
}
method := r.Header.Get("X-Forwarded-Method")
browser := (method == "" || method == http.MethodGet) && strings.Contains(r.Header.Get("Accept"), "text/html")
back := "https://felhom." + s.cfg.Customer.Domain + filesOpenPath + "?" + url.Values{"to": {safeFilesPath(uri)}}.Encode()
if u, err := url.Parse(uri); err == nil && u.Path == filesCallbackURI {
sk, rd, err := s.takeFilesToken(u.Query().Get("t"), host)
if err == nil && !s.filesSessionAlive(sk) {
err = errors.New("the session ended")
}
if err != nil {
s.logger.Printf("[WARN] [web] files sign-in: a pass was refused (%v) — visitor %s", err, clientIP(r))
if browser {
w.Header().Set("Cache-Control", "no-store")
http.Redirect(w, r, "https://felhom."+s.cfg.Customer.Domain+filesOpenPath+"?to=%2F", http.StatusFound)
return
}
filesRefuse(w, http.StatusForbidden)
return
}
exp := strconv.FormatInt(s.gateNow().Add(sessionMaxAge).Unix(), 10)
http.SetCookie(w, &http.Cookie{
Name: filesCookieName, Value: sk + "." + exp + "." + s.gateMAC("filescookie", host, sk, exp), Path: "/",
MaxAge: int(sessionMaxAge.Seconds()), HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode,
})
// FileBrowser's own token of an earlier sign-in (admin's, or an old header user's) would win over the header.
http.SetCookie(w, &http.Cookie{Name: fileBrowserJWTCookie, Value: "", Path: "/", Domain: host, MaxAge: -1, HttpOnly: true, Secure: true})
s.logger.Printf("[INFO] [web] files sign-in: a signed-in household browser passed — visitor %s", clientIP(r))
w.Header().Set("Cache-Control", "no-store")
http.Redirect(w, r, safeFilesPath(rd), http.StatusFound)
return
}
present, ok := s.filesCookieKey(r)
switch {
case ok:
w.Header().Set(header, infra.FileBrowserSSOUser)
w.WriteHeader(http.StatusOK)
case !present:
w.WriteHeader(http.StatusOK) // FileBrowser's own sign-in (traefik has removed any client copy of the header)
default:
s.clearFilesCookies(w)
if browser {
w.Header().Set("Cache-Control", "no-store")
http.Redirect(w, r, back, http.StatusFound)
return
}
filesRefuse(w, http.StatusUnauthorized)
}
}
+342
View File
@@ -0,0 +1,342 @@
package web
import (
"io"
"log"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/infra"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// R-1100 — one sign-in for the file manager. Driven through CatchAllMiddleware, the entry traefik's forwardAuth and the
// browser both reach: GET /__files/open on the dashboard host, and GET /__felhom_gate/files with traefik's
// X-Forwarded-* for files.<domain>. Each refusal below was seen to fail with its guard removed (COMPANION RED-PROOFs).
func filesHarness(t *testing.T, domain, dataDir string) *Server {
t.Helper()
cfg := config.Default()
cfg.Customer.Domain = domain
cfg.Paths.DataDir = dataDir
return &Server{cfg: cfg, logger: log.New(io.Discard, "", 0), sessions: map[string]*session{}}
}
func newFilesServer(t *testing.T) *Server {
t.Helper()
d := filepath.Join(t.TempDir(), "data")
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
return filesHarness(t, "example.hu", d)
}
func (s *Server) filesEntry(r *http.Request) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
s.CatchAllMiddleware(http.NotFoundHandler()).ServeHTTP(w, r)
return w
}
// filesOpen is the household's „Fájlok ↗" press: GET /__files/open on the dashboard host.
func filesOpen(s *Server, to string, cookies ...*http.Cookie) *httptest.ResponseRecorder {
r := httptest.NewRequest(http.MethodGet, "https://felhom."+s.cfg.Customer.Domain+filesOpenPath+"?"+url.Values{"to": {to}}.Encode(), nil)
for _, c := range cookies {
r.AddCookie(c)
}
return s.filesEntry(r)
}
// filesAsk is traefik's forwardAuth question for a request to host+uri.
func filesAsk(s *Server, host, uri, accept string, cookies ...*http.Cookie) *httptest.ResponseRecorder {
r := httptest.NewRequest(http.MethodGet, "http://felhom-controller:8080"+filesAuthPath, nil)
r.Header.Set("X-Forwarded-Host", host)
r.Header.Set("X-Forwarded-Method", http.MethodGet)
r.Header.Set("X-Forwarded-Uri", uri)
r.Header.Set("Accept", accept)
for _, c := range cookies {
r.AddCookie(c)
}
return s.filesEntry(r)
}
const htmlAccept = "text/html,application/xhtml+xml"
func sessionCookie(s *Server) *http.Cookie {
return &http.Cookie{Name: sessionCookieName, Value: s.createSession()}
}
// passFrom returns the /_felhom/sso URI a /__files/open answer sends the browser to ("" = none).
func passFrom(t *testing.T, w *httptest.ResponseRecorder, host string) string {
t.Helper()
loc, err := url.Parse(w.Header().Get("Location"))
if err != nil || w.Code != http.StatusFound || loc.Host != host || loc.Path != filesCallbackURI || loc.Query().Get("t") == "" {
return ""
}
return loc.RequestURI()
}
func filesCookieIn(w *httptest.ResponseRecorder, name string) *http.Cookie {
for _, c := range w.Result().Cookies() {
if c.Name == name {
return c
}
}
return nil
}
// signIn walks the whole household path and returns the files cookie.
func signIn(t *testing.T, s *Server, sess *http.Cookie, to string) *http.Cookie {
t.Helper()
pass := passFrom(t, filesOpen(s, to, sess), "files.example.hu")
if pass == "" {
t.Fatal("the dashboard minted no pass for a signed-in household")
}
w := filesAsk(s, "files.example.hu", pass, htmlAccept)
fc := filesCookieIn(w, filesCookieName)
if w.Code != http.StatusFound || fc == nil || fc.Value == "" {
t.Fatalf("the pass was not swapped for the files cookie: %d %v", w.Code, w.Header())
}
return fc
}
// The household's path: „Fájlok ↗" → the pass → the files cookie → FileBrowser gets the header naming the household's
// user. The cookie is host-only (no Domain), HttpOnly, Secure, Lax; the browser lands on the folder it asked for.
func TestFilesSSO_R1100_HouseholdOpensTheFolderWithoutALogin(t *testing.T) {
s := newFilesServer(t)
header := s.filesHeader()
if !infra.ValidFileBrowserSSOHeader(header) {
t.Fatalf("no secret header name: %q", header)
}
sess := sessionCookie(s)
const folder = "/files/HDD%202/media"
pass := passFrom(t, filesOpen(s, folder, sess), "files.example.hu")
if pass == "" {
t.Fatal("no pass minted")
}
w := filesAsk(s, "files.example.hu", pass, htmlAccept)
fc := filesCookieIn(w, filesCookieName)
if w.Code != http.StatusFound || w.Header().Get("Location") != folder {
t.Fatalf("the pass answer: %d → %q, want 302 → %s", w.Code, w.Header().Get("Location"), folder)
}
if fc == nil || fc.Domain != "" || !fc.HttpOnly || !fc.Secure || fc.SameSite != http.SameSiteLaxMode || fc.Path != "/" {
t.Fatalf("the files cookie must be host-only, HttpOnly, Secure, Lax: %+v", fc)
}
if jwt := filesCookieIn(w, fileBrowserJWTCookie); jwt == nil || jwt.MaxAge >= 0 || jwt.Domain != "files.example.hu" {
t.Fatalf("FileBrowser's own earlier token must be cleared for files.<domain>: %+v", jwt)
}
ok := filesAsk(s, "files.example.hu", folder, htmlAccept, fc)
if ok.Code != http.StatusOK || ok.Header().Get(header) != infra.FileBrowserSSOUser {
t.Fatalf("with the files cookie: %d, header %q — want 200 naming %s", ok.Code, ok.Header().Get(header), infra.FileBrowserSSOUser)
}
// No files cookie: FileBrowser's own sign-in (password) — 200 WITHOUT the header.
none := filesAsk(s, "files.example.hu", folder, htmlAccept)
if none.Code != http.StatusOK || none.Header().Get(header) != "" {
t.Fatalf("without the files cookie: %d header %q — want 200 and no header", none.Code, none.Header().Get(header))
}
}
// COMPANION RED-PROOF (observed 2026-10-19): drop the `s.gate.used["fs:"+…]` check in takeFilesToken → this fails with
// "a replayed pass set the files cookie again". Restored.
func TestFilesSSO_R1100_ReplayedPassRefused(t *testing.T) {
s := newFilesServer(t)
pass := passFrom(t, filesOpen(s, "/", sessionCookie(s)), "files.example.hu")
if w := filesAsk(s, "files.example.hu", pass, "application/json"); filesCookieIn(w, filesCookieName) == nil {
t.Fatal("the first use must pass")
}
w := filesAsk(s, "files.example.hu", pass, "application/json")
if c := filesCookieIn(w, filesCookieName); c != nil && c.Value != "" {
t.Fatal("a replayed pass set the files cookie again")
}
if w.Code != http.StatusForbidden {
t.Fatalf("a replayed pass: %d, want 403", w.Code)
}
}
// COMPANION RED-PROOF (observed 2026-10-19): drop the `now.Unix() > t.Exp` check in takeFilesToken → this fails with
// "a pass 61 s old set the files cookie". Restored.
func TestFilesSSO_R1100_ExpiredPassRefused(t *testing.T) {
s := newFilesServer(t)
now := time.Now()
s.gateClock = func() time.Time { return now }
pass := passFrom(t, filesOpen(s, "/", sessionCookie(s)), "files.example.hu")
now = now.Add(gateTokenLife + time.Second)
w := filesAsk(s, "files.example.hu", pass, "application/json")
if c := filesCookieIn(w, filesCookieName); c != nil && c.Value != "" {
t.Fatal("a pass 61 s old set the files cookie")
}
if w.Code != http.StatusForbidden {
t.Fatalf("an expired pass: %d, want 403", w.Code)
}
}
// A pass minted for another host (another box's files.<domain> under the same key; a setup-gate pass for this host) is
// refused, and the answer is refused outright for any host but files.<domain>.
//
// Two guards bind a pass to its host: takeFilesToken's `t.Host != host`, and the session key, which is derived for one
// host (filesSessionKey). COMPANION RED-PROOF (observed 2026-10-19): with either one alone removed this still passes;
// with both removed it fails with "a pass for files.other.hu passed on files.example.hu: 302". Drop the
// X-Forwarded-Host check in ServeFilesAuth → it fails with "the answer named the household on felhom.example.hu: 200".
// Restored.
func TestFilesSSO_R1100_PassForAnotherHostRefused(t *testing.T) {
s := newFilesServer(t)
sess := sessionCookie(s)
// Another domain on the same data dir = the same gate key, so only the host binding can refuse it.
other := filesHarness(t, "other.hu", s.cfg.Paths.DataDir)
other.sessions = s.sessions
foreign := passFrom(t, filesOpen(other, "/", sess), "files.other.hu")
if foreign == "" {
t.Fatal("setup: no pass from the other host")
}
w := filesAsk(s, "files.example.hu", foreign, "application/json")
if c := filesCookieIn(w, filesCookieName); (c != nil && c.Value != "") || w.Code != http.StatusForbidden {
t.Fatalf("a pass for files.other.hu passed on files.example.hu: %d", w.Code)
}
gate := filesCallbackURI + "?" + url.Values{"t": {s.mintGateToken("files.example.hu", "/")}}.Encode()
if w := filesAsk(s, "files.example.hu", gate, "application/json"); w.Code != http.StatusForbidden || filesCookieIn(w, filesCookieName) != nil {
t.Fatalf("a setup-gate pass opened the file manager: %d", w.Code)
}
fc := signIn(t, s, sess, "/")
for _, h := range []string{"felhom.example.hu", "app.example.hu", ""} {
w := filesAsk(s, h, "/", htmlAccept, fc)
if w.Header().Get(s.filesHeader()) != "" || w.Code != http.StatusForbidden {
t.Fatalf("the answer named the household on %s: %d", h, w.Code)
}
}
}
// No dashboard session → no pass: the dashboard's sign-in, then back to /__files/open.
//
// COMPANION RED-PROOF (observed 2026-10-19): drop `!s.isValidSession(c.Value)` from ServeFilesOpen's check → this fails
// with "a browser with no session got a pass" (the invalid-cookie case). Restored.
func TestFilesSSO_R1100_NoSessionNoPass(t *testing.T) {
s := newFilesServer(t)
for _, cs := range [][]*http.Cookie{nil, {{Name: sessionCookieName, Value: "not-a-session"}}} {
w := filesOpen(s, "/files/HDD/x", cs...)
if passFrom(t, w, "files.example.hu") != "" {
t.Fatal("a browser with no session got a pass")
}
loc := w.Header().Get("Location")
if w.Code != http.StatusFound || !strings.HasPrefix(loc, "/login?next=") || !strings.Contains(loc, url.QueryEscape(filesOpenPath)) {
t.Fatalf("no session: %d → %q, want the sign-in with next=/__files/open", w.Code, loc)
}
}
// A `to` that is not a FileBrowser path never leaves files.<domain>.
if got := safeFilesPath("https://evil.example/x"); got != "/" {
t.Fatalf("safeFilesPath let %q through", got)
}
for _, bad := range []string{"//evil.example/files/x", "/files/\\evil", "/files/a\r\nSet-Cookie: x"} {
if got := safeFilesPath(bad); got != "/" {
t.Fatalf("safeFilesPath(%q) = %q", bad, got)
}
}
}
// Signed out on the dashboard → the files cookie stops at the next request: both cookies cleared, the browser sent to
// the dashboard, which asks for the sign-in again. The same after the dashboard password changes (the gate key rotates).
//
// COMPANION RED-PROOF (observed 2026-10-19): make filesSessionAlive return true for any key → this fails with "after the
// sign-out the files cookie still named the household". Restored.
func TestFilesSSO_R1100_SignedOutAsksAgain(t *testing.T) {
s := newFilesServer(t)
sess := sessionCookie(s)
fc := signIn(t, s, sess, "/")
header := s.filesHeader()
s.sessionsMu.Lock()
delete(s.sessions, sessionFingerprint(sess.Value)) // what handleLogout does
s.sessionsMu.Unlock()
w := filesAsk(s, "files.example.hu", "/files/HDD/x", htmlAccept, fc)
if w.Header().Get(header) != "" {
t.Fatal("after the sign-out the files cookie still named the household")
}
if loc := w.Header().Get("Location"); w.Code != http.StatusFound || !strings.HasPrefix(loc, "https://felhom.example.hu"+filesOpenPath) {
t.Fatalf("after the sign-out: %d → %q, want 302 to the dashboard", w.Code, loc)
}
for _, n := range []string{filesCookieName, fileBrowserJWTCookie} {
if c := filesCookieIn(w, n); c == nil || c.MaxAge >= 0 {
t.Fatalf("%s was not cleared after the sign-out", n)
}
}
if api := filesAsk(s, "files.example.hu", "/api/resources", "application/json", fc); api.Code != http.StatusUnauthorized || api.Header().Get(header) != "" {
t.Fatalf("an API call with the stale cookie: %d", api.Code)
}
if passFrom(t, filesOpen(s, "/", sess), "files.example.hu") != "" {
t.Fatal("the dashboard minted a pass for the signed-out session")
}
// The dashboard password changes → every gate pass and files cookie stops (rotateGateKey).
sess2 := sessionCookie(s)
fc2 := signIn(t, s, sess2, "/")
s.rotateGateKey()
if w := filesAsk(s, "files.example.hu", "/", htmlAccept, fc2); w.Header().Get(header) != "" {
t.Fatal("a files cookie signed with the old gate key still named the household")
}
}
// The header name is the box's secret: X-Felhom-User-<32 hex>, made once, 0600, the same on every read, different on
// another box; a damaged file is an error (never a silent second name that splits the config from the route).
func TestFileBrowserSSOHeader_R1100_ABoxSecret(t *testing.T) {
d1, d2 := t.TempDir(), t.TempDir()
h1, err := infra.LoadOrCreateFileBrowserSSOHeader(d1)
if err != nil || !infra.ValidFileBrowserSSOHeader(h1) || h1 == "X-Felhom-User" {
t.Fatalf("first read: %q %v", h1, err)
}
if again, _ := infra.LoadOrCreateFileBrowserSSOHeader(d1); again != h1 {
t.Fatalf("a second read made another name: %q vs %q", again, h1)
}
if h2, _ := infra.LoadOrCreateFileBrowserSSOHeader(d2); h2 == h1 {
t.Fatal("two boxes got the same header name")
}
st, err := os.Stat(filepath.Join(d1, "filebrowser-sso-header"))
if err != nil || st.Mode().Perm() != 0o600 {
t.Fatalf("the name file must be 0600: %v %v", st, err)
}
if err := os.WriteFile(filepath.Join(d2, "filebrowser-sso-header"), []byte("X-Felhom-User\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := infra.LoadOrCreateFileBrowserSSOHeader(d2); err == nil {
t.Fatal("a damaged name file must be an error")
}
// Two writers at once (the stacks manager and the web server) get one name.
d3 := t.TempDir()
got := make(chan string, 8)
for i := 0; i < 8; i++ {
go func() { h, _ := infra.LoadOrCreateFileBrowserSSOHeader(d3); got <- h }()
}
first := <-got
for i := 1; i < 8; i++ {
if h := <-got; h != first || h == "" {
t.Fatalf("parallel writers got different names: %q vs %q", h, first)
}
}
}
// The data tab's „FileBrowser asks for its own password the first time" note is said only on a box without the one
// sign-in — with it the folder links open FileBrowser signed in, and the note would be false.
//
// COMPANION RED-PROOF (observed 2026-10-19): drop `{{if not .FilesSSO}}` from app_data.html → this fails with "the data
// tab still says FileBrowser asks for its own password". Restored.
func TestFilesSSO_R1100_DataTabLoginNote(t *testing.T) {
s := lfHarness(t, settings.FileBrowserAdminGenerated, lfDriveA, lfDriveB)
if err := os.MkdirAll(s.cfg.Paths.DataDir, 0o755); err != nil {
t.Fatal(err)
}
if _, err := infra.LoadOrCreateFileBrowserSSOHeader(s.cfg.Paths.DataDir); err != nil {
t.Fatal(err)
}
s.filesSSO.header = ""
if _, data, _ := lfGet(t, s, "/apps/plex/data"); strings.Contains(data, `data-fb-login-note="1"`) {
t.Fatal("the data tab still says FileBrowser asks for its own password")
}
s.filesSSO.header = ""
s.cfg.Paths.DataDir = filepath.Join(t.TempDir(), "missing") // no name file can be made: no one sign-in
if _, data, _ := lfGet(t, s, "/apps/plex/data"); !strings.Contains(data, `data-fb-login-note="1"`) {
t.Fatal("without the one sign-in the note must stay")
}
}
+1 -1
View File
@@ -3807,7 +3807,7 @@ func (s *Server) syncFileBrowserMounts(resetDBOnChange bool) {
}
configPath := stackDir + "/config.yaml"
fbConfig := infra.RenderFileBrowserConfigKept(configPaths, importSource, keptLabel)
fbConfig := infra.RenderFileBrowserConfigKept(configPaths, importSource, keptLabel, s.fileBrowserSSO())
// Capture the current on-disk content BEFORE any writes, so we can detect whether this sync
// actually changes anything (F2). The integrations' ReapplyConfigForTarget edits config.yaml
+1 -1
View File
@@ -120,7 +120,7 @@ func (s *Server) keptPageHandler(w http.ResponseWriter, r *http.Request) {
DisplayName: it.DisplayName, App: it.App, Path: it.Path, Drive: it.Drive,
Date: fmtLocalTime(lang, it.Date),
Size: i18n.LocalizeDecimals(lang, appbackup.HumanizeBytes(it.SizeBytes)), Backup: backupName, CanLoad: can && it.App != "",
LookURL: fileBrowserLink(s.cfg.Customer.Domain, s.msgLang(s.boxLang(), "kept.fb_source"), KeptViewName(it)),
LookURL: fileBrowserOpenLink(s.cfg.Customer.Domain, s.msgLang(s.boxLang(), "kept.fb_source"), KeptViewName(it)),
}
row.DeleteText = s.msgLang(lang, "kept.delete.confirm", it.DisplayName, row.Size)
row.TypePrompt = s.msgLang(lang, "kept.delete.type", it.DisplayName)
+60 -19
View File
@@ -2,10 +2,12 @@ package web
import (
"bytes"
"html"
"io"
"log"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"regexp"
@@ -181,6 +183,34 @@ func lfGet(t *testing.T, s *Server, path string) (int, string, string) {
var lfAnswerRe = regexp.MustCompile(`data-login-answer="([a-z_]+)"`)
var lfFilesRe = regexp.MustCompile(`href="([^"]+)"[^>]*data-app-files="1"`)
// lfOpenTo is the FileBrowser path a rendered „Fájlok" href opens since R-1100: the href is the dashboard's
// /__files/open, the folder rides `to`. "" = not such a link.
func lfOpenTo(href string) string {
u, err := url.Parse(html.UnescapeString(href))
if err != nil || u.Host != "felhom.example.hu" || u.Path != "/__files/open" {
return ""
}
return u.Query().Get("to")
}
// lfOpenHrefs are the FileBrowser paths of every dashboard pass link on a page.
func lfOpenHrefs(page string) []string {
var out []string
for _, m := range regexp.MustCompile(`href="(https://felhom\.example\.hu/__files/open\?[^"]+)"`).FindAllStringSubmatch(page, -1) {
out = append(out, lfOpenTo(m[1]))
}
return out
}
func lfHasOpen(page, path string) bool {
for _, p := range lfOpenHrefs(page) {
if p == path {
return true
}
}
return false
}
// ── Part 1: FileBrowser is part of the box ─────────────────────────────────────────────────────────────────────────
// RED-PROOF (2026-10-16, REPORT): with appPageHandler reading `installed := found.Deployed` again (the v0.315.0
@@ -277,32 +307,38 @@ func TestAppPage_EveryInstalledAppHasOneLoginAnswer(t *testing.T) {
// calibre-web — the button opened the import drop-zone, the first declared folder.
func TestAppPage_FilesButtonTarget(t *testing.T) {
s := lfHarness(t, settings.FileBrowserAdminGenerated, lfDriveA, lfDriveB)
const srcA = "https://files.example.hu/files/M%C3%A9dia%20&amp;%20k%C3%B6nyvt%C3%A1r"
// R-1100: the button goes through the dashboard's pass (/__files/open); the FileBrowser path rides `to`.
const srcA = "/files/M%C3%A9dia%20&%20k%C3%B6nyvt%C3%A1r"
for _, c := range []struct{ slug, want string }{
{"romm", srcA + "/roms"},
{"calibre-web", srcA + "/media/books"},
{"plex", srcA + "/media"}, // the first drive's media folder
{"immich", ""},
} {
_, html, _ := lfGet(t, s, "/apps/"+c.slug)
m := lfFilesRe.FindStringSubmatch(html)
_, page, _ := lfGet(t, s, "/apps/"+c.slug)
m := lfFilesRe.FindStringSubmatch(page)
switch {
case c.want == "" && m != nil:
t.Errorf("%s: a Files button (%s) for an app with no folder", c.slug, m[1])
case c.want != "" && m == nil:
t.Errorf("%s: no Files button, want %s", c.slug, c.want)
case c.want != "" && m[1] != c.want:
t.Errorf("%s: Files → %s, want %s", c.slug, m[1], c.want)
case c.want != "" && lfOpenTo(m[1]) != c.want:
t.Errorf("%s: Files → %s (to=%q), want to=%s", c.slug, m[1], lfOpenTo(m[1]), c.want)
}
if strings.Contains(html, "M%C3%A9dia+") || strings.Contains(html, "%26") {
t.Errorf("%s: a link built with QueryEscape (+ for a space, %%26 for &)", c.slug)
for _, p := range lfOpenHrefs(page) {
if strings.Contains(p, "M%C3%A9dia+") || strings.Contains(p, "%26") {
t.Errorf("%s: a FileBrowser path built with QueryEscape (+ for a space, %%26 for &): %s", c.slug, p)
}
}
}
// The data tab lists each drive's media folders for Plex, and points at the file manager's login.
_, data, _ := lfGet(t, s, "/apps/plex/data")
for _, want := range []string{srcA + "/media/movies", srcA + "/media/tv",
"https://files.example.hu/files/HDD%202/media/movies", "https://files.example.hu/files/HDD%202/media/tv",
`data-fb-login-note="1"`, `href="/apps/filebrowser"`} {
for _, want := range []string{srcA + "/media/movies", srcA + "/media/tv", "/files/HDD%202/media/movies", "/files/HDD%202/media/tv"} {
if !lfHasOpen(data, want) {
t.Errorf("plex data tab lacks a link to %s (has %v)", want, lfOpenHrefs(data))
}
}
for _, want := range []string{`data-fb-login-note="1"`, `href="/apps/filebrowser"`} {
if !strings.Contains(data, want) {
t.Errorf("plex data tab lacks %s", want)
}
@@ -313,25 +349,30 @@ func TestAppPage_FilesButtonTarget(t *testing.T) {
// „romm links a folder on a drive that is not on the box".
func TestAppPage_UnknownSourceIsTextNotLink(t *testing.T) {
s := lfHarness(t, settings.FileBrowserAdminGenerated, lfDriveB) // drive A (romm's, plex's first) is away
_, html, _ := lfGet(t, s, "/apps/romm/data")
if strings.Contains(html, "/roms") && strings.Contains(html, "files.example.hu/files/M%C3%A9dia") {
t.Fatal("romm links a folder on a drive that is not on the box")
_, page, _ := lfGet(t, s, "/apps/romm/data")
for _, p := range lfOpenHrefs(page) {
if strings.HasPrefix(p, "/files/M%C3%A9dia") {
t.Fatal("romm links a folder on a drive that is not on the box")
}
}
if !strings.Contains(html, `data-folder-unavailable="1"`) {
if !strings.Contains(page, `data-folder-unavailable="1"`) {
t.Fatal("romm's folder is not shown as text when its source is absent")
}
if _, page, _ := lfGet(t, s, "/apps/romm"); lfFilesRe.MatchString(page) {
if _, p, _ := lfGet(t, s, "/apps/romm"); lfFilesRe.MatchString(p) {
t.Error("romm has a Files button although no folder of it is on the box")
}
// Plex: drive A text, drive B links; the button goes to the first PRESENT drive.
_, plex, _ := lfGet(t, s, "/apps/plex/data")
if !strings.Contains(plex, `data-folder-unavailable="1"`) || !strings.Contains(plex, "HDD%202/media/movies") {
if !strings.Contains(plex, `data-folder-unavailable="1"`) || !lfHasOpen(plex, "/files/HDD%202/media/movies") {
t.Error("plex: absent drive not text, or present drive not linked")
}
if strings.Contains(plex, "files.example.hu/files/M%C3%A9dia") {
t.Error("plex links the absent drive")
for _, p := range lfOpenHrefs(plex) {
if strings.HasPrefix(p, "/files/M%C3%A9dia") {
t.Error("plex links the absent drive")
}
}
if _, page, _ := lfGet(t, s, "/apps/plex"); !strings.Contains(page, `href="https://files.example.hu/files/HDD%202/media"`) {
_, pp, _ := lfGet(t, s, "/apps/plex")
if m := lfFilesRe.FindStringSubmatch(pp); m == nil || lfOpenTo(m[1]) != "/files/HDD%202/media" {
t.Error("plex Files button does not go to the first present drive")
}
}
+8
View File
@@ -62,6 +62,7 @@ type Server struct {
fam familyState // v0.287.0: the family gate (family_gate.go)
familyStoreOverride *family.Store // test seam: the family list without a data dir
gateClock func() time.Time
filesSSO filesSSOState // R-1100: the file manager's header name, read once
tmpl *template.Template // the Hungarian set (i18n.Default) — every pre-i18n caller renders this
tmplByLang map[string]*template.Template
i18n *i18n.Bundle
@@ -983,6 +984,10 @@ func (s *Server) CatchAllMiddleware(next http.Handler) http.Handler {
s.ServeFamilyGateAuth(w, r)
return
}
if r.URL.Path == filesAuthPath { // R-1100: the file manager's forwardAuth, any host (it checks X-Forwarded-Host)
s.ServeFilesAuth(w, r)
return
}
// R-983 (v0.324.0): no page of ours may be shown inside another page's frame. Every app on *.<domain> is
// SAME-SITE with the dashboard, so the Lax session cookie goes along into a frame an app page draws — a page
// on any app host could lay the signed-in dashboard under its own buttons. Nothing frames the dashboard
@@ -1003,6 +1008,9 @@ func (s *Server) CatchAllMiddleware(next http.Handler) http.Handler {
case familyLogoutPath:
s.ServeFamilyLogout(w, r)
return
case filesOpenPath: // R-1100: „Fájlok ↗" — the pass to the file manager (it checks the session itself)
s.ServeFilesOpen(w, r)
return
}
}
if r.URL.Path == signupClosedPath { // v0.281.0 (decision 47): an app's own sign-up address while closed
@@ -33,8 +33,9 @@
</div>
{{end}}
</div>
{{/* v0.320.0: the file manager asks for its own login the first time — say so, and where it is. */}}
<p class="form-hint datapath-fb-note" data-fb-login-note="1">{{T "app_page.files_login_note"}} <a href="/apps/filebrowser" class="app-info-link">{{T "app_page.files_login_link"}}</a></p>
{{/* v0.320.0: the file manager asks for its own login the first time — say so, and where it is. R-1100: not with
the one sign-in (the links open it signed in). */}}
{{if not .FilesSSO}}<p class="form-hint datapath-fb-note" data-fb-login-note="1">{{T "app_page.files_login_note"}} <a href="/apps/filebrowser" class="app-info-link">{{T "app_page.files_login_link"}}</a></p>{{end}}
</div>
{{end}}