#!/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())