diff --git a/CHANGELOG.md b/CHANGELOG.md index 037c695..a5c7a87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,31 @@ +## v0.333.0 — the file manager's own pass ends with the dashboard sign-out (R-1155); the recovery-code page says the code becomes live after the check (R-1161) (2026-10-19) + +**MinAgent: 0.158.1** — the floor's, carried. Neither change needs a new agent (R-1161's two texts are chosen by the +claim answer the agent already gives). No golden (`09` §3 decision 250). Task: the second day brief of 2026-10-19. + +- **FileBrowser's own token no longer outlives the dashboard session (R-1155).** Measured on demo-hp before the fix: the + one-sign-in user's FileBrowser token (HS256 JWT; claims `belongsTo`, `Permissions{admin,…}`, `iat`, `exp` 2 h, `iss` + — no user name) still opened `/api/users?id=self` (200) after the dashboard sign-out, as a cookie and as a Bearer + header. The forwardAuth answer (`ServeFilesAuth`) now refuses a request that has NO valid files cookie but carries a + NON-admin FileBrowser token — in the `filebrowser_quantum_jwt` cookie, an `Authorization: Bearer` header or the + `auth` query parameter — exactly like a stale files cookie: both cookies cleared, a browser sent to the dashboard + (`/__files/open`, which re-issues the pass while the session lives), anything else 401. `admin`'s token (the password + fallback) and FileBrowser's sign-in page still pass; an unreadable token passes on to FileBrowser, which refuses it. + The signature is not checked here and need not be: this check can only refuse. `carriesNonAdminFileBrowserToken`; + `TestFilesSSO_R1155_HouseholdTokenDiesWithTheSignOut` — red-proved both ways: without the check the household's + token alone passes after the sign-out (200); with every token refused, admin's own token gets 302. +- **The recovery-code page tells the truth about when the code is live (R-1161).** Since R-1151 (agent ≥ 0.160.0) the + new code becomes live only when the two words come back; the page still said „Ez mostantól az élő helyreállítási + kód". It now shows „Ez a kód az ellenőrzés után lesz az élő helyreállítási kód: írd be lent a két kért szót. Addig a + korábbi kód marad érvényes, akkor is, ha most bezárod az oldalt. …" (key `backups_escrow.ez_a_kod_az_ellenorzes`, + English twin), and the old sentence only when the claim answer says no confirm is required (an agent before 0.160.0, + which stores the package at the claim). i18n parity: `escrow_first` and `escrow_receremony` re-captured (the + reason: R-1151 made the old sentence untrue); every other fixture byte-identical. `TestEscrowTemplates_Render` + pins both texts and the switch — red-proved with the old template. +- **The Chrome „Install" chip on the file manager: NOT-A-FINDING.** FileBrowser 1.5.6 always links + `/public/static/site.webmanifest` (display `standalone`); its only PWA setting is `disablePWAInstall` (set — it hides + FileBrowser's own prompt). No setting stops the manifest; FileBrowser is not patched. + ## v0.332.0 — one sign-in for the file manager (R-1100, operator D16 = yes); the new recovery code replaces the old one only after the household typed it back (R-1151) (2026-10-19) **MinAgent: 0.158.1** — the floor's, carried. Neither change needs a new agent: the file manager's sign-in is the diff --git a/controller/README.md b/controller/README.md index cfb8629..8ac5c08 100644 --- a/controller/README.md +++ b/controller/README.md @@ -388,7 +388,9 @@ forwardAuth on `files.` (`GET /__felhom_gate/files`, `web/files_sso.go`) host-only `felhom_files` cookie and then sets the box's SECRET header (`X-Felhom-User-<32 hex>`, `/filebrowser-sso-header`) naming FileBrowser user `felhom-household` — FileBrowser opens at the folder with no login. No files cookie → no header: FileBrowser's own password (`admin`, the fallback). A dashboard sign-out or password change ends the files cookie at the -next request. The dashboard cookie stays host-only. +next request; since v0.333.0 (R-1155) so does FileBrowser's own 2-hour token for the household — with no valid files +cookie, a request carrying a non-admin FileBrowser token (cookie, Bearer or `?auth=`) is refused like a stale files +cookie, while `admin`'s token still passes. The dashboard cookie stays host-only. #### „Még egy lépés" — a value asked AFTER the app starts (v0.315.0) diff --git a/controller/internal/i18n/locales/en.json b/controller/internal/i18n/locales/en.json index 6e38082..a02b92f 100644 --- a/controller/internal/i18n/locales/en.json +++ b/controller/internal/i18n/locales/en.json @@ -382,6 +382,7 @@ "backups_escrow.elrejtes": "Hide", "backups_escrow.es": " and no.", "backups_escrow.ez_mostantol_az_elo_helyreallitasi": "This is now the live recovery code — the earlier code is no longer valid. Save it now: the code cannot be shown again.", + "backups_escrow.ez_a_kod_az_ellenorzes": "This code becomes the live recovery code after the check: type the two requested words below. Until then the earlier code stays valid, also if you close this page now. Save it now: the code cannot be shown again.", "backups_escrow.felirtam_a_kodot_es_biztonsagos": "I wrote down the code and keep it in a safe place — not on this server.", "backups_escrow.helyreallitasi_dr_szint_aktiv": "Recovery (DR) level active", "backups_escrow.helyreallitasi_kod": "Recovery code", diff --git a/controller/internal/i18n/locales/hu.json b/controller/internal/i18n/locales/hu.json index 10ac74c..e37dda2 100644 --- a/controller/internal/i18n/locales/hu.json +++ b/controller/internal/i18n/locales/hu.json @@ -378,6 +378,7 @@ "backups_escrow.elrejtes": "Elrejtés", "backups_escrow.es": ". és", "backups_escrow.ez_mostantol_az_elo_helyreallitasi": "Ez mostantól az élő helyreállítási kód — a korábbi kód érvényét vesztette. Mentsd el most: a kód többé nem jelenik meg.", + "backups_escrow.ez_a_kod_az_ellenorzes": "Ez a kód az ellenőrzés után lesz az élő helyreállítási kód: írd be lent a két kért szót. Addig a korábbi kód marad érvényes, akkor is, ha most bezárod az oldalt. Mentsd el most: a kód többé nem jelenik meg.", "backups_escrow.felirtam_a_kodot_es_biztonsagos": "Felírtam a kódot, és biztonságos helyen — nem ezen a szerveren — tárolom.", "backups_escrow.helyreallitasi_dr_szint_aktiv": "Helyreállítási (DR) szint aktív", "backups_escrow.helyreallitasi_kod": "Helyreállítási kód", diff --git a/controller/internal/web/escrow_wizard_test.go b/controller/internal/web/escrow_wizard_test.go index 86712e9..32cf996 100644 --- a/controller/internal/web/escrow_wizard_test.go +++ b/controller/internal/web/escrow_wizard_test.go @@ -373,9 +373,12 @@ func TestEscrowTemplates_Render(t *testing.T) { "A folytatáshoz add meg a bejelentkezési jelszavadat", "Kód létrehozása", "Kód megjelenítése", - // v0.127.3 (Viktor): the reveal states EXPLICITLY that the shown code is already the - // live one (the supersede happened at upload, before display). - "Ez mostantól az élő helyreállítási kód — a korábbi kód érvényét vesztette. Mentsd el most: a kód többé nem jelenik meg.", + // R-1161 (2026-10-19): with an agent >= 0.160.0 the code becomes live only after the two words come back + // (R-1151), so the reveal says that by default; v0.127.3's "already live" sentence is shown only for an + // older agent (confirm_required false), which still stores the package at the claim. + `id="live-after-check" style="margin-bottom:.5rem">Ez a kód az ellenőrzés után lesz az élő helyreállítási kód: írd be lent a két kért szót. Addig a korábbi kód marad érvényes, akkor is, ha most bezárod az oldalt. Mentsd el most: a kód többé nem jelenik meg.`, + `id="live-now" style="display:none;margin-bottom:.5rem">Ez mostantól az élő helyreállítási kód — a korábbi kód érvényét vesztette. Mentsd el most: a kód többé nem jelenik meg.`, + "if(!confirmRequired){ document.getElementById('live-after-check').style.display='none'; document.getElementById('live-now').style.display='block'; }", // The pre-generation cancel must stay next to the start button (nothing runs until then). `>Mégsem`, "nem ezen a szerveren", diff --git a/controller/internal/web/files_sso.go b/controller/internal/web/files_sso.go index 1b8eb16..f65fd66 100644 --- a/controller/internal/web/files_sso.go +++ b/controller/internal/web/files_sso.go @@ -29,6 +29,9 @@ import ( // 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). +// 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") diff --git a/controller/internal/web/files_sso_test.go b/controller/internal/web/files_sso_test.go index 30bfb53..3d983e9 100644 --- a/controller/internal/web/files_sso_test.go +++ b/controller/internal/web/files_sso_test.go @@ -1,6 +1,8 @@ package web import ( + "encoding/base64" + "encoding/json" "io" "log" "net/http" @@ -381,3 +383,68 @@ func TestFilesSSO_R1100_CrossSiteNoPass(t *testing.T) { t.Fatal("a name with two dots inside it is a real folder name") } } + +// fbToken is a FileBrowser 1.5.6 token's shape (claims measured on demo-hp 2026-10-19: belongsTo, Permissions{…}, iat, +// exp, iss; HS256). The signature is junk: the gate never checks it, FileBrowser does. +func fbToken(admin bool, id int) string { + enc := func(v any) string { b, _ := json.Marshal(v); return base64.RawURLEncoding.EncodeToString(b) } + return enc(map[string]any{"alg": "HS256", "typ": "JWT"}) + "." + enc(map[string]any{ + "belongsTo": id, "iat": 1, "exp": 4102444800, "iss": "FileBrowser Quantum", + "Permissions": map[string]bool{"admin": admin, "api": admin, "modify": true, "share": true, "realtime": true, "delete": true, "create": true, "download": true}, + }) + ".c2lnbmF0dXJl" +} + +// R-1155: FileBrowser's own 2-hour token for the household user must not outlive the dashboard session. A browser that +// lost only the files cookie (or a token copied out of it) is refused like a stale files cookie — by cookie, by Bearer +// header and by the `auth` query parameter; `admin`'s token and FileBrowser's own sign-in page still pass. +func TestFilesSSO_R1155_HouseholdTokenDiesWithTheSignOut(t *testing.T) { + s := newFilesServer(t) + sess := sessionCookie(s) + fc := signIn(t, s, sess, "/") + header := s.filesHeader() + household := &http.Cookie{Name: fileBrowserJWTCookie, Value: fbToken(false, 3)} + admin := &http.Cookie{Name: fileBrowserJWTCookie, Value: fbToken(true, 1)} + + if w := filesAsk(s, "files.example.hu", "/files/", htmlAccept, fc, household); w.Code != http.StatusOK || w.Header().Get(header) != infra.FileBrowserSSOUser { + t.Fatalf("signed in, files cookie + the household's token: %d header=%q, want 200 with the household", w.Code, w.Header().Get(header)) + } + + // The household signs out on the dashboard; the browser kept FileBrowser's token but not the files cookie. + s.sessionsMu.Lock() + delete(s.sessions, sessionFingerprint(sess.Value)) + s.sessionsMu.Unlock() + w := filesAsk(s, "files.example.hu", "/files/", htmlAccept, household) + if w.Code == http.StatusOK { + t.Fatalf("after the sign-out the household's FileBrowser token alone still passed (200) — it outlives the session") + } + if loc := w.Header().Get("Location"); w.Code != http.StatusFound || !strings.HasPrefix(loc, "https://felhom.example.hu"+filesOpenPath) { + t.Fatalf("browser with the token alone: %d → %q, want 302 to the dashboard", w.Code, loc) + } + if c := filesCookieIn(w, fileBrowserJWTCookie); c == nil || c.MaxAge >= 0 { + t.Fatal("the household's FileBrowser token was not cleared") + } + if api := filesAsk(s, "files.example.hu", "/api/users?id=self", "application/json", household); api.Code != http.StatusUnauthorized { + t.Fatalf("API call with the household's token cookie alone: %d, want 401", api.Code) + } + r := httptest.NewRequest(http.MethodGet, "http://felhom-controller:8080"+filesAuthPath, nil) + r.Header.Set("X-Forwarded-Host", "files.example.hu") + r.Header.Set("X-Forwarded-Uri", "/api/users?id=self") + r.Header.Set("Authorization", "Bearer "+household.Value) + if b := s.filesEntry(r); b.Code != http.StatusUnauthorized { + t.Fatalf("API call with the household's token as Bearer: %d, want 401", b.Code) + } + if q := filesAsk(s, "files.example.hu", "/api/raw?files=x&auth="+url.QueryEscape(household.Value), "*/*"); q.Code != http.StatusUnauthorized { + t.Fatalf("download with the household's token in ?auth=: %d, want 401", q.Code) + } + + // The fallback stays: admin's token, FileBrowser's own sign-in page, and a token FileBrowser itself will refuse. + if a := filesAsk(s, "files.example.hu", "/files/", htmlAccept, admin); a.Code != http.StatusOK || a.Header().Get(header) != "" { + t.Fatalf("admin's own token: %d header=%q, want 200 without the household", a.Code, a.Header().Get(header)) + } + if p := filesAsk(s, "files.example.hu", "/login", htmlAccept); p.Code != http.StatusOK { + t.Fatalf("FileBrowser's sign-in page with no token: %d, want 200", p.Code) + } + if g := filesAsk(s, "files.example.hu", "/files/", htmlAccept, &http.Cookie{Name: fileBrowserJWTCookie, Value: "not-a-token"}); g.Code != http.StatusOK { + t.Fatalf("an unreadable token: %d, want 200 (FileBrowser refuses it itself)", g.Code) + } +} diff --git a/controller/internal/web/templates/backups_escrow.html b/controller/internal/web/templates/backups_escrow.html index a8595a6..cc63752 100644 --- a/controller/internal/web/templates/backups_escrow.html +++ b/controller/internal/web/templates/backups_escrow.html @@ -85,7 +85,8 @@