86a78c6767
gates / gates (push) Successful in 14s
The orphan card told a customer their set-aside off-site history may be restorable later with their recovery code. The discriminator lives on the hub and no wire field carries it, so the box rendering that card cannot evaluate the promise. Copy replaced per the spec: state what happens, decline what we cannot know and say why, name a route. The claim page called the same three-word dashboard code two different names depending on branch, one of which collides with the ten-word escrow code. Retired 'Visszaallito kod'; the name is now constant and the sentence changes. Naming only - a test pins that a reset code is still accepted. secret_in_markup_gate no longer convicts Go template comments, which are stripped before render; still convicts a real rendered secret.
130 lines
6.9 KiB
Python
130 lines
6.9 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()
|
|
# 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
|
|
|
|
|
|
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())
|