27d1165962
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.
122 lines
6.3 KiB
Python
122 lines
6.3 KiB
Python
#!/usr/bin/env python3
|
|
"""secret_in_markup_gate.py — a secret must never be rendered into a template.
|
|
|
|
WHY THIS EXISTS. Three instances of ONE pattern shipped in two days, each found by hand:
|
|
|
|
R-249 settings_security.html {{.RetrievalPassword}} inside a display:none span
|
|
R-254 app_info.html {{.InitialCreds.Password}} inside a `hidden` span
|
|
R-254 deploy.html value="{{$val}}" in a readonly type=password input
|
|
|
|
Each was "hidden" by an instruction the browser honours when DRAWING and by nothing else, so the
|
|
plaintext sat in the response body of a page the customer merely opened. **Hiding is not containment.**
|
|
A pattern found three times is not closed by searching a fourth time; it is closed by a check.
|
|
|
|
WHAT THIS GATE DOES. It reads every template and convicts any `{{ … }}` action whose expression names
|
|
a secret (password / secret / token / credential / passphrase / apikey), unless that exact expression
|
|
is on the ALLOWLIST below with a stated reason.
|
|
|
|
⚠ WHAT IT DOES *NOT* DO, STATED PLAINLY SO NOBODY READS IT AS COMPLETE COVERAGE.
|
|
|
|
1. It is NAME-BASED, and the hole was MEASURED rather than guessed at. It catches
|
|
`{{.InitialCreds.Password}}`, and it also catches a launder through a local variable, because the
|
|
ASSIGNMENT names the secret (`{{$v := .InitialCreds.Password}}` is convicted). What it cannot see
|
|
is a secret that arrives under a NEUTRAL PAGE-DATA KEY — `data["Tagline"] = creds.Password` then
|
|
`{{.AppInfo.Tagline}}` passes this gate cleanly. Verified both ways during the 2026-08-08 session.
|
|
The third instance above (`value="{{$val}}"` inside an `{{if eq .Type "secret"}}` branch) is that
|
|
shape: this gate would NOT have caught it.
|
|
2. It reasons about TEMPLATES, not about rendered output. A handler that writes a secret into a
|
|
neutrally-named page-data key is invisible to it.
|
|
3. Runtime body-assertion — rendering a page with a sentinel and grepping the response — is the
|
|
check that catches all three, and it needs each page's data to be constructible. Four pages have
|
|
that today (settings_security, app_info, deploy, backups_restore) and each has its own test; the
|
|
other 23 page templates do NOT. Closing that gap is R-255.
|
|
|
|
So: this is the cheap layer that would have caught two of the three, plus a per-page runtime assertion
|
|
for the pages that can afford one. Together they are not a proof; they are two nets with different
|
|
holes, and the holes are named above.
|
|
"""
|
|
import os
|
|
import re
|
|
import sys
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
CTRL = os.path.dirname(HERE)
|
|
TPL = os.path.join(CTRL, "internal", "web", "templates")
|
|
|
|
SECRETY = re.compile(r"pass(word|phrase)|secret|token|credential|apikey|api_key", re.I)
|
|
ACTION = re.compile(r"\{\{-?\s*(.*?)\s*-?\}\}", re.S)
|
|
|
|
# Expressions that name a secret but are NOT one, each with the reason it is safe. An entry here is a
|
|
# claim someone made; it should be short enough to re-check by eye.
|
|
ALLOWLIST = {
|
|
# booleans / presence flags — the whole point of the R-249 and R-254 fixes
|
|
".HasRetrievalPassword": "boolean: whether one exists, never the value",
|
|
".InitialCredsHasPassword": "boolean: whether one exists, never the value",
|
|
".SharePasswordSet": "boolean: whether a share password is set",
|
|
".PasswordError": "an error MESSAGE for a failed password change, not a password",
|
|
".MinPassword": "the minimum LENGTH policy number",
|
|
# form field names and types, not values
|
|
'eq .Type "password"': "a field TYPE discriminator",
|
|
'eq .Type "secret"': "a field TYPE discriminator",
|
|
'eq .Type "secret_input"': "a field TYPE discriminator",
|
|
'if or .Required (eq .Type "password")': "a field TYPE discriminator",
|
|
'if and (not $isDeployed) (eq .Type "secret")': "guards the PRE-DEPLOY hidden input — a form must "
|
|
"carry what it submits (README §318); see R-254 site two",
|
|
"define \"launcher_share_password\"": "a template name",
|
|
# the CSRF token is not a secret in this sense: it is bound to the session and useless without it,
|
|
# and it MUST be in the form for the form to work.
|
|
".CSRFToken": "CSRF token — session-bound, must be in the page for any POST to work",
|
|
".CSRFField": "CSRF token — same",
|
|
}
|
|
|
|
|
|
def check(path):
|
|
convictions = []
|
|
src = open(path, encoding="utf-8").read()
|
|
for m in ACTION.finditer(src):
|
|
expr = m.group(1).strip()
|
|
if not SECRETY.search(expr):
|
|
continue
|
|
if expr in ALLOWLIST:
|
|
continue
|
|
# `{{if .X}}` / `{{with .X}}` where .X is allowlisted is the same claim as `.X`
|
|
bare = re.sub(r"^(if|with|else if)\s+", "", expr).strip()
|
|
if bare in ALLOWLIST:
|
|
continue
|
|
line = src[: m.start()].count("\n") + 1
|
|
convictions.append((line, expr))
|
|
return convictions
|
|
|
|
|
|
def main():
|
|
if not os.path.isdir(TPL):
|
|
print("secret-in-markup gate INCONCLUSIVE: template dir not found: %s" % TPL)
|
|
return 2
|
|
files = sorted(f for f in os.listdir(TPL) if f.endswith(".html"))
|
|
if not files:
|
|
print("secret-in-markup gate INCONCLUSIVE: no templates found in %s" % TPL)
|
|
return 2
|
|
total = 0
|
|
bad = 0
|
|
for f in files:
|
|
total += 1
|
|
for line, expr in check(os.path.join(TPL, f)):
|
|
bad += 1
|
|
print(" %s:%d renders a secret-named expression into the markup: {{%s}}" % (f, line, expr))
|
|
if bad:
|
|
print()
|
|
print("SECRET-IN-MARKUP GATE FAILED: %d expression(s) across %d template(s)." % (bad, total))
|
|
print("A secret must not be in the response body of a page the customer merely opens —")
|
|
print("hiding it with `hidden` / display:none / type=password stops it being DRAWN and nothing else.")
|
|
print("Fix: carry a BOOLEAN in the page data and fetch the value with an explicit authenticated")
|
|
print("POST that sets Cache-Control: no-store and logs the act (see R-249's and R-254's endpoints).")
|
|
print("If the expression genuinely is not a secret, add it to ALLOWLIST with the reason.")
|
|
return 1
|
|
print("secret-in-markup gate OK — %d templates, no secret-named expression rendered" % total)
|
|
print(" (NAME-BASED: blind to a secret arriving under a neutral PAGE-DATA key — see the docstring)")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|