fd28d70bff
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
264 lines
10 KiB
Go
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)
|
|
}
|
|
}
|