f30a80841e
gates / gates (push) Successful in 50s
FileBrowser (and traefik, cloudflared, samba) has no app.yaml, so Deployed is false. Since v0.315.0 the app page read Deployed alone and showed FileBrowser "not installed", with Install and without its login. A protected infra stack is now installed by definition on the app page (Deployed || Protected, as every other surface already counted): settings/guide/log tabs, Restart only, no install page, no data tab. The Install press would have deployed the infra stack as an app: DeployStack and the deploy API now refuse a protected stack. In the operator state (the box does not know FileBrowser's password - both demo boxes) the login panel offers "Új jelszó beállítása": the file browser's own CLI with the server stopped (spiked on demo-hp: new 200, old 401, users/shares/settings unchanged), verified, stored encrypted, state generated. Every installed app's settings tab has the login panel with exactly one answer: the box's fields, the login the app wrote (InitialCreds), or a sentence chosen by the catalog's new app_info.login_kind (first_visit default, email, none; none behind the family gate says the family sign-in is the login). "Fájlok ↗" opens the library folder, else the first declared folder, else a media app's first present drive's media folder. The data tab lists each drive's media folders; a folder whose drive is not bound into FileBrowser is text, never a link. Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
207 lines
9.6 KiB
Go
207 lines
9.6 KiB
Go
package web
|
|
|
|
import (
|
|
"bytes"
|
|
"io"
|
|
"log"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
|
)
|
|
|
|
// R-254 site one — AN APP'S GENERATED FIRST-LOGIN PASSWORD MUST NOT BE IN THE RESPONSE BODY.
|
|
//
|
|
// The third instance of one pattern in two days (R-249 was the retrieval passphrase; this is a real
|
|
// per-install app credential, read live out of the running container). The markup rendered it into
|
|
// `<span id="initcred-pw-val" hidden>…</span>` — `hidden` stops a browser DRAWING the value and
|
|
// nothing else, so a `curl` of an app's info page returned it in plaintext.
|
|
//
|
|
// These assert the RAW BODY. A test that asks what the customer *sees* passes on this defect, which
|
|
// is exactly how it survived three times.
|
|
|
|
const testAppPassword = "TESTONLY-app-initial-pw-7Kq2mZ"
|
|
|
|
// renderAppInfo drives the REAL template with the page data shape the handler produces, and returns
|
|
// the bytes a browser would receive.
|
|
func renderAppInfo(t *testing.T, creds *stacks.ExtractedCreds, hasPassword bool) string {
|
|
t.Helper()
|
|
s := securityHarness(t)
|
|
s.loadTemplates()
|
|
data := map[string]interface{}{
|
|
"Page": "stacks", "Title": "Teszt app", "Domain": "example.hu",
|
|
"Stack": stacks.Stack{Name: "crafty", Deployed: true, State: "running"},
|
|
"Meta": stacks.Metadata{DisplayName: "Crafty", Slug: "crafty", Category: "media"},
|
|
"AppInfo": stacks.AppInfo{Tagline: "teszt"},
|
|
// The credentials card lives inside {{if .HasAppInfo}} — without this the card never renders
|
|
// and every assertion below would pass for the wrong reason.
|
|
"HasAppInfo": true,
|
|
}
|
|
if creds != nil {
|
|
data["InitialCreds"] = creds
|
|
data["InitialCredsHasPassword"] = hasPassword
|
|
}
|
|
// v0.320.0: the settings tab's handler picks the login panel's ONE answer from this same data.
|
|
meta := data["Meta"].(stacks.Metadata)
|
|
data["LoginAnswer"] = loginAnswerFor(data, &meta)
|
|
var buf bytes.Buffer
|
|
if err := s.tmpl.ExecuteTemplate(&buf, "app_settings", data); err != nil {
|
|
t.Fatalf("render app_info: %v", err)
|
|
}
|
|
return buf.String()
|
|
}
|
|
|
|
// ── SCENARIO A — the app password is not in the page ────────────────────────────────────────────
|
|
|
|
// RED-PROOF: put `<span id="initcred-pw-val" hidden>{{.InitialCreds.Password}}</span>` back into
|
|
// app_info.html AND restore `data["InitialCreds"] = creds` in the handler — this fails on the first
|
|
// assertion, showing the plaintext returning to the body. That is the defect, reproduced.
|
|
//
|
|
// NOTE the second assertion, and a CORRECTION to what v0.207.0's report claimed. It said HTML
|
|
// comments ship in the response body. **They do not, here:** this package renders with
|
|
// `html/template` (server.go), which STRIPS comments — measured: text/template keeps them,
|
|
// html/template does not. So a comment cannot leak a secret, and a red-proof planting one in a
|
|
// comment correctly does NOT fail. The assertion is kept because it catches the real regression —
|
|
// the hidden element itself coming back into the markup.
|
|
func TestAppInfoPage_DoesNotContainTheInitialPassword(t *testing.T) {
|
|
html := renderAppInfo(t, &stacks.ExtractedCreds{
|
|
Available: true, Username: "admin", Password: testAppPassword,
|
|
}, true)
|
|
|
|
if strings.Contains(html, testAppPassword) {
|
|
t.Error("R-254: the app's first-login password is in the response body of its info page — " +
|
|
"a fetch of this page returns a real per-install credential, and the reveal button only " +
|
|
"stops a browser DRAWING it")
|
|
}
|
|
if strings.Contains(html, "initcred-pw-val") {
|
|
t.Error("the old hidden-value element is back in the markup")
|
|
}
|
|
// The feature must survive: the fix removes the VALUE, not the customer's access (Scenario B).
|
|
// v0.320.0: the card is the login panel's „initial" answer (it was its own „Kezdeti belepesi adatok" card).
|
|
if !strings.Contains(html, `data-login-answer="initial"`) {
|
|
t.Error("the initial-credentials card vanished — the fix must not take the password away " +
|
|
"from the person whose app it is")
|
|
}
|
|
if !strings.Contains(html, "initial-credentials/reveal") {
|
|
t.Error("no reveal call rendered, so the customer has no way to obtain the password at all")
|
|
}
|
|
// The username is NOT a secret and must still render — otherwise the card is useless.
|
|
if !strings.Contains(html, "admin") {
|
|
t.Error("the username stopped rendering; only the password was supposed to leave the page")
|
|
}
|
|
}
|
|
|
|
// ── SCENARIO E — an app with no generated credentials is unchanged ──────────────────────────────
|
|
|
|
// RED-PROOF: make the card unconditional (drop `{{if .InitialCreds}}`) and this fails — an app that
|
|
// has no generated credential grows a reveal control for a password that does not exist.
|
|
func TestAppInfoPage_NoCredentialsCard_WhenAppHasNone(t *testing.T) {
|
|
html := renderAppInfo(t, nil, false)
|
|
|
|
if strings.Contains(html, `data-login-answer="initial"`) || strings.Contains(html, `id="initcred-reveal"`) {
|
|
t.Error("the initial-credentials card rendered for an app with no generated credentials")
|
|
}
|
|
if strings.Contains(html, "initial-credentials/reveal") {
|
|
t.Error("a reveal control rendered for an app that has no password to reveal")
|
|
}
|
|
}
|
|
|
|
// ── SCENARIO B — the customer can still get it, and C — the act is recorded ─────────────────────
|
|
|
|
// credsHarness builds a Server with a REAL stack manager carrying one deployed app whose slug is
|
|
// "crafty", plus a capturing logger. Only the container READ is seamed — slug resolution is the
|
|
// production path, because a reveal answering for the wrong app is the failure mode that matters.
|
|
func credsHarness(t *testing.T) (*Server, *bytes.Buffer) {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
buf := &bytes.Buffer{}
|
|
lg := log.New(io.MultiWriter(buf), "", 0)
|
|
cfg := config.Default()
|
|
cfg.Customer.Domain = "example.hu"
|
|
cfg.Paths.StacksDir = filepath.Join(dir, "stacks")
|
|
cfg.Paths.DataDir = filepath.Join(dir, "data")
|
|
cfg.Web.SessionSecret = "test-session-secret-abcdef"
|
|
|
|
sd := filepath.Join(cfg.Paths.StacksDir, "crafty")
|
|
if err := os.MkdirAll(sd, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
os.WriteFile(filepath.Join(sd, ".felhom.yml"), []byte("display_name: Crafty\nslug: crafty\n"), 0o644)
|
|
os.WriteFile(filepath.Join(sd, "docker-compose.yml"), []byte("services: {}\n"), 0o644)
|
|
os.WriteFile(filepath.Join(sd, "app.yaml"), []byte("deployed: true\n"), 0o644)
|
|
|
|
sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
mgr, err := stacks.NewManager(cfg, lg)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Discovery is a separate step — the constructor only prepares the directory.
|
|
_ = mgr.ScanStacks() // container-status refresh fails on a docker-less host; discovery is enough
|
|
if _, ok := mgr.GetStack("crafty"); !ok {
|
|
t.Fatal("crafty not discovered by ScanStacks — the fixture would prove nothing")
|
|
}
|
|
return &Server{cfg: cfg, settings: sett, stackMgr: mgr, logger: lg, version: "test"}, buf
|
|
}
|
|
|
|
// RED-PROOF for B: delete the route case from server.go (or make the handler always 404) and the
|
|
// customer is shown unable to reach their own app password.
|
|
// RED-PROOF for C: delete the s.logger.Printf line and the "recorded" assertion fails.
|
|
func TestAppInitialCredsReveal_ReturnsThePasswordAndRecordsTheAct(t *testing.T) {
|
|
s, logBuf := credsHarness(t)
|
|
s.initialCredsFn = func(name string) (*stacks.ExtractedCreds, error) {
|
|
return &stacks.ExtractedCreds{Available: true, Username: "admin", Password: testAppPassword}, nil
|
|
}
|
|
rr := httptest.NewRecorder()
|
|
s.appInitialCredsRevealHandler(rr, httptest.NewRequest("POST", "/apps/crafty/initial-credentials/reveal", nil), "crafty")
|
|
|
|
if rr.Code != 200 {
|
|
t.Fatalf("reveal returned %d, want 200 — the customer cannot get their own app password", rr.Code)
|
|
}
|
|
if !strings.Contains(rr.Body.String(), testAppPassword) {
|
|
t.Error("the reveal did not return the password — Scenario A's fix must not protect the " +
|
|
"secret by taking it from its owner")
|
|
}
|
|
if got := rr.Header().Get("Cache-Control"); !strings.Contains(got, "no-store") {
|
|
t.Errorf("Cache-Control = %q, want no-store — a cached reveal is the same defect one layer down", got)
|
|
}
|
|
// SCENARIO C — the act is recorded, and the VALUE is not.
|
|
logged := logBuf.String()
|
|
if !strings.Contains(logged, "initial-credential password revealed") {
|
|
t.Error("the reveal was not recorded — a silent read is what the markup allowed, and why " +
|
|
"nobody can say whether any of these was ever read")
|
|
}
|
|
if strings.Contains(logged, testAppPassword) {
|
|
t.Error("the password was written to the log")
|
|
}
|
|
}
|
|
|
|
// §7.1 — a reveal that cannot read the value SAYS SO. An empty string would render as a blank
|
|
// password and read to the customer as "your password is empty".
|
|
func TestAppInitialCredsReveal_SaysWhyWhenUnreadable(t *testing.T) {
|
|
s, _ := credsHarness(t)
|
|
s.initialCredsFn = func(name string) (*stacks.ExtractedCreds, error) {
|
|
return &stacks.ExtractedCreds{Available: false}, nil // container stopped / file gone
|
|
}
|
|
rr := httptest.NewRecorder()
|
|
s.appInitialCredsRevealHandler(rr, httptest.NewRequest("POST", "/apps/crafty/initial-credentials/reveal", nil), "crafty")
|
|
|
|
if rr.Code != 404 {
|
|
t.Errorf("unreadable reveal returned %d, want 404", rr.Code)
|
|
}
|
|
body := rr.Body.String()
|
|
if !strings.Contains(body, "futnia kell") {
|
|
t.Errorf("the refusal does not say WHY it could not be read: %s", body)
|
|
}
|
|
if strings.Contains(body, `"password"`) {
|
|
t.Error("an unreadable reveal still carried a password field")
|
|
}
|
|
}
|