343 lines
15 KiB
Go
343 lines
15 KiB
Go
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")
|
|
}
|
|
}
|