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
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:
@@ -0,0 +1,202 @@
|
||||
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
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := s.tmpl.ExecuteTemplate(&buf, "app_info", 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).
|
||||
if !strings.Contains(html, "Kezdeti belépési adatok") {
|
||||
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, "Kezdeti belépési adatok") {
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
|
||||
// R-254 site two — WHAT §7.2 ESTABLISHED, PINNED SO IT CANNOT DRIFT BACK.
|
||||
//
|
||||
// The deploy page has TWO places a generated secret can appear, and they are NOT the same question:
|
||||
//
|
||||
// - the PRE-DEPLOY hidden input — a form must carry what it submits. README §318 documents why:
|
||||
// the customer is shown the generated secrets so they can note them down, and submitting them
|
||||
// back is what makes the saved value the SAME one they saw ("no silent re-generation on submit").
|
||||
// This is NOT the defect and is deliberately left alone.
|
||||
// - the READONLY display input on an ALREADY-DEPLOYED app — nothing is being submitted there (the
|
||||
// hidden input is correctly omitted), yet the value was rendered into the body of a page the
|
||||
// customer merely opens. That IS R-249's shape, and it is what v0.208.0 fixes.
|
||||
//
|
||||
// Both directions are asserted, because "fixed" here means one branch changed and the other did not.
|
||||
|
||||
const testDeploySecret = "TESTONLY-generated-db-pw-Xy91"
|
||||
|
||||
func renderDeployPage(t *testing.T, alreadyDeployed bool) string {
|
||||
t.Helper()
|
||||
s := securityHarness(t)
|
||||
s.loadTemplates()
|
||||
data := map[string]interface{}{
|
||||
"Page": "stacks", "Title": "Telepítés", "Domain": "example.hu",
|
||||
"Stack": stacks.Stack{Name: "vaultwarden", Deployed: alreadyDeployed},
|
||||
"Meta": stacks.Metadata{DisplayName: "Vaultwarden", Slug: "vaultwarden"},
|
||||
"AlreadyDeployed": alreadyDeployed,
|
||||
"AutoFields": []stacks.DeployField{
|
||||
{EnvVar: "DB_PASSWORD", Label: "Adatbázis jelszó", Type: "secret"},
|
||||
},
|
||||
"AutoFieldValues": map[string]string{"DB_PASSWORD": testDeploySecret},
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := s.tmpl.ExecuteTemplate(&buf, "deploy", data); err != nil {
|
||||
t.Fatalf("render deploy: %v", err)
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// RED-PROOF: drop the `{{if $isDeployed}}` branch so the deployed page renders `value="{{$val}}"`
|
||||
// again — this fails, showing the secret returning to the body of a page with nothing to submit.
|
||||
func TestDeployPage_DeployedApp_DoesNotCarryTheSecret(t *testing.T) {
|
||||
html := renderDeployPage(t, true)
|
||||
|
||||
if strings.Contains(html, testDeploySecret) {
|
||||
t.Error("R-254 site two: an already-deployed app's generated secret is in the response body " +
|
||||
"of its settings page — nothing there submits it, so there is no form reason for it to " +
|
||||
"be in the page at all")
|
||||
}
|
||||
// Assert the CONTROL, not the URL: the revealAutoField() function ships in the page script on
|
||||
// both variants, so a substring match on the endpoint path matches the script and would report a
|
||||
// control that is not there. (This test caught exactly that on itself.)
|
||||
if !strings.Contains(html, `onclick="revealAutoField('vaultwarden','DB_PASSWORD'`) {
|
||||
t.Error("no reveal control rendered, so the customer cannot see their own generated secret")
|
||||
}
|
||||
// The hidden input must NOT appear on a deployed app — it never did, and that is the asymmetry
|
||||
// that makes the readonly input indefensible there.
|
||||
if strings.Contains(html, `<input type="hidden" name="DB_PASSWORD"`) {
|
||||
t.Error("a submit-carrying hidden input rendered on an already-deployed app")
|
||||
}
|
||||
}
|
||||
|
||||
// The OTHER half of §7.2's answer: the pre-deploy form still carries the value, deliberately. If this
|
||||
// ever starts failing, someone has "fixed" a form by stopping it submitting what it must submit —
|
||||
// which would silently re-generate the secret on save and hand the customer a password that is not
|
||||
// the one they wrote down.
|
||||
func TestDeployPage_PreDeployForm_StillCarriesTheValue_Deliberately(t *testing.T) {
|
||||
html := renderDeployPage(t, false)
|
||||
|
||||
if !strings.Contains(html, `<input type="hidden" name="DB_PASSWORD" value="`+testDeploySecret+`">`) {
|
||||
t.Error("the pre-deploy form no longer submits the generated secret — the saved value would " +
|
||||
"then not be the one the customer was shown (README §318, 'no silent re-generation on submit')")
|
||||
}
|
||||
if strings.Contains(html, `onclick="revealAutoField(`) {
|
||||
t.Error("the deployed-app reveal control leaked onto the pre-deploy form, where the value is " +
|
||||
"already legitimately present")
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -74,6 +74,12 @@ type Server struct {
|
||||
agentCliErr error
|
||||
agentCliOnce sync.Once
|
||||
|
||||
// initialCredsFn is the R-254 read seam for an app's generated first-login credential. nil → the
|
||||
// real live container read (stackMgr.ReadInitialCredentials). ONE definition, used by BOTH the
|
||||
// info page and the reveal endpoint — two ways to read the same secret is how one of them ends up
|
||||
// caching it back into the page.
|
||||
initialCredsFn func(stackName string) (*stacks.ExtractedCreds, error)
|
||||
|
||||
// Hub push status callback — set via SetHubPushStatus for monitoring page
|
||||
hubPushStatusFn func() HubPushStatusData
|
||||
|
||||
@@ -561,6 +567,12 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.offboxConfirmEscrowHandler(w, r)
|
||||
case path == "/backup/offbox/inject-password" && r.Method == http.MethodPost:
|
||||
s.offboxInjectPasswordHandler(w, r)
|
||||
// R-254 site two: an already-deployed app's generated secrets are fetched by an explicit act,
|
||||
// not rendered into the settings page. The PRE-DEPLOY hidden input is untouched and deliberate
|
||||
// (README §318) — see the handler for what §7.2 established.
|
||||
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/auto-field/reveal") && r.Method == http.MethodPost:
|
||||
name := strings.TrimSuffix(strings.TrimPrefix(path, "/stacks/"), "/auto-field/reveal")
|
||||
s.appAutoFieldRevealHandler(w, r, name)
|
||||
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/export"):
|
||||
name := strings.TrimPrefix(path, "/stacks/")
|
||||
name = strings.TrimSuffix(name, "/export")
|
||||
@@ -604,6 +616,12 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, AppPlaceholderSVG)
|
||||
case strings.HasPrefix(path, "/static/assets/"):
|
||||
s.serveAsset(w, r, strings.TrimPrefix(path, "/static/assets/"))
|
||||
// R-254: the app's generated first-login password is fetched by an explicit authenticated act,
|
||||
// never templated into the info page. Placed BEFORE the /apps/ catch-all so the more specific
|
||||
// path wins. POST (not GET) so CsrfProtect covers it and it is not cacheable — see the handler.
|
||||
case strings.HasPrefix(path, "/apps/") && strings.HasSuffix(path, "/initial-credentials/reveal") && r.Method == http.MethodPost:
|
||||
slug := strings.TrimSuffix(strings.TrimPrefix(path, "/apps/"), "/initial-credentials/reveal")
|
||||
s.appInitialCredsRevealHandler(w, r, slug)
|
||||
case strings.HasPrefix(path, "/apps/"):
|
||||
slug := strings.TrimPrefix(path, "/apps/")
|
||||
s.appDetailHandler(w, r, slug)
|
||||
|
||||
@@ -180,11 +180,14 @@ function appMigrate(btn,app,label){
|
||||
{{end}}
|
||||
<tr>
|
||||
<td class="initcred-label" style="padding:.25rem .75rem .25rem 0;color:var(--text-3);white-space:nowrap;vertical-align:middle">Jelszó</td>
|
||||
<!-- R-254: the value is NOT in this page. It used to be rendered into a `hidden`
|
||||
span, which stops a browser drawing it and nothing else — a fetch of this page
|
||||
returned a real per-install password. Both buttons now ask the server. -->
|
||||
<td style="display:flex;align-items:center;gap:.5rem;flex-wrap:wrap">
|
||||
<code id="initcred-pw">••••••••••••</code>
|
||||
<span id="initcred-pw-val" hidden>{{.InitialCreds.Password}}</span>
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="icRevealPw(this)">Megjelenítés</button>
|
||||
<button type="button" class="btn btn-sm btn-outline" id="initcred-reveal" onclick="icRevealPw(this)">Megjelenítés</button>
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="icCopyPw(this)">Másolás</button>
|
||||
<span id="initcred-err" class="form-hint" style="display:none;color:var(--red)"></span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -212,33 +215,53 @@ function appMigrate(btn,app,label){
|
||||
|
||||
{{if .InitialCreds}}
|
||||
<script>
|
||||
// Initial-credential password reveal/copy. The value lives in a hidden element (HTML-escaped by the
|
||||
// template) so it's never inlined into a JS string literal.
|
||||
function icPwVal() {
|
||||
var el = document.getElementById('initcred-pw-val');
|
||||
return el ? el.textContent : '';
|
||||
// R-254: the password is NOT in this page. It is fetched on demand from the server, which re-reads it
|
||||
// live from the running container — so this is the only way it reaches a browser, and every fetch is
|
||||
// recorded server-side. Nothing caches it in a variable between presses: each act asks again.
|
||||
function icFetchPw() {
|
||||
return fetch('/apps/{{.Meta.Slug}}/initial-credentials/reveal', {
|
||||
method: 'POST',
|
||||
headers: {'X-CSRF-Token': '{{.CSRFToken}}'},
|
||||
credentials: 'same-origin'
|
||||
}).then(function (r) { return r.json(); }).then(function (j) {
|
||||
if (!j.ok) { throw new Error(j.error || 'A kezdeti jelszó beolvasása nem sikerült.'); }
|
||||
return j.data.password;
|
||||
});
|
||||
}
|
||||
function icErr(msg) {
|
||||
var e = document.getElementById('initcred-err');
|
||||
e.textContent = msg;
|
||||
e.style.display = 'inline';
|
||||
}
|
||||
function icRevealPw(btn) {
|
||||
var code = document.getElementById('initcred-pw');
|
||||
if (!code) return;
|
||||
if (code.dataset.shown === '1') {
|
||||
document.getElementById('initcred-err').style.display = 'none';
|
||||
if (code.dataset.shown === '1') { // hide: drop the value out of the DOM again
|
||||
code.textContent = '••••••••••••';
|
||||
code.dataset.shown = '0';
|
||||
btn.textContent = 'Megjelenítés';
|
||||
} else {
|
||||
code.textContent = icPwVal();
|
||||
return;
|
||||
}
|
||||
btn.disabled = true;
|
||||
icFetchPw().then(function (pw) {
|
||||
btn.disabled = false;
|
||||
code.textContent = pw;
|
||||
code.dataset.shown = '1';
|
||||
btn.textContent = 'Elrejtés';
|
||||
}
|
||||
}).catch(function (e) { btn.disabled = false; icErr(e.message); });
|
||||
}
|
||||
function icCopyPw(btn) {
|
||||
var val = icPwVal();
|
||||
if (!val) return;
|
||||
navigator.clipboard.writeText(val).then(function () {
|
||||
var orig = btn.textContent;
|
||||
btn.textContent = 'Másolva';
|
||||
setTimeout(function () { btn.textContent = orig; }, 1500);
|
||||
});
|
||||
document.getElementById('initcred-err').style.display = 'none';
|
||||
btn.disabled = true;
|
||||
icFetchPw().then(function (pw) {
|
||||
return navigator.clipboard.writeText(pw).then(function () {
|
||||
btn.disabled = false;
|
||||
var orig = btn.textContent;
|
||||
btn.textContent = 'Másolva';
|
||||
setTimeout(function () { btn.textContent = orig; }, 1500);
|
||||
});
|
||||
}).catch(function (e) { btn.disabled = false; icErr(e.message); });
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
@@ -471,6 +471,7 @@
|
||||
<p class="form-section-desc">Ezek az értékek a telepítéssel együtt mentésre kerülnek. Jegyezze fel a szükséges jelszavakat!</p>
|
||||
{{end}}
|
||||
{{$autoValues := .AutoFieldValues}}
|
||||
{{$stackName := .Stack.Name}}
|
||||
{{$isDeployed := .AlreadyDeployed}}
|
||||
{{range .AutoFields}}
|
||||
{{$val := index $autoValues .EnvVar}}
|
||||
@@ -478,10 +479,21 @@
|
||||
<label>{{.Label}} {{if eq .Type "secret"}}<span class="auto-generated-badge"><svg class="ico ico-sm"><use href="#i-check"/></svg> Automatikusan generálva</span>{{end}}</label>
|
||||
{{if $val}}
|
||||
{{if eq .Type "secret"}}
|
||||
{{if $isDeployed}}
|
||||
<!-- R-254 site two: on an ALREADY-DEPLOYED app nothing is being submitted, so there is
|
||||
no reason for the value to be in this page at all. It is fetched on demand. The
|
||||
pre-deploy branch below is different and deliberate — the form must carry what it
|
||||
submits, so the saved value is the one the customer was shown (README §318). -->
|
||||
<div class="input-with-button">
|
||||
<input type="password" id="auto-field-{{.EnvVar}}" class="form-control" value="" placeholder="••••••••••••" readonly>
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="revealAutoField('{{$stackName}}','{{.EnvVar}}', this)">Megjelenítés</button>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="input-with-button">
|
||||
<input type="password" id="auto-field-{{.EnvVar}}" class="form-control" value="{{$val}}" readonly>
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="toggleAutoField('auto-field-{{.EnvVar}}', this)">Megjelenítés</button>
|
||||
</div>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<input type="text" id="auto-field-{{.EnvVar}}" class="form-control" value="{{$val}}" readonly>
|
||||
{{end}}
|
||||
@@ -743,6 +755,33 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
if (sel) checkStorageSpace(sel);
|
||||
});
|
||||
|
||||
// R-254 site two: on an already-deployed app the value is NOT in this page — it is fetched on
|
||||
// demand, and the server records the act. Nothing caches it between presses.
|
||||
function revealAutoField(stackName, envVar, btn) {
|
||||
var el = document.getElementById('auto-field-' + envVar);
|
||||
if (!el) return;
|
||||
if (btn.dataset.shown === '1') {
|
||||
el.value = '';
|
||||
el.type = 'password';
|
||||
btn.dataset.shown = '';
|
||||
btn.textContent = 'Megjelenítés';
|
||||
return;
|
||||
}
|
||||
btn.disabled = true;
|
||||
fetch('/stacks/' + encodeURIComponent(stackName) + '/auto-field/reveal', {
|
||||
method: 'POST',
|
||||
headers: Object.assign({'Content-Type': 'application/x-www-form-urlencoded'}, csrfHeaders()),
|
||||
credentials: 'same-origin',
|
||||
body: 'env_var=' + encodeURIComponent(envVar)
|
||||
}).then(function (r) { return r.json(); }).then(function (j) {
|
||||
btn.disabled = false;
|
||||
if (!j.ok) { showAlert(j.error || 'A lekérés nem sikerült.'); return; }
|
||||
el.value = j.data.value;
|
||||
el.type = 'text';
|
||||
btn.dataset.shown = '1';
|
||||
btn.textContent = 'Elrejtés';
|
||||
}).catch(function () { btn.disabled = false; showAlert('A lekérés nem sikerült.'); });
|
||||
}
|
||||
function toggleAutoField(fieldId, btn) {
|
||||
var el = document.getElementById(fieldId);
|
||||
if (!el) return;
|
||||
|
||||
Reference in New Issue
Block a user