v0.208.0 — R-254: the last two secrets leave the page source, plus a gate against a fourth
gates / gates (push) Successful in 17s

Site one. app_info.html rendered {{.InitialCreds.Password}} into a hidden span —
a REAL per-install credential, read live out of the running container, in the
response body of every render. The page now carries the non-secret half plus a
boolean; the value comes from POST /apps/<slug>/initial-credentials/reveal, which
RE-READS the container rather than serving a cached copy (caching it in the
handler would put it back in the body one layer in). no-store, CSRF-covered,
logged as an act. Both buttons go through it. A reveal that cannot read the value
SAYS SO rather than returning an empty string that renders as a blank password.

Site two, established before changing. The hidden input is NOT the defect and was
left alone: it fires only pre-deploy, and README §318 documents why the value must
round-trip — the customer notes the generated secrets down and submitting them
back is what makes the saved value the same one they saw. The defect was the
neighbouring READONLY input, which on an ALREADY-DEPLOYED app rendered the secret
into a page with nothing to submit. Fixed by POST /stacks/<name>/auto-field/reveal,
authorised by requiring a type:secret auto-field of that stack. Both directions
pinned.

The premise that this contradicted a repo rule does not hold: the rule is
CONTEXT.md:2070 'Password fields require explicit input — prevents accidental
empty-password deployments', about EMPTINESS. No line in the repo says 'no silent
auto-fill'.

The gate. scripts/secret_in_markup_gate.py, registered in controller_gates.py,
convicts any template expression that names a secret unless allowlisted with a
reason. Its limits are MEASURED and in its docstring: it catches a launder through
a local variable (the assignment names the secret) but is blind to a secret
arriving under a neutral page-data key — verified both ways. That is the shape of
site two, which this gate would NOT have caught. The runtime body assertion covers
all shapes but only 4 of 27 page templates; the other 23 are R-255, filed rather
than glossed. Two nets, different holes, both named.

