package web import ( "fmt" "io/fs" "net/http" "net/http/httptest" "os" "path" "path/filepath" "reflect" "regexp" "sort" "strings" "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" ) // R-255 (operator ruling 2026-10-12, S9, `09` §3 decision 204): ONE test renders EVERY dashboard page with // planted secret values in the data it renders from, and fails if any planted value reaches the HTML. // // Why a render and not only scripts/secret_in_markup_gate.py: the gate reads TEMPLATES and is blind to a // secret that arrives under a neutral page-data key (R-254 site two was exactly that shape). A rendered body // cannot hide it. // // What is planted, per page and per parity case (i18nCases — the same data the parity fixtures render): // // 1. every STRING in the case's data whose field or map-key NAME says secret (secretNameRe: password, // passphrase, secret, token, API key, private key, recovery code, credential, an env var ending _KEY) // — at any depth: maps, slices, structs, pointers; // 2. the secret-carrying keys handlers really set (RetrievalPassword, InitialCreds.Password — R-249/R-254) // and the names a future handler would reach for (RecoveryCode, Passphrase, SharePassword, …). // // Each planted value is unique, so a hit names the field it came from. The ONE deliberate exception is the // pre-deploy form of an app not yet installed: its generated secrets are SHOWN so the household can note them // down, and carried in a hidden input so the saved value is the one they saw (README §318; R-254 site two // keeps both, TestDeployPage_PreDeployForm_StillCarriesTheValue_Deliberately pins it). It is allowed only // there, only in those two exact markups. // // EVERY PAGE: a page template is a file whose `{{define}}` carries its own base name (launcher.html defines // "launcher"); the test derives that list from the embedded templates and fails if any page has no case — // a new page cannot be added without joining this sweep. // // RED-PROOF (REPORT.md): plant `` into settings_security.html → // this fails naming the page and the planted field. var secretNameRe = regexp.MustCompile(`(?i)(pass(word|phrase)|secret|token|api_?key|private_?key|recovery_?code|credential|_key$)`) // notSecretNames are names the pattern matches that carry no secret: the CSRF tokens a form must carry // (session-bound, useless without the session) and a password-change ERROR MESSAGE. var notSecretNames = map[string]bool{ "CSRFToken": true, "CSRFField": true, "FormToken": true, "LangCSRF": true, "PasswordError": true, } const plantedPrefix = "PLANTEDSECRET" type planter struct { n int where map[string]string // sentinel -> field path } func (p *planter) next(path string) string { p.n++ v := fmt.Sprintf("%s%03dX", plantedPrefix, p.n) p.where[v] = path return v } func isSecretName(name string) bool { return !notSecretNames[name] && secretNameRe.MatchString(name) } // plant returns a copy of v in which every string under a secret-named field or key is a sentinel. func (p *planter) plant(v reflect.Value, path string, secretCtx bool) reflect.Value { if !v.IsValid() { return v } switch v.Kind() { case reflect.String: if secretCtx { out := reflect.New(v.Type()).Elem() out.SetString(p.next(path)) return out } return v case reflect.Interface: if v.IsNil() { return v } inner := p.plant(v.Elem(), path, secretCtx) out := reflect.New(v.Type()).Elem() out.Set(inner) return out case reflect.Ptr: if v.IsNil() { return v } inner := p.plant(v.Elem(), path, secretCtx) out := reflect.New(v.Type().Elem()) out.Elem().Set(inner) return out case reflect.Map: if v.IsNil() { return v } out := reflect.MakeMapWithSize(v.Type(), v.Len()) for _, k := range v.MapKeys() { name := fmt.Sprint(k.Interface()) out.SetMapIndex(k, p.plant(v.MapIndex(k), path+"."+name, isSecretName(name))) } return out case reflect.Slice: if v.IsNil() { return v } out := reflect.MakeSlice(v.Type(), v.Len(), v.Len()) for i := 0; i < v.Len(); i++ { out.Index(i).Set(p.plant(v.Index(i), fmt.Sprintf("%s[%d]", path, i), secretCtx)) } return out case reflect.Struct: out := reflect.New(v.Type()).Elem() out.Set(v) for i := 0; i < v.NumField(); i++ { f := v.Type().Field(i) if f.PkgPath != "" { // unexported: cannot be set, and a template cannot read it either continue } out.Field(i).Set(p.plant(v.Field(i), path+"."+f.Name, isSecretName(f.Name))) } return out } return v } // pageTemplates lists every template file that defines a template carrying its own base name. func pageTemplates(t *testing.T) []string { t.Helper() names, err := fs.Glob(templateFS, "templates/*.html") if err != nil { t.Fatal(err) } var pages []string for _, n := range names { raw, err := templateFS.ReadFile(n) if err != nil { t.Fatal(err) } base := strings.TrimSuffix(path.Base(n), ".html") src := string(raw) // A PAGE is a whole document: its own shell, or the dashboard chrome. A partial that // happens to carry its file's name (lang_globe, meta_badge) is rendered inside pages, not alone. whole := strings.Contains(src, "`) shown := regexp.MustCompile(``) return strings.Count(html, sentinel) == len(hidden.FindAllString(html, -1))+len(shown.FindAllString(html, -1)) } // R-255's finding, fixed in the same session: an optional-config `secret_input` (a key the household typed in // for its app) was rendered into `value="…"` of its settings page. It is now revealed on demand. The reveal // endpoint serves it to the household's session and still refuses a field that is not a secret_input. // // COMPANION RED-PROOF (REPORT.md): drop the secret_input branch in appAutoFieldRevealHandler → the first case // 403s, and the household can no longer see the key it saved. func TestR255_OptionalSecretIsRevealedOnDemand(t *testing.T) { s := gateHarness(t) app := filepath.Join(s.cfg.Paths.StacksDir, "gapp") meta := "display_name: G\nslug: gapp\noptional_config:\n - group: Kulso\n fields:\n - env_var: META_API_KEY\n type: secret_input\n - env_var: META_REGION\n type: text\n" if err := os.WriteFile(filepath.Join(app, ".felhom.yml"), []byte(meta), 0o644); err != nil { t.Fatal(err) } const key = "TESTONLY-optional-api-key-Q7" if err := stacks.SaveAppConfig(app, &stacks.AppConfig{Deployed: true, Env: map[string]string{"META_API_KEY": key, "META_REGION": "eu"}}, nil, nil); err != nil { t.Fatal(err) } if err := s.stackMgr.ScanStacks(); err != nil { t.Fatal(err) } ask := func(field string) *httptest.ResponseRecorder { r := httptest.NewRequest(http.MethodPost, "/stacks/gapp/auto-field/reveal", strings.NewReader("env_var="+field)) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") w := httptest.NewRecorder() s.appAutoFieldRevealHandler(w, r, "gapp") return w } if w := ask("META_API_KEY"); w.Code != http.StatusOK || !strings.Contains(w.Body.String(), key) { t.Fatalf("the saved optional key is not revealable: %d", w.Code) } if w := ask("META_REGION"); w.Code != http.StatusForbidden { t.Fatalf("a non-secret optional field went through the secret reveal: %d", w.Code) } if w := ask("NOPE"); w.Code != http.StatusForbidden { t.Fatalf("an unknown field: %d", w.Code) } }