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
gates / gates (push) Successful in 47s
This commit is contained in:
+37
-3
@@ -1,7 +1,41 @@
|
||||
## Unreleased (2026-10-19) — the new recovery code replaces the old one only after the household typed it back (R-1151, controller half)
|
||||
## 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)
|
||||
|
||||
Ships in the next release. **No new MinAgent:** it works with every agent — the agent's claim answer says whether a
|
||||
confirm is needed.
|
||||
**MinAgent: 0.158.1** — the floor's, carried. Neither change needs a new agent: the file manager's sign-in is the
|
||||
controller's alone, and R-1151's claim answer itself says whether the agent (≥ 0.160.0) waits for the typed-back words —
|
||||
an older agent stores the package at the claim, as before (pinned). No golden (`09` §3 decision 250). Task: the day
|
||||
brief of 2026-10-19 (felhom.eu `REPORT-day-2026-10-19.md`); design: felhom.eu `audits/proxyauth-spike-2026-10-18/DESIGN-NOTE.md`.
|
||||
|
||||
- **One sign-in for the file manager (R-1100).** „Fájlok ↗" (and every FileBrowser folder link on the dashboard) now
|
||||
goes to the dashboard's `GET /__files/open?to=<FileBrowser path>`. 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 `files.<domain>/_felhom/sso?t=…`;
|
||||
without a session, the dashboard's sign-in and back. Every request to `files.<domain>` now first asks the controller
|
||||
(traefik forwardAuth `felhom-files-sso` → `GET /__felhom_gate/files`, the setup gate's pattern, decision 46):
|
||||
`/_felhom/sso` swaps the pass for the files cookie `felhom_files` (host-only on `files.<domain>`, HttpOnly, Secure,
|
||||
Lax; it carries a value DERIVED from the session's fingerprint for that host — the R-976 shape — never the session)
|
||||
and clears FileBrowser's own earlier token; a valid files cookie → 200 with the box's secret header naming the
|
||||
household's FileBrowser user `felhom-household`; no files cookie → 200 without it (FileBrowser's own password sign-in:
|
||||
`admin`, the fallback and the operator's way — R-513 and the R-1126 gate unchanged); a files cookie that no longer opens
|
||||
(the dashboard session ended, the dashboard password changed → the gate key rotates) → both cookies cleared and the
|
||||
browser sent back to the dashboard, which asks for the sign-in again. **The header name is a per-box secret**
|
||||
(`X-Felhom-User-<32 hex>`, made once, 0600, `<data>/filebrowser-sso-header`, `infra.LoadOrCreateFileBrowserSSOHeader`)
|
||||
written only into FileBrowser's config (`auth.methods.proxy`, with `logoutRedirectUrl` = the dashboard; password
|
||||
sign-in stays on) and traefik's route (`authResponseHeaders`, which strips a client's copy) — so an app container that
|
||||
reaches `filebrowser:80` directly cannot forge it (the spike measured that it could with a fixed name). **The dashboard
|
||||
cookie is NOT widened** to the parent domain. FileBrowser gives a header user only its userDefaults, so with the sign-in
|
||||
the defaults are the household's file rights (change, add, delete, share, download, live updates — not admin, not
|
||||
API); a user the household adds by hand starts from the same ticks. The data tab's „asks for its own password the first
|
||||
time" note is shown only on a box without the sign-in. Every box's FileBrowser config and route change once, so
|
||||
FileBrowser is re-created once. Tests (each refusal red-proved, `internal/web/files_sso_test.go`,
|
||||
`internal/infra/filebrowser_sso_test.go`): `TestFilesSSO_R1100_HouseholdOpensTheFolderWithoutALogin`,
|
||||
`_ReplayedPassRefused` (used-nonce check off → „a replayed pass set the files cookie again"), `_ExpiredPassRefused`
|
||||
(expiry off → „a pass 61 s old set the files cookie"), `_PassForAnotherHostRefused` (host check AND host-bound session
|
||||
key off → „a pass for files.other.hu passed …: 302"; X-Forwarded-Host check off → „the answer named the household on
|
||||
felhom.example.hu: 200"; a setup-gate pass refused), `_NoSessionNoPass` (validity off → „a browser with no session got
|
||||
a pass"), `_SignedOutAsksAgain` (session check always true → „after the sign-out the files cookie still named the
|
||||
household"; also after a dashboard password change), `_DataTabLoginNote`, `TestFileBrowserSSOHeader_R1100_ABoxSecret`,
|
||||
`TestFileBrowserConfig_R1100_TrustsOnlyTheBoxSecret`, `_HeaderUserGetsTheHouseholdsRights`,
|
||||
`TestFileBrowserRoute_R1100_AsksTheControllerAndStripsTheHeader` (each red-proved). Link tests now read the folder from
|
||||
the pass link's `to`. i18n fixtures: none changed (no fixture page shows a folder link or the note).
|
||||
|
||||
- **The wizard's typed-back words now go to the box, not only to the browser.** The claim asks the agent to keep the
|
||||
new package (`{"defer_upload": true}`); an agent ≥ 0.160.0 answers with `confirm_required` and the two word
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
| `appbackup.ValidateRelPath` / `ValidRoot` | controller/internal/appbackup/classify.go | `(root, path)` / `(root)` | THE single path-safety refusal set for every `${VAR}`-relative catalog path | Shared by `backup:` and `data_paths:`. **Do not write a second path validator.** |
|
||||
| `stacks.ValidateDataPaths` | controller/internal/stacks/datapaths.go | `(entries, binds, appName, logger)` | `data_paths:` annotation validation | ASYMMETRIC on purpose (Fork-3): malformed PATH ⇒ whole-block reject (data handling, `backup:` precedent); unknown ROLE ⇒ fails OPEN, one WARN (presentation, `Lifecycle` precedent). |
|
||||
| `web.fileBrowserLink` / `importFolderLink` | controller/internal/web/filebrowser_link.go | `(domain, sourceName, relPath)` | FileBrowser Quantum deep link | Template read out of the shipped router (SPIKE P2). **`url.PathEscape` per segment — NEVER `QueryEscape`** (space→`+` is a literal plus in a path). Let `html/template` do the attribute escaping; do not pre-escape. |
|
||||
| `web.fileBrowserOpenLink` · `web.ServeFilesOpen` / `ServeFilesAuth` · `infra.LoadOrCreateFileBrowserSSOHeader` / `FileBrowserSSOFor` (v0.332.0, R-1100) | controller/internal/web/filebrowser_link.go · controller/internal/web/files_sso.go · controller/internal/infra/filebrowser_sso.go | `(domain, sourceName, relPath)` · handlers · `(dataDir[, domain])` | A dashboard link to a FileBrowser folder that opens it signed in (the one sign-in) | **Every dashboard folder link uses `fileBrowserOpenLink`** (through `/__files/open`), never `fileBrowserLink` raw. The header NAME is the box's secret: only FileBrowser's config and traefik's route carry it — never a log, a page or a report. Never widen the dashboard cookie; the files cookie is derived per host from the session fingerprint (the R-976 shape) |
|
||||
| `HumanizeBytes` | controller/internal/appbackup/appdata.go | `(b int64) string` | Human byte sizes | Exported canonical; private clones exist (§6) |
|
||||
| `stablePathForName` / `agentWhere` | controller/internal/web/intermediary.go | `(name/registeredPath) string` | Map registry stable path `/mnt/felhom-drives/<n>` ↔ raw agent mount | Registry stores STABLE path; agent ops take the RAW mount — always convert |
|
||||
| `offsiteRestoreRootFor` | controller/internal/backup/offbox_verify_copies.go | `(drivePath string) string` | THE only place `backups/offsite-restore` is spelled | `offboxRestoreScratchDir` builds on it — the listing/delete surface MUST resolve byte-identical paths to what the restore wrote. Do not re-hardcode the segments (they were open-coded in 3 places before v0.147.0) |
|
||||
@@ -207,7 +208,7 @@
|
||||
| `settingsRetrievalPasswordRevealHandler` | controller/internal/web/handlers.go | `POST /settings/retrieval-password/reveal` | **THE PATTERN for showing a secret in the UI** — an XHR that returns only the value | **Never template a secret into a page and hide it with CSS.** `display:none` / `hidden` / `type="password"` stop a browser DRAWING the value; the plaintext is still in the response body, so a `curl` of the page returns it, and it reaches caches, history and any screen-share of the source. R-249 shipped exactly that for two months and was found by it landing in a transcript. The page carries a **boolean** (`HasRetrievalPassword`); the value comes from a POST (CSRF-covered, uncacheable) and the reveal is **logged as an act**. `escrow_handlers.go` states the same rule for R. **Test on the RESPONSE BODY** — a test asserting what the customer *sees* cannot see this class at all. **Both R-254 sites are now FIXED the same way** — `POST /apps/<slug>/initial-credentials/reveal` (re-reads the container, never a cached copy) and `POST /stacks/<name>/auto-field/reveal` (authorised on the field being a `type: secret` auto-field of that stack). **Per-secret, never one generic reveal-any-named-secret endpoint.** The PRE-DEPLOY hidden input is deliberate and untouched — a form must carry what it submits (README §318). Enforced by `scripts/secret_in_markup_gate.py`, whose measured blind spot (a secret under a neutral page-data key) is in its docstring; runtime body-assertion covers 4 of 27 pages — R-255. |
|
||||
| `storedValuePresent` / `appLoginFields` | controller/internal/web/app_login.go | the settings tab's login panel and „Cím és kulcsok" rows (v0.319.0) | **A field the box holds no value for is a sentence, never an eye.** `storedValuePresent` is the ONE definition of „stored", read by the page AND by `appAutoFieldRevealHandler`'s 404 branch — so the page cannot offer a reveal the endpoint refuses. The page gets a boolean per login field, never the value (R-254/R-709). Pinned by `TestAppPage_NoStoredValueShowsSentence`. |
|
||||
| `loginAnswerFor` | controller/internal/web/app_login.go | the settings tab's ONE login answer (v0.320.0) | `fields` · `initial` · the catalog's `app_info.login_kind` via `AppInfo.EffectiveLoginKind` (`first_visit` default; an unknown value reads as it). An infra stack with no login gets no panel. Pinned by `TestAppPage_EveryInstalledAppHasOneLoginAnswer`. |
|
||||
| `filesButtonLink` / `mediaFolderCards` / `drivePresent` | controller/internal/web/datapath_card.go | the „Fájlok ↗" target and the media folder rows (v0.320.0) | **A folder whose drive is not bound into FileBrowser now is TEXT, never a link** — `drivePresent` reads `stacks.PresentMediaDrives` (the same gates `buildFileBrowserPaths` applies); tests seam it with `Server.presentDrivesFn`. Links only through `fileBrowserLink`. Pinned by `TestAppPage_FilesButtonTarget`, `_UnknownSourceIsTextNotLink`. |
|
||||
| `filesButtonLink` / `mediaFolderCards` / `drivePresent` | controller/internal/web/datapath_card.go | the „Fájlok ↗" target and the media folder rows (v0.320.0) | **A folder whose drive is not bound into FileBrowser now is TEXT, never a link** — `drivePresent` reads `stacks.PresentMediaDrives` (the same gates `buildFileBrowserPaths` applies); tests seam it with `Server.presentDrivesFn`. Links only through `fileBrowserOpenLink` (R-1100; the folder path built by `fileBrowserLink`). Pinned by `TestAppPage_FilesButtonTarget`, `_UnknownSourceIsTextNotLink`. |
|
||||
| `infraMetaIn` | controller/internal/web/inframeta.go | an infra stack's curated name/description in a language | The ONE place for both the `infraMeta` template func and the app page. |
|
||||
| `Manager.FileBrowserStoredPasswordRefused` | controller/internal/stacks/filebrowser_password.go | is the stored FileBrowser password still valid (v0.321.0, R-1101) | TRUE only on FileBrowser's own 401/403 in the `generated` state; FileBrowser not answering is FALSE (an unproven refusal must not tell the household its password changed). 3 s bound. Web seam: `Server.fbStoredRefused`. |
|
||||
| `Manager.ResetFileBrowserAdminPassword` | controller/internal/stacks/filebrowser_password.go | FileBrowser's admin password without the old one (v0.320.0) | Only in `operator`; holds `infraMu` (the base tick must not start the stopped server), and the web handler holds `fileBrowserMu` (the mount sync must not recreate it). Stop → `docker run --rm --volumes-from filebrowser … set -u admin,<pw>` → start (detached from the request's cancel) → verify → store encrypted. TRAP: the CLI opens the DB with no lock timeout — never run it against a running server. Seam: `fbSetPassword`. |
|
||||
|
||||
@@ -382,7 +382,13 @@ wrote or FileBrowser's (`InitialCreds`) · a sentence from the catalog's `app_in
|
||||
admin/admin refused, store encrypted, state `generated`). *„Fájlok ↗"* in the header opens the library folder, else
|
||||
the first declared folder, else a media app's first present drive's `media` folder (`datapath_card.go`
|
||||
`filesButtonLink`); the data tab lists each drive's media folders, and a folder whose drive is not bound into
|
||||
FileBrowser is text, never a link.
|
||||
FileBrowser is text, never a link. **One sign-in (v0.332.0, R-1100):** every folder link goes through the dashboard's
|
||||
`GET /__files/open`, which mints a 60-second one-use pass bound to `files.<domain>` and the session; traefik's
|
||||
forwardAuth on `files.<domain>` (`GET /__felhom_gate/files`, `web/files_sso.go`) swaps it at `/_felhom/sso` for the
|
||||
host-only `felhom_files` cookie and then sets the box's SECRET header (`X-Felhom-User-<32 hex>`, `<data>/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.
|
||||
|
||||
#### „Még egy lépés" — a value asked AFTER the app starts (v0.315.0)
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// One sign-in for the file manager (R-1100, operator decision D16 = yes, 2026-10-19; design note
|
||||
// felhom.eu/documentation/audits/proxyauth-spike-2026-10-18/DESIGN-NOTE.md, `02` „Logins and folders").
|
||||
//
|
||||
// FileBrowser trusts its proxy header blindly (`auth.methods.proxy`, „FileBrowser blindly accepts the header value as
|
||||
// username") and every app container can reach filebrowser:80 directly, without traefik — measured: a forged header
|
||||
// from the Nextcloud container logged in (200). So the header NAME is a per-box secret: an app that does not know it
|
||||
// cannot send it, and traefik's forwardAuth strips it from every client request (it is in authResponseHeaders) and sets
|
||||
// it only for a browser that holds the files pass the dashboard minted. The name is written ONLY into FileBrowser's
|
||||
// config and traefik's dynamic route — never a log, a page or a report.
|
||||
|
||||
// FileBrowserSSOUser is the FileBrowser user the household is signed in as through the header. A separate user from
|
||||
// `admin` (a password user, which FileBrowser refuses to reach by a header — ErrWrongLoginMethod), created by
|
||||
// FileBrowser on the first header sign-in with the config's userDefaults.
|
||||
const FileBrowserSSOUser = "felhom-household"
|
||||
|
||||
// FilesSSOMiddleware is the traefik forwardAuth middleware on the files.<domain> router.
|
||||
const FilesSSOMiddleware = "felhom-files-sso"
|
||||
|
||||
// FilesAuthURL is the controller's forwardAuth answer for files.<domain> (web.ServeFilesAuth).
|
||||
const FilesAuthURL = "http://felhom-controller:8080/__felhom_gate/files"
|
||||
|
||||
// fileBrowserSSOHeaderFile holds the header name, 0600, in the controller's data dir (like setup-gate.key).
|
||||
const fileBrowserSSOHeaderFile = "filebrowser-sso-header"
|
||||
|
||||
var reSSOHeader = regexp.MustCompile(`^X-Felhom-User-[0-9a-f]{32}$`)
|
||||
|
||||
// ValidFileBrowserSSOHeader reports whether h has the shape LoadOrCreateFileBrowserSSOHeader makes.
|
||||
func ValidFileBrowserSSOHeader(h string) bool { return reSSOHeader.MatchString(h) }
|
||||
|
||||
// LoadOrCreateFileBrowserSSOHeader returns this box's secret header name, making it once. Two writers (the stacks
|
||||
// manager rendering FileBrowser's files, the web server answering forwardAuth) may ask at the same moment: the file is
|
||||
// created with O_EXCL, and the loser reads the winner's value. A file that does not hold a valid name is an error, not
|
||||
// a silent new name (a new name would split FileBrowser's config from traefik's route until both are rewritten).
|
||||
// Pinned by TestFileBrowserSSOHeader_R1100_*.
|
||||
func LoadOrCreateFileBrowserSSOHeader(dataDir string) (string, error) {
|
||||
if dataDir == "" {
|
||||
return "", errors.New("no data dir")
|
||||
}
|
||||
p := filepath.Join(dataDir, fileBrowserSSOHeaderFile)
|
||||
if b, err := os.ReadFile(p); err == nil {
|
||||
h := strings.TrimSpace(string(b))
|
||||
if !ValidFileBrowserSSOHeader(h) {
|
||||
return "", fmt.Errorf("%s does not hold a valid header name", p)
|
||||
}
|
||||
return h, nil
|
||||
} else if !os.IsNotExist(err) {
|
||||
return "", err
|
||||
}
|
||||
rb := make([]byte, 16)
|
||||
if _, err := rand.Read(rb); err != nil {
|
||||
return "", err
|
||||
}
|
||||
h := "X-Felhom-User-" + hex.EncodeToString(rb)
|
||||
f, err := os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err != nil {
|
||||
if os.IsExist(err) {
|
||||
return LoadOrCreateFileBrowserSSOHeader(dataDir)
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if _, err := f.WriteString(h + "\n"); err != nil {
|
||||
f.Close()
|
||||
_ = os.Remove(p)
|
||||
return "", err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
_ = os.Remove(p)
|
||||
return "", err
|
||||
}
|
||||
return h, nil
|
||||
}
|
||||
|
||||
// FileBrowserSSO is what FileBrowser's config needs for the header sign-in. Header "" = no header sign-in (the config
|
||||
// is the pre-R-1100 one, byte for byte).
|
||||
type FileBrowserSSO struct {
|
||||
Header string // the box's secret header name
|
||||
LogoutURL string // where FileBrowser's „Kijelentkezés" goes for the header user: the dashboard
|
||||
}
|
||||
|
||||
// renderFileBrowserAuth is the config's auth block for the header sign-in. Password sign-in stays on: `admin` is the
|
||||
// fallback when the pass path fails, and the operator's way in (R-513 and the R-1126 gate unchanged).
|
||||
func renderFileBrowserAuth(sso FileBrowserSSO) string {
|
||||
if sso.Header == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(`auth:
|
||||
methods:
|
||||
password:
|
||||
enabled: true
|
||||
minLength: 5
|
||||
# R-1100: the household's one sign-in. traefik sets this header only for a browser holding the dashboard's files
|
||||
# pass and strips it from every client request; its name is this box's secret (an app container that reaches
|
||||
# filebrowser:80 directly does not know it). Pinned by TestFileBrowserConfig_R1100_*.
|
||||
proxy:
|
||||
enabled: true
|
||||
header: %q
|
||||
logoutRedirectUrl: %q
|
||||
`, sso.Header, sso.LogoutURL)
|
||||
}
|
||||
|
||||
// FileBrowserSSOFor is the header sign-in for this box: its secret header name and the dashboard as FileBrowser's
|
||||
// logout target. An error (no data dir, an unreadable or damaged name file) = no header sign-in: the caller renders
|
||||
// the pre-R-1100 config and route and says so in its log; FileBrowser then asks for its own password, as before.
|
||||
func FileBrowserSSOFor(dataDir, domain string) (FileBrowserSSO, error) {
|
||||
if domain == "" {
|
||||
return FileBrowserSSO{}, errors.New("no domain")
|
||||
}
|
||||
h, err := LoadOrCreateFileBrowserSSOHeader(dataDir)
|
||||
if err != nil {
|
||||
return FileBrowserSSO{}, err
|
||||
}
|
||||
return FileBrowserSSO{Header: h, LogoutURL: "https://felhom." + domain + "/"}, nil
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
const testSSOHeader = "X-Felhom-User-0123456789abcdef0123456789abcdef"
|
||||
|
||||
// With the header sign-in FileBrowser trusts THIS box's secret header name (never the old public X-Felhom-User), keeps
|
||||
// password sign-in on (admin: the fallback and the operator's way), and sends a header user's logout to the dashboard.
|
||||
// Without it the config is the pre-R-1100 one.
|
||||
//
|
||||
// COMPANION RED-PROOF (observed 2026-10-19): render renderFileBrowserAuth's header as "X-Felhom-User" → this fails with
|
||||
// `the auth block lacks "header: \"X-Felhom-User-0123…\""`. Restored.
|
||||
func TestFileBrowserConfig_R1100_TrustsOnlyTheBoxSecret(t *testing.T) {
|
||||
sso := FileBrowserSSO{Header: testSSOHeader, LogoutURL: "https://felhom.example.hu/"}
|
||||
c := RenderFileBrowserConfigKept([]settings.StoragePath{{Path: "/mnt/felhom-drives/hdd_1", Label: "HDD"}}, true, "", sso)
|
||||
block := c[strings.Index(c, "\nauth:\n"):]
|
||||
block = block[:strings.Index(block, "\nuserDefaults:")]
|
||||
for _, want := range []string{" password:\n enabled: true", " proxy:\n enabled: true", `header: "` + testSSOHeader + `"`, `logoutRedirectUrl: "https://felhom.example.hu/"`} {
|
||||
if !strings.Contains(block, want) {
|
||||
t.Fatalf("the auth block lacks %q:\n%s", want, block)
|
||||
}
|
||||
}
|
||||
if strings.Count(c, "header:") != 1 || strings.Contains(c, `header: "X-Felhom-User"`) {
|
||||
t.Fatal("FileBrowser trusts a header name other than the box's secret")
|
||||
}
|
||||
plain := RenderFileBrowserConfigKept([]settings.StoragePath{{Path: "/mnt/felhom-drives/hdd_1", Label: "HDD"}}, true, "", FileBrowserSSO{})
|
||||
if strings.Contains(plain, "auth:") || strings.Contains(plain, "X-Felhom-User") {
|
||||
t.Fatal("without the header sign-in the config must carry no auth block")
|
||||
}
|
||||
}
|
||||
|
||||
// The household's header user is created by FileBrowser from userDefaults, so with the header sign-in the defaults are
|
||||
// the household's file rights (not FileBrowser's admin or API); without it, the old download-only defaults.
|
||||
//
|
||||
// COMPANION RED-PROOF (observed 2026-10-19): return the download-only block for both cases → this fails with "the
|
||||
// header user could not change files: modify false". Restored.
|
||||
func TestFileBrowserConfig_R1100_HeaderUserGetsTheHouseholdsRights(t *testing.T) {
|
||||
with := RenderFileBrowserConfigKept(nil, false, "", FileBrowserSSO{Header: testSSOHeader, LogoutURL: "https://felhom.example.hu/"})
|
||||
perms := with[strings.Index(with, " permissions:\n"):strings.Index(with, "frontend:")]
|
||||
for _, k := range []string{"modify", "share", "delete", "create", "download", "realtime"} {
|
||||
if !strings.Contains(perms, " "+k+": true\n") {
|
||||
t.Fatalf("the header user could not change files: %s false\n%s", k, perms)
|
||||
}
|
||||
}
|
||||
for _, k := range []string{"admin", "api"} {
|
||||
if !strings.Contains(perms, " "+k+": false\n") {
|
||||
t.Fatalf("the header user must not get %s", k)
|
||||
}
|
||||
}
|
||||
without := RenderFileBrowserConfigKept(nil, false, "", FileBrowserSSO{})
|
||||
if !strings.Contains(without, " modify: false\n") || !strings.Contains(without, " download: true\n") {
|
||||
t.Fatal("without the header sign-in the old download-only defaults must stay")
|
||||
}
|
||||
}
|
||||
|
||||
// The route asks the controller before FileBrowser and names the header in authResponseHeaders — which is what makes
|
||||
// traefik remove a client's copy of it (measured, audits/proxyauth-spike-2026-10-18). Without the header sign-in: the
|
||||
// old route, no middleware.
|
||||
//
|
||||
// COMPANION RED-PROOF (observed 2026-10-19): drop the router's `middlewares:` line in RenderFileBrowserRoute → this fails
|
||||
// with "the files router does not ask the controller first". Restored.
|
||||
func TestFileBrowserRoute_R1100_AsksTheControllerAndStripsTheHeader(t *testing.T) {
|
||||
r := RenderFileBrowserRoute("example.hu", testSSOHeader)
|
||||
router := r[strings.Index(r, " filebrowser:\n rule:"):strings.Index(r, " services:")]
|
||||
if !strings.Contains(router, " middlewares:\n - "+FilesSSOMiddleware+"\n") {
|
||||
t.Fatalf("the files router does not ask the controller first:\n%s", router)
|
||||
}
|
||||
for _, want := range []string{" " + FilesSSOMiddleware + ":\n forwardAuth:\n", `address: "` + FilesAuthURL + `"`, "authResponseHeaders:\n - \"" + testSSOHeader + "\""} {
|
||||
if !strings.Contains(r, want) {
|
||||
t.Fatalf("the route lacks %q:\n%s", want, r)
|
||||
}
|
||||
}
|
||||
if plain := RenderFileBrowserRoute("example.hu", ""); strings.Contains(plain, "middlewares") || strings.Contains(plain, "X-Felhom-User") {
|
||||
t.Fatalf("without the header sign-in the route must be the old one:\n%s", plain)
|
||||
}
|
||||
}
|
||||
@@ -264,23 +264,35 @@ networks:
|
||||
// v0.327.1). The name resolves only while FileBrowser is on traefik-public, which the box allows only after this run's
|
||||
// default login was checked — so the route can stay in place and still never reach an unchecked FileBrowser (502 then).
|
||||
// TLS as the old Docker label had it (`tls: {}`, the default certificate store / the wildcard).
|
||||
func RenderFileBrowserRoute(domain string) string {
|
||||
//
|
||||
// R-1100: with ssoHeader set, every request to files.<domain> first asks the controller (forwardAuth, FilesAuthURL).
|
||||
// traefik removes ssoHeader from the client's request whatever the answer (it is in authResponseHeaders — measured,
|
||||
// any case, twice: audits/proxyauth-spike-2026-10-18) and sets it only from the controller's 200, which carries it only
|
||||
// for a browser holding the dashboard's files pass. The same answer handles /_felhom/sso (the pass → the files cookie).
|
||||
// ssoHeader "" = the route as before, byte for byte. Pinned by TestFileBrowserRoute_R1100_*.
|
||||
func RenderFileBrowserRoute(domain, ssoHeader string) string {
|
||||
mw, mwDef := "", ""
|
||||
if ssoHeader != "" {
|
||||
mw = " middlewares:\n - " + FilesSSOMiddleware + "\n"
|
||||
mwDef = fmt.Sprintf(" middlewares:\n %s:\n forwardAuth:\n address: %q\n authResponseHeaders:\n - %q\n",
|
||||
FilesSSOMiddleware, FilesAuthURL, ssoHeader)
|
||||
}
|
||||
return fmt.Sprintf(`# Traefik dynamic route for FileBrowser (files.<domain>) — managed by felhom-controller (R-1126).
|
||||
# WARNING: auto-generated at base-infra bring-up. Manual edits are overwritten.
|
||||
http:
|
||||
routers:
|
||||
%s routers:
|
||||
filebrowser:
|
||||
rule: "Host(`+"`"+`files.%s`+"`"+`)"
|
||||
entryPoints:
|
||||
- websecure
|
||||
service: filebrowser
|
||||
tls: {}
|
||||
%s tls: {}
|
||||
services:
|
||||
filebrowser:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://filebrowser:80"
|
||||
`, domain)
|
||||
`, mwDef, domain, mw)
|
||||
}
|
||||
|
||||
// RenderControllerRoute returns a traefik file-provider dynamic config routing the controller's own
|
||||
@@ -420,12 +432,13 @@ http:
|
||||
// storage path (each a named sidebar entry). Empty paths → a single default /srv source. Ported
|
||||
// verbatim from internal/web/handlers.go.
|
||||
func RenderFileBrowserConfig(paths []settings.StoragePath, importSource bool) string {
|
||||
return RenderFileBrowserConfigKept(paths, importSource, "")
|
||||
return RenderFileBrowserConfigKept(paths, importSource, "", FileBrowserSSO{})
|
||||
}
|
||||
|
||||
// RenderFileBrowserConfigKept is RenderFileBrowserConfig plus the read-only kept-data source, LAST,
|
||||
// named keptLabel ("" = no such source: nothing is kept, or the caller predates it).
|
||||
func RenderFileBrowserConfigKept(paths []settings.StoragePath, importSource bool, keptLabel string) string {
|
||||
// named keptLabel ("" = no such source: nothing is kept, or the caller predates it), and the household's header
|
||||
// sign-in (R-1100; a zero sso = none, the config byte for byte as before).
|
||||
func RenderFileBrowserConfigKept(paths []settings.StoragePath, importSource bool, keptLabel string, sso FileBrowserSSO) string {
|
||||
var sources string
|
||||
// The canonical drop-zone (R-75) is FIRST and is NOT a registered storage path — it is a separate
|
||||
// bind of <system namespace>/userdata/import. Separate rather than nested inside a drive source:
|
||||
@@ -471,7 +484,7 @@ server:
|
||||
logging:
|
||||
- levels: "info|warning|error"
|
||||
sources:
|
||||
%suserDefaults:
|
||||
%s%suserDefaults:
|
||||
stickySidebar: true
|
||||
darkMode: true
|
||||
viewMode: "normal"
|
||||
@@ -490,7 +503,23 @@ server:
|
||||
autoplayMedia: true
|
||||
folder: true
|
||||
permissions:
|
||||
api: false
|
||||
%sfrontend:
|
||||
# No „Install app" (PWA) prompt in the sidebar: the file manager is opened from the dashboard (v0.321.0;
|
||||
# structs.go:294, sent to the page at backend/http/static.go:300).
|
||||
disablePWAInstall: true
|
||||
`, sources, renderFileBrowserAuth(sso), fileBrowserDefaultPermissions(sso))
|
||||
}
|
||||
|
||||
// fileBrowserDefaultPermissions is userDefaults.permissions — what FileBrowser gives a user it creates. With the header
|
||||
// sign-in, the one user it creates on its own is the household's header user (FileBrowserSSOUser, on its first sign-in),
|
||||
// and that user must be able to do with the files what the household does as `admin`: change, add, delete, share,
|
||||
// download — but not run FileBrowser itself (admin) or its API keys (api). FileBrowser has no per-user setting for a
|
||||
// header user; the defaults are the only place the box can set its rights (R-1100). A user the household adds by hand
|
||||
// in FileBrowser starts from the same ticks and the household can change them there. Without the header sign-in: the
|
||||
// old download-only defaults, unchanged. Pinned by TestFileBrowserConfig_R1100_HeaderUserGetsTheHouseholdsRights.
|
||||
func fileBrowserDefaultPermissions(sso FileBrowserSSO) string {
|
||||
if sso.Header == "" {
|
||||
return ` api: false
|
||||
admin: false
|
||||
modify: false
|
||||
share: false
|
||||
@@ -498,9 +527,15 @@ server:
|
||||
delete: false
|
||||
create: false
|
||||
download: true
|
||||
frontend:
|
||||
# No „Install app" (PWA) prompt in the sidebar: the file manager is opened from the dashboard (v0.321.0;
|
||||
# structs.go:294, sent to the page at backend/http/static.go:300).
|
||||
disablePWAInstall: true
|
||||
`, sources)
|
||||
`
|
||||
}
|
||||
return ` api: false
|
||||
admin: false
|
||||
modify: true
|
||||
share: true
|
||||
realtime: true
|
||||
delete: true
|
||||
create: true
|
||||
download: true
|
||||
`
|
||||
}
|
||||
|
||||
@@ -296,7 +296,7 @@ func TestFileBrowserRender_R1126_NotPublicAtStart(t *testing.T) {
|
||||
if strings.Contains(compose, "traefik.") {
|
||||
t.Fatalf("FileBrowser must carry no Traefik labels (the route is the file route by name):\n%s", compose)
|
||||
}
|
||||
route := RenderFileBrowserRoute("demo-felhom.eu")
|
||||
route := RenderFileBrowserRoute("demo-felhom.eu", "")
|
||||
for _, w := range []string{"Host(`files.demo-felhom.eu`)", `url: "http://filebrowser:80"`, "websecure", "tls: {}"} {
|
||||
if !strings.Contains(route, w) {
|
||||
t.Errorf("the file route misses %q:\n%s", w, route)
|
||||
|
||||
@@ -7,10 +7,10 @@ import (
|
||||
|
||||
// `09` §3 decision 36 — the kept-data source appears only when named, at /srv/megorzott.
|
||||
func TestFileBrowser_KeptSourceOnlyWhenNamed(t *testing.T) {
|
||||
if s := RenderFileBrowserConfigKept(nil, false, ""); strings.Contains(s, FileBrowserKeptMount) {
|
||||
if s := RenderFileBrowserConfigKept(nil, false, "", FileBrowserSSO{}); strings.Contains(s, FileBrowserKeptMount) {
|
||||
t.Fatal("a kept source rendered with no label")
|
||||
}
|
||||
s := RenderFileBrowserConfigKept(nil, false, "Megőrzött adatok")
|
||||
s := RenderFileBrowserConfigKept(nil, false, "Megőrzött adatok", FileBrowserSSO{})
|
||||
if !strings.Contains(s, `path: "/srv/`+FileBrowserKeptMount+`"`) || !strings.Contains(s, `name: "Megőrzött adatok"`) {
|
||||
t.Fatalf("kept source missing:\n%s", s)
|
||||
}
|
||||
|
||||
@@ -274,7 +274,7 @@ func (m *Manager) ensureFileBrowser(dir string) error {
|
||||
// import source here without its bind would produce a source with no path behind it — a broken
|
||||
// sidebar entry — so both halves are deliberately deferred to the same place.
|
||||
compose := infra.RenderFileBrowserCompose(m.cfg.Customer.Domain, nil)
|
||||
config := infra.RenderFileBrowserConfig(nil, false)
|
||||
config := infra.RenderFileBrowserConfigKept(nil, false, "", m.fileBrowserSSO())
|
||||
if err := os.WriteFile(composePath, []byte(compose), 0o644); err != nil {
|
||||
return fmt.Errorf("write docker-compose.yml: %w", err)
|
||||
}
|
||||
@@ -296,16 +296,28 @@ func (m *Manager) ensureFileBrowserRoute(traefikDir string) error {
|
||||
return fmt.Errorf("mkdir dynamic: %w", err)
|
||||
}
|
||||
path := filepath.Join(dynDir, "filebrowser.yml")
|
||||
want := infra.RenderFileBrowserRoute(domain)
|
||||
sso := m.fileBrowserSSO()
|
||||
want := infra.RenderFileBrowserRoute(domain, sso.Header)
|
||||
if cur, err := os.ReadFile(path); err != nil || string(cur) != want {
|
||||
if err := os.WriteFile(path, []byte(want), 0o644); err != nil {
|
||||
return fmt.Errorf("write filebrowser route: %w", err)
|
||||
}
|
||||
m.logger.Printf("[INFO] [infra] wrote filebrowser route → %s (Host files.%s → filebrowser:80 by name, R-1126)", path, domain)
|
||||
m.logger.Printf("[INFO] [infra] wrote filebrowser route → %s (Host files.%s → filebrowser:80 by name, R-1126; one sign-in %v, R-1100)", path, domain, sso.Header != "")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fileBrowserSSO is this box's header sign-in for FileBrowser (R-1100): the same secret name for its config and for
|
||||
// traefik's route. An error = none (the pre-R-1100 files) and a WARN; the name itself is never logged.
|
||||
func (m *Manager) fileBrowserSSO() infra.FileBrowserSSO {
|
||||
sso, err := infra.FileBrowserSSOFor(m.cfg.Paths.DataDir, m.cfg.Customer.Domain)
|
||||
if err != nil {
|
||||
m.logger.Printf("[WARN] [infra] filebrowser: no one sign-in (R-1100): %v — FileBrowser asks for its own password", err)
|
||||
return infra.FileBrowserSSO{}
|
||||
}
|
||||
return sso
|
||||
}
|
||||
|
||||
// ensureServersTransports writes the traefik file-provider dynamic config defining named backend
|
||||
// transports (the insecure-skip-verify transport for self-signed HTTPS backends like Crafty). Like
|
||||
// wireController, it writes only when the content changes so the traefik file watcher doesn't reload
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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&%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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user