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= on the DASHBOARD host. With the household's session it // mints a pass — 60 s, one use, bound to files. and to that session — and sends the browser to // https://files./_felhom/sso?t=. Without a session: the dashboard's sign-in, then back here. // - Every request to files. first asks GET /__felhom_gate/files (traefik forwardAuth). /_felhom/sso swaps the // pass for the files cookie (`felhom_files`, host-only on files., 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. (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. 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: ".." → 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, and never // a dot-segment that a browser would resolve out of /files/ (onto FileBrowser's API, say). func safeFilesPath(p string) string { if p == "" || p == "/" { return "/" } if !strings.HasPrefix(p, "/files/") || strings.HasPrefix(p, "//") || strings.ContainsAny(p, "\\\r\n") { return "/" } path, _, _ := strings.Cut(p, "?") dec, err := url.PathUnescape(path) if err != nil { return "/" } for _, seg := range strings.Split(dec, "/") { if seg == ".." || seg == "." { return "/" } } return p } // filesPathOf is the FileBrowser path of an absolute files. 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= 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 } // A pass is minted only for the household's own press (the dashboard page: same-origin) or a typed or bookmarked // address (none) — never for a request another site started. A browser that sends no Sec-Fetch-Site (an old one) is // let through: the pass opens only the household's own files on this box. Pinned by TestFilesSSO_R1100_CrossSiteNoPass. if sfs := r.Header.Get("Sec-Fetch-Site"); sfs == "cross-site" { s.logger.Printf("[WARN] [web] files sign-in: a pass asked for from another site — refused, visitor %s", clientIP(r)) 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.. 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") // ABSOLUTE, like the setup gate's: traefik makes a relative Location in a forwardAuth answer absolute against // the AUTH server's address — measured on 9201, "/files/" became http://felhom-controller:8080/files/. // Pinned by TestFilesSSO_R1100_HouseholdOpensTheFolderWithoutALogin. http.Redirect(w, r, "https://"+host+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) } }