Files
felhom-controller/controller/internal/web/r255_secret_sweep_test.go
T
admin fd28d70bff v0.308.0: controller batch 2026-10-12 — clear-address warning (R-922), secret sweep over every page (R-255), read_failed (R-266), no NVMe heat band (R-333), share globe (R-577), Hungarian dates and numbers (R-562), lost-drive option pinned (R-521)
Operator rulings 2026-10-12, 09 §3 decisions 196, 204-207, 223, 227. MinAgent 0.131.0 unchanged.
R-255's sweep found one leak, fixed here: an optional-config secret_input was rendered into its
settings page; it is now revealed on demand. Hungarian parity fixtures re-captured once for R-562.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-12 15:13:59 +02:00

264 lines
10 KiB
Go

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 `<span hidden>{{.RetrievalPassword}}</span>` 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 <!DOCTYPE> 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, "<!DOCTYPE") || strings.Contains(src, `{{template "layout_start"`)
if whole && strings.Contains(src, `{{define "`+base+`"}}`) {
pages = append(pages, base)
}
}
sort.Strings(pages)
return pages
}
func TestR255_NoPlantedSecretReachesAnyPage(t *testing.T) {
pages := pageTemplates(t)
if len(pages) < 30 {
t.Fatalf("only %d page templates found — the page list is not being read", len(pages))
}
cases := i18nCases()
covered := map[string]int{}
for _, c := range cases {
covered[c.tmpl]++
}
for _, pg := range pages {
if covered[pg] == 0 {
t.Errorf("page template %q has no parity case, so this sweep never renders it — add one to i18nCases", pg)
}
}
s := i18nTestServer(t)
planted := 0
for _, c := range cases {
p := &planter{where: map[string]string{}}
data := p.plant(reflect.ValueOf(c.data()), c.name, false).Interface().(map[string]interface{})
// The secret-carrying keys handlers set (R-249, R-254) and the names a future one would use.
for _, k := range []string{"RetrievalPassword", "RecoveryCode", "Passphrase", "EscrowPassphrase",
"SharePassword", "HubAPIKey", "AgentToken", "ResticPassword", "SessionToken", "PrivateKey"} {
data[k] = p.next(c.name + "." + k)
}
data["InitialCreds"] = &stacks.ExtractedCreds{Available: true, Username: "admin", Password: p.next(c.name + ".InitialCreds.Password")}
planted += len(p.where)
for _, lang := range []string{"hu", "en"} {
html := renderI18nCase(t, s, lang, i18nCase{c.name, c.tmpl, func() map[string]interface{} { return data }})
for sentinel, from := range p.where {
if !strings.Contains(html, sentinel) {
continue
}
if deliberatePreDeployInput(c, data, html, sentinel) {
continue
}
t.Errorf("%s [%s] (page %q): a planted secret reached the HTML — from %s", c.name, lang, c.tmpl, from)
}
}
}
if planted < 200 {
t.Fatalf("only %d values were planted across %d cases — the planter is not reaching the data", planted, len(cases))
}
t.Logf("R-255 sweep: %d pages, %d cases, %d planted values, both languages", len(pages), len(cases), planted)
}
// deliberatePreDeployInput: the ONE place a secret value belongs in a page — the pre-deploy form's hidden
// input, which must submit the generated value the household was shown (README §318). Only on the deploy
// page, only for an app not yet deployed, only in that exact markup and nowhere else in the page.
func deliberatePreDeployInput(c i18nCase, data map[string]interface{}, html, sentinel string) bool {
if c.tmpl != "deploy" {
return false
}
if deployed, _ := data["AlreadyDeployed"].(bool); deployed {
return false
}
q := regexp.QuoteMeta(sentinel)
hidden := regexp.MustCompile(`<input type="hidden" name="[A-Z0-9_]+" value="` + q + `">`)
shown := regexp.MustCompile(`<input type="password" id="auto-field-[A-Z0-9_]+" class="form-control" value="` + q + `" readonly>`)
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)
}
}