Files
felhom-controller/controller/scripts/secret_in_markup_gate.py
T
admin fd28d70bff v0.308.0: controller batch 2026-10-12 — clear-address warning (R-922), secret sweep over every page (R-255), read_failed (R-266), no NVMe heat band (R-333), share globe (R-577), Hungarian dates and numbers (R-562), lost-drive option pinned (R-521)
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
2026-10-12 15:13:59 +02:00

159 lines
8.2 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. Since v0.308.0 (R-255) ONE test does it for EVERY page template:
internal/web/r255_secret_sweep_test.go (TestR255_NoPlantedSecretReachesAnyPage) renders every
parity case of every page, in both languages, with a unique value planted in every secret-named
field of its data, and fails on any that reaches the HTML. Its hole, stated: it plants into the
fixture data, so a HANDLER that copies a secret into a neutral key is caught only if a case
models that handler's data.
So: this is the cheap layer over the templates, plus the render sweep over the pages. 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
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import i18n_bundle # noqa: E402
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",
".FormToken": "the family sign-in form's own CSRF (an HMAC over its expiry, v0.287.0) — the visitor has no "
"session; it opens nothing, it only proves the POST came from the page",
}
def check(path):
# v0.247.0: bundle values carry template actions ({{.RecoveryAbandonDate}}), so a secret-named
# expression can arrive through a translation. Judge every language's expansion; dedupe.
out = []
for lang in i18n_bundle.LANGS:
for c in _check_src(i18n_bundle.read_template(path, lang)):
if c not in out:
out.append(c)
return out
def _check_src(src):
convictions = []
for m in ACTION.finditer(src):
expr = m.group(1).strip()
# A TEMPLATE comment `{{/* ... */}}` is stripped by html/template and never reaches the
# response body, so it cannot leak anything into markup — unlike an HTML `<!-- -->` comment,
# which does ship and is deliberately NOT skipped here. Without this the gate convicted the
# prose explaining a fix, purely for containing the word "secret" (2026-08-10), which is a
# false positive that teaches people to write worse comments or to widen the ALLOWLIST —
# both of which cost more than the check is worth.
if expr.startswith("/*"):
continue
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
# R-421 (2026-09-01): any depth, was os.listdir (one level). No template subdirectory exists
# today, so this was green and correct — and would have stayed green the moment anyone added
# templates/partials/. Measured: a planted file there passed every listdir-based gate.
def _html_at_any_depth(root):
out = []
for dirpath, _dirs, names in os.walk(root):
for fn in sorted(names):
if fn.endswith('.html'):
out.append(os.path.join(dirpath, fn))
return sorted(out)
def main():
if not os.path.isdir(TPL):
print("secret-in-markup gate INCONCLUSIVE: template dir not found: %s" % TPL)
return 2
files = [os.path.relpath(x, TPL) for x in _html_at_any_depth(TPL)]
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())