Correction to v0.207.0's report: HTML comments do NOT ship in the response body
here — html/template strips them, text/template does not. Measured. A red-proof
planting a secret in a comment therefore correctly does not fail.
This commit is contained in:
2026-08-07 21:20:26 +02:00
parent 62998aab4f
commit 27d1165962
12 changed files with 755 additions and 22 deletions
+146 -2
View File
@@ -671,11 +671,29 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug s
// Initial auto-generated login (e.g. Crafty writes a random admin password to a file at first
// boot). Read it live from the container so the customer doesn't have to dig through logs. Only
// for deployed apps that declare an initial_credentials spec; hidden when unreadable.
//
// ⚠ R-254 (v0.208.0) — THE PASSWORD DOES NOT GO INTO THE PAGE DATA, AND THAT IS THE WHOLE FIX.
//
// Until v0.208.0 this handed the whole struct to the template, which rendered the password into
// `<span id="initcred-pw-val" hidden>…</span>`. `hidden` is an attribute the browser honours when
// DRAWING; the plaintext was in the response body of every render, so a `curl` of an app's info
// page returned a real per-install credential. Identical in shape to R-249 one page over, and this
// one is an app the customer actually logs into.
//
// What travels now is the non-secret half (username, note) plus a BOOLEAN. The value is fetched by
// POST /apps/<slug>/initial-credentials/reveal, which re-reads it LIVE from the container — see
// §7.1: caching it here would put it straight back where it started, one layer in.
if found.Deployed && found.Meta.InitialCreds != nil {
if creds, err := s.stackMgr.ReadInitialCredentials(found.Name); err != nil {
if creds, err := s.readInitialCreds(found.Name); err != nil {
s.logger.Printf("[WARN] [web] initial-creds for %s: %v", found.Name, err)
} else if creds != nil && creds.Available {
data["InitialCreds"] = creds
data["InitialCreds"] = &stacks.ExtractedCreds{
Available: creds.Available,
Username: creds.Username,
Note: creds.Note,
// Password deliberately NOT carried — the reveal endpoint is the only path to it.
}
data["InitialCredsHasPassword"] = strings.TrimSpace(creds.Password) != ""
}
}
@@ -1662,6 +1680,132 @@ func (s *Server) settingsRetrievalPasswordRevealHandler(w http.ResponseWriter, r
escrowJSON(w, http.StatusOK, map[string]any{"password": pw}, "")
}
// readInitialCreds is the ONE place an app's generated first-login credential is read — the live
// container read, behind a test seam. Both the info page (which takes only the non-secret half) and
// the reveal endpoint (which takes the value) go through here, so they cannot diverge.
func (s *Server) readInitialCreds(stackName string) (*stacks.ExtractedCreds, error) {
if s.initialCredsFn != nil {
return s.initialCredsFn(stackName)
}
return s.stackMgr.ReadInitialCredentials(stackName)
}
// appAutoFieldRevealHandler — POST /stacks/{name}/auto-field/reveal (v0.208.0, R-254 site two).
//
// WHAT §7.2 ESTABLISHED, AND WHY THIS EXISTS RATHER THAN A CHANGE TO THE HIDDEN INPUT.
//
// The hidden input (`{{if and (not $isDeployed) (eq .Type "secret")}}`) is NOT this defect. It fires
// only on the PRE-DEPLOY form, and README §318 documents why the value must round-trip: the customer
// is shown the generated secrets so they can write them down, and submitting them back is what makes
// the saved value the SAME one they saw ("no silent re-generation on submit"). A form must carry what
// it submits.
//
// The defect is the neighbouring READONLY display input. On an ALREADY-DEPLOYED app the hidden input
// is correctly omitted — nothing is being submitted — yet `<input type="password" … value="{{$val}}"
// readonly>` still renders the secret into the body of a page the customer merely opens. That is
// R-249's shape exactly, with no form to justify it.
//
// PER-SECRET, NOT GENERIC: it serves only fields the CATALOG declares `type: secret` on that stack.
// An env var that is not an auto-generated secret field is refused — that check is the authorisation,
// and it is what stops this becoming "read me any value out of any app's config".
func (s *Server) appAutoFieldRevealHandler(w http.ResponseWriter, r *http.Request, stackName string) {
if s.stackMgr == nil {
escrowJSON(w, http.StatusServiceUnavailable, nil, "Az alkalmazáskezelő nem elérhető.")
return
}
stack, ok := s.stackMgr.GetStack(stackName)
if !ok {
escrowJSON(w, http.StatusNotFound, nil, "Ismeretlen alkalmazás.")
return
}
_ = r.ParseForm()
envVar := strings.TrimSpace(r.FormValue("env_var"))
if envVar == "" {
escrowJSON(w, http.StatusBadRequest, nil, "Hiányzó mező.")
return
}
// AUTHORISATION: the field must be an auto-generated SECRET of this stack's catalog metadata.
allowed := false
for _, f := range stack.Meta.AutoGeneratedFields() {
if f.EnvVar == envVar && f.Type == "secret" {
allowed = true
break
}
}
if !allowed {
s.logger.Printf("[WARN] [web] auto-field reveal refused for %s/%s: not an auto-generated secret field", stackName, envVar)
escrowJSON(w, http.StatusForbidden, nil, "Ez a mező nem kérhető le.")
return
}
appCfg := s.stackMgr.LoadAppConfigByName(stackName)
if appCfg == nil {
escrowJSON(w, http.StatusNotFound, nil, "Az alkalmazás beállításai nem olvashatók.")
return
}
val := crypto.DecryptMap(s.encKey, appCfg.Env)[envVar]
if strings.TrimSpace(val) == "" {
w.Header().Set("Cache-Control", "no-store")
escrowJSON(w, http.StatusNotFound, nil, "Ehhez a mezőhöz nincs mentett érték.")
return
}
s.logger.Printf("[INFO] [web] auto-generated secret revealed for %s/%s from %s (value never logged)", stackName, envVar, clientIP(r))
w.Header().Set("Cache-Control", "no-store")
escrowJSON(w, http.StatusOK, map[string]any{"value": val}, "")
}
// appInitialCredsRevealHandler — POST /apps/{slug}/initial-credentials/reveal (v0.208.0, R-254).
//
// The ONLY path by which an app's generated first-login password reaches a browser. Same shape as
// v0.207.0's retrieval-password reveal, deliberately: POST (so CsrfProtect covers it and it is not
// re-fetchable from history), `no-store`, and **logged as an act** — reading it off the markup left
// no trace anywhere, which is why nobody can say whether any of these was ever read.
//
// ⚠ PER-SECRET, NOT GENERIC. This serves exactly one kind of value for one app. A single endpoint
// that returned any named secret would be a worse thing than the defect it fixed: it would turn three
// narrow exposures into one lever with a parameter.
//
// §7.1 — IT RE-READS THE CONTAINER, it does not serve a copy the page already had. Caching the value
// in the handler's page data would put it back in the response body one layer in, which is the defect.
// The consequence is that the reveal can legitimately fail (container stopped, file deleted after
// first login) and it SAYS SO — an empty string here would render as a blank password and read as
// "your password is empty".
func (s *Server) appInitialCredsRevealHandler(w http.ResponseWriter, r *http.Request, slug string) {
if s.stackMgr == nil {
escrowJSON(w, http.StatusServiceUnavailable, nil, "Az alkalmazáskezelő nem elérhető.")
return
}
// Resolved EXACTLY as appDetailHandler resolves it — same loop, same field. A second definition
// of "which app is this slug" is how a reveal ends up answering for a different app than the page
// the customer is looking at.
var found *stacks.Stack
for _, stack := range s.stackMgr.GetStacks() {
if stack.Meta.Slug == slug {
found = &stack
break
}
}
if found == nil {
escrowJSON(w, http.StatusNotFound, nil, "Ismeretlen alkalmazás.")
return
}
creds, err := s.readInitialCreds(found.Name)
if err != nil {
// Never swallowed, and never surfaced raw — the error can name a container/path.
s.logger.Printf("[WARN] [web] initial-creds reveal for %s: %v", found.Name, err)
escrowJSON(w, http.StatusBadGateway, nil, "A kezdeti jelszó beolvasása nem sikerült.")
return
}
if creds == nil || !creds.Available || strings.TrimSpace(creds.Password) == "" {
w.Header().Set("Cache-Control", "no-store")
escrowJSON(w, http.StatusNotFound, nil,
"A kezdeti jelszó most nem olvasható ki — az alkalmazásnak futnia kell hozzá, és lehet, hogy a fájlt az első bejelentkezés után már törölték.")
return
}
s.logger.Printf("[INFO] [web] initial-credential password revealed for %s from %s (value never logged)", found.Name, clientIP(r))
w.Header().Set("Cache-Control", "no-store")
escrowJSON(w, http.StatusOK, map[string]any{"password": creds.Password}, "")
}
func (s *Server) settingsHandler(w http.ResponseWriter, r *http.Request) {
s.executeTemplate(w, r, "settings_system", s.systemPageData())
}