Files
admin 977665d8c0
gates / gates (push) Successful in 27s
The family gate (decisions 63/64, R-780): family members with their own logins, a permanent forwardAuth door per family app, anchored exceptions, min_controller
- internal/family: the family list (bcrypt, generated 4x4 passwords shown once) + 30-day sessions in family.json
  (0600, atomic); a reset (generation), a removal or a logout ends sessions at the next request.
- internal/stacks/family_gate.go: family_gate / family_gate_except / min_controller in .felhom.yml; the door is written
  BEFORE the first start (install and a removed app's restore), a life record in app.yaml, reconciled by the gate loop;
  priority below the install hold, setup gate and sign-up block; every exception anchored ^/prefix(/|$) (finding F1).
- internal/web/family_gate.go: forwardAuth /__felhom_gate/family (app cookie felhom_famgate, host-only, names a store
  session); /__family/start|login|logout on the dashboard host (session cookie felhom_family, Path=/__family);
  sign-in counted per visitor (clientIP) AND per name, short windows; the household's dashboard session vouches.
  RequireAuth never reads a family cookie. The "Család" card on the security page: add / new password / remove.
Red-proofs RP-F1..RP-F7 (felhom.eu audits/family-gate-2026-10-02/A/).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-02 07:42:47 +02:00

157 lines
8.0 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
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())