e6f3ec2087
- Sparkyfitness: better-auth sign-up/sign-in, a check-in weight stored and read back; a wrong password and an empty date must read as absent. Waits out the app's own 429 (one client address behind traefik). - Rallly: sign-up, the six-digit e-mail code READ (select only) from the app's own verifications row in place of a mailbox, verify-email, sign-in, polls.make, readback by the public polls.get; an unknown id must be not found. - Outline: the self-hosted first-run route installation.create (workspace + admin, refused once a team exists), an API key with Outline's own CSRF pair, a document, readback by documents.info; an unknown id must 404 and a wrong key 401. - outline and rallly leave the NoRoute list: both had a front-door route after all. Measured on the bench (LXC 9401) and on 9202 2026-09-30: felhom.eu/documentation/audits/pg-last-six-2026-09-30/ Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
413 lines
20 KiB
Python
413 lines
20 KiB
Python
# PORTED 2026-09-23 (night shift, R-462) VERBATIM from felhom.eu/documentation/audits/the-28-2026-09-22/
|
|
# fixtures28.py. See upgrade_fixtures_box.py.
|
|
#!/usr/bin/env python3
|
|
"""fixtures28.py — seed/verify for the twenty-eight, same rule as `fixtures.py` (R-156).
|
|
|
|
*Nothing is ever seeded into a volume by hand.* Every seed goes in through the app's OWN interface:
|
|
its HTTP API through the household's real front door, or its own CLI inside its own container. A raw
|
|
SQL INSERT or a planted file is never used.
|
|
|
|
An app with no non-browser route returns None from `seed()` and carries a `tried` string naming
|
|
what was attempted. That is a RESULT — `inconclusive` — not a gap to be papered over.
|
|
|
|
Every `verify()` that can prove itself does so on the same call: it also asks for something that
|
|
MUST be absent, so a readback that has broken into always answering "found" fails instead of
|
|
passing everything.
|
|
"""
|
|
import json, re, secrets
|
|
|
|
|
|
def _gx(w, container, *cmd, timeout=240):
|
|
import shlex
|
|
return w.guest(f"docker exec {container} " + " ".join(shlex.quote(c) for c in cmd)
|
|
+ " 2>&1", timeout=timeout)
|
|
|
|
|
|
# ── the *arr family: their own v3 API, key read from their own config ────────────────────────────
|
|
class _Arr:
|
|
"""radarr / sonarr. The API key is minted by the app into its own config.xml; reading it is
|
|
how a household's own client authenticates, and the tag endpoints are ordinary app data."""
|
|
api = "v3"
|
|
|
|
def _key(self, w):
|
|
out = w.guest(f"docker exec {self.name} cat /config/config.xml 2>/dev/null")
|
|
m = re.search(r"<ApiKey>([0-9a-f]+)</ApiKey>", out or "")
|
|
return m.group(1) if m else None
|
|
|
|
def seed(self, w, sub, say):
|
|
if not w.wait_app(sub, "/", want=("200", "302", "401")):
|
|
return None
|
|
k = self._key(w)
|
|
if not k:
|
|
self.tried = "read ApiKey from the app's own /config/config.xml — not present yet"
|
|
say(f" {self.name}: no ApiKey in config.xml yet")
|
|
return None
|
|
label = "drill" + secrets.token_hex(4)
|
|
rc, code, out = w.app_curl(sub, f"/api/{self.api}/tag", "-H", f"X-Api-Key: {k}",
|
|
"-H", "Content-Type: application/json",
|
|
data=json.dumps({"label": label}), method="POST")
|
|
if code not in ("200", "201", "202"):
|
|
self.tried = f"POST /api/{self.api}/tag with the app's own key -> {code}"
|
|
say(f" {self.name}: POST tag -> {code} {out[:150]}")
|
|
return None
|
|
say(f" {self.name}: seeded tag {label}")
|
|
return {"label": label, "key": k}
|
|
|
|
def verify(self, w, sub, t, say):
|
|
k = self._key(w) or t["key"]
|
|
rc, code, out = w.app_curl(sub, f"/api/{self.api}/tag", "-H", f"X-Api-Key: {k}")
|
|
found = t["label"] in (out or "")
|
|
# negative control, EVERY call: a label that cannot exist must read as absent
|
|
absent = ("drillnope" + secrets.token_hex(6)) not in (out or "")
|
|
if not absent:
|
|
say(f" {self.name}: READBACK UNUSABLE — an impossible label read as present")
|
|
return None
|
|
say(f" {self.name}: readback found={found} (http {code}, control passed)")
|
|
return found
|
|
|
|
|
|
class Radarr(_Arr):
|
|
name = "radarr"; sub = "radarr"; route = "its own /api/v3/tag with the app's own ApiKey"
|
|
|
|
|
|
class Sonarr(_Arr):
|
|
name = "sonarr"; sub = "sonarr"; route = "its own /api/v3/tag with the app's own ApiKey"
|
|
|
|
|
|
# ── kimai — its own console, the route the app documents ─────────────────────────────────────────
|
|
class Kimai:
|
|
sub = "kimai"; route = "its own `bin/console kimai:user:create`"
|
|
|
|
def seed(self, w, sub, say):
|
|
u = "drill" + secrets.token_hex(4)
|
|
out = _gx(w, "kimai", "/opt/kimai/bin/console", "kimai:user:create", u,
|
|
f"{u}@example.invalid", "ROLE_USER", "Drill-" + secrets.token_hex(6) + "!aA")
|
|
if "success" not in (out or "").lower() and "created" not in (out or "").lower():
|
|
self.tried = "its own `bin/console kimai:user:create` -> " + (out or "")[:200]
|
|
say(f" kimai: console create said: {(out or '')[:200]}")
|
|
return None
|
|
say(f" kimai: seeded user {u}")
|
|
return {"user": u}
|
|
|
|
def verify(self, w, sub, t, say):
|
|
out = _gx(w, "kimai", "/opt/kimai/bin/console", "kimai:user:list") or ""
|
|
found = t["user"] in out
|
|
absent = ("nope" + secrets.token_hex(6)) not in out
|
|
if not absent:
|
|
say(" kimai: READBACK UNUSABLE — an impossible user read as present")
|
|
return None
|
|
say(f" kimai: readback found={found} (control passed)")
|
|
return found
|
|
|
|
|
|
# ── gramps-web — its own CLI ─────────────────────────────────────────────────────────────────────
|
|
class GrampsWeb:
|
|
sub = "gramps"; route = "its own `python3 -m gramps_webapi user add`"
|
|
|
|
def seed(self, w, sub, say):
|
|
u = "drill" + secrets.token_hex(4)
|
|
out = _gx(w, "gramps-web", "python3", "-m", "gramps_webapi", "--config",
|
|
"/app/config/config.cfg", "user", "add", u, "Drill-" + secrets.token_hex(6))
|
|
if "error" in (out or "").lower() or "traceback" in (out or "").lower():
|
|
self.tried = "its own `gramps_webapi user add` -> " + (out or "")[:200]
|
|
say(f" gramps-web: {(out or '')[:200]}")
|
|
return None
|
|
say(f" gramps-web: seeded user {u}")
|
|
return {"user": u}
|
|
|
|
def verify(self, w, sub, t, say):
|
|
out = _gx(w, "gramps-web", "python3", "-m", "gramps_webapi", "--config",
|
|
"/app/config/config.cfg", "user", "list") or ""
|
|
found = t["user"] in out
|
|
absent = ("nope" + secrets.token_hex(6)) not in out
|
|
if not absent:
|
|
say(" gramps-web: READBACK UNUSABLE")
|
|
return None
|
|
say(f" gramps-web: readback found={found} (control passed)")
|
|
return found
|
|
|
|
|
|
# ── homebox — its own registration + item API ────────────────────────────────────────────────────
|
|
class Homebox:
|
|
sub = "homebox"; route = "its own /api/v1/users/register + /api/v1/locations"
|
|
|
|
def seed(self, w, sub, say):
|
|
if not w.wait_app(sub, "/", want=("200", "302")):
|
|
return None
|
|
u = "drill" + secrets.token_hex(4) + "@example.invalid"
|
|
pw = "Drill-" + secrets.token_hex(8) + "!aA"
|
|
rc, code, out = w.app_curl(sub, "/api/v1/users/register", "-H", "Content-Type: application/json",
|
|
data=json.dumps({"name": "drill", "email": u, "password": pw}),
|
|
method="POST")
|
|
if code not in ("200", "201", "204"):
|
|
self.tried = f"POST /api/v1/users/register -> {code} {out[:150]}"
|
|
say(f" homebox: register -> {code} {out[:150]}")
|
|
return None
|
|
rc, code, out = w.app_curl(sub, "/api/v1/users/login", "-H", "Content-Type: application/json",
|
|
data=json.dumps({"username": u, "password": pw}), method="POST")
|
|
try:
|
|
tokv = json.loads(out)["token"]
|
|
except Exception:
|
|
self.tried = f"POST /api/v1/users/login -> {code} {out[:150]}"
|
|
say(f" homebox: login -> {code} {out[:150]}")
|
|
return None
|
|
name = "drillloc" + secrets.token_hex(4)
|
|
rc, code, out = w.app_curl(sub, "/api/v1/locations", "-H", f"Authorization: {tokv}",
|
|
"-H", "Content-Type: application/json",
|
|
data=json.dumps({"name": name, "description": "drill"}),
|
|
method="POST")
|
|
if code not in ("200", "201"):
|
|
self.tried = f"POST /api/v1/locations -> {code} {out[:150]}"
|
|
say(f" homebox: create location -> {code} {out[:150]}")
|
|
return None
|
|
say(f" homebox: seeded location {name}")
|
|
return {"name": name, "tok": tokv, "u": u, "pw": pw}
|
|
|
|
def verify(self, w, sub, t, say):
|
|
rc, code, out = w.app_curl(sub, "/api/v1/users/login", "-H", "Content-Type: application/json",
|
|
data=json.dumps({"username": t["u"], "password": t["pw"]}),
|
|
method="POST")
|
|
try:
|
|
tokv = json.loads(out)["token"]
|
|
except Exception:
|
|
tokv = t["tok"]
|
|
rc, code, out = w.app_curl(sub, "/api/v1/locations", "-H", f"Authorization: {tokv}")
|
|
found = t["name"] in (out or "")
|
|
absent = ("nope" + secrets.token_hex(6)) not in (out or "")
|
|
if not absent:
|
|
say(" homebox: READBACK UNUSABLE")
|
|
return None
|
|
say(f" homebox: readback found={found} (http {code}, control passed)")
|
|
return found
|
|
|
|
|
|
FIXTURES28 = {
|
|
"radarr": Radarr(), "sonarr": Sonarr(), "kimai": Kimai(),
|
|
"gramps-web": GrampsWeb(), "homebox": Homebox(),
|
|
}
|
|
|
|
|
|
# ── apps whose front door is a SIGN-UP or SETUP call ─────────────────────────────────────────────
|
|
def _neg(w, sub, path, hdr, say, name):
|
|
"""The negative control every verify() runs: something that CANNOT exist must read absent."""
|
|
rc, code, out = w.app_curl(sub, path, *hdr)
|
|
return ("nope" + secrets.token_hex(6)) not in (out or ""), out, code
|
|
|
|
|
|
class Termix:
|
|
sub = "termix"; route = "its own /users/create sign-up, then /users/me"
|
|
|
|
def seed(self, w, sub, say):
|
|
if not w.wait_app(sub, "/", want=("200", "302")):
|
|
return None
|
|
u = "drill" + secrets.token_hex(4)
|
|
pw = "Drill-" + secrets.token_hex(8) + "!aA"
|
|
for p in ("/users/create", "/api/users/create", "/users/register"):
|
|
rc, code, out = w.app_curl(sub, p, "-H", "Content-Type: application/json",
|
|
data=json.dumps({"username": u, "password": pw}),
|
|
method="POST")
|
|
if code in ("200", "201"):
|
|
say(f" termix: seeded user {u} via {p}")
|
|
return {"u": u, "pw": pw, "path": p}
|
|
self.tried = "POST /users/create, /api/users/create, /users/register — none accepted"
|
|
say(f" termix: no sign-up route accepted (last {code} {out[:120]})")
|
|
return None
|
|
|
|
def verify(self, w, sub, t, say):
|
|
rc, code, out = w.app_curl(sub, "/users/login", "-H", "Content-Type: application/json",
|
|
data=json.dumps({"username": t["u"], "password": t["pw"]}),
|
|
method="POST")
|
|
found = code in ("200", "201") and ("token" in (out or "") or t["u"] in (out or ""))
|
|
rc2, code2, out2 = w.app_curl(sub, "/users/login", "-H", "Content-Type: application/json",
|
|
data=json.dumps({"username": "nope" + secrets.token_hex(6),
|
|
"password": t["pw"]}), method="POST")
|
|
if code2 in ("200", "201"):
|
|
say(" termix: READBACK UNUSABLE — an impossible user logged in")
|
|
return None
|
|
say(f" termix: readback found={found} (http {code}, control refused as it must)")
|
|
return found
|
|
|
|
|
|
class Ghost:
|
|
sub = "blog"; route = "its own /ghost/api/admin/authentication/setup/"
|
|
|
|
def seed(self, w, sub, say):
|
|
if not w.wait_app(sub, "/", want=("200", "301", "302")):
|
|
return None
|
|
title = "Drill-" + secrets.token_hex(6)
|
|
u = "drill" + secrets.token_hex(4) + "@example.invalid"
|
|
pw = "Drill-" + secrets.token_hex(8) + "aA1"
|
|
body = json.dumps({"setup": [{"name": "Drill", "email": u, "password": pw,
|
|
"blogTitle": title}]})
|
|
rc, code, out = w.app_curl(sub, "/ghost/api/admin/authentication/setup/",
|
|
"-H", "Content-Type: application/json",
|
|
"-H", "Accept-Version: v5.0", data=body, method="POST")
|
|
if code not in ("200", "201"):
|
|
self.tried = f"POST /ghost/api/admin/authentication/setup/ -> {code} {out[:150]}"
|
|
say(f" ghost: setup -> {code} {out[:160]}")
|
|
return None
|
|
say(f" ghost: seeded site title {title}")
|
|
return {"title": title, "u": u}
|
|
|
|
def verify(self, w, sub, t, say):
|
|
rc, code, out = w.app_curl(sub, "/", "-L")
|
|
found = t["title"] in (out or "")
|
|
absent = ("Drill-nope" + secrets.token_hex(6)) not in (out or "")
|
|
if not absent:
|
|
say(" ghost: READBACK UNUSABLE")
|
|
return None
|
|
say(f" ghost: readback found={found} (http {code}, control passed)")
|
|
return found
|
|
|
|
|
|
class Komga:
|
|
sub = "komga"; route = "its own POST /api/v1/claim, then GET /api/v2/users/me"
|
|
|
|
def seed(self, w, sub, say):
|
|
if not w.wait_app(sub, "/", want=("200", "302", "401")):
|
|
return None
|
|
u = "drill" + secrets.token_hex(4) + "@example.invalid"
|
|
pw = "Drill-" + secrets.token_hex(8)
|
|
rc, code, out = w.app_curl(sub, "/api/v1/claim", "-H", f"X-Komga-Email: {u}",
|
|
"-H", f"X-Komga-Password: {pw}", method="POST")
|
|
if code not in ("200", "201"):
|
|
self.tried = f"POST /api/v1/claim -> {code} {out[:150]}"
|
|
say(f" komga: claim -> {code} {out[:150]}")
|
|
return None
|
|
say(f" komga: claimed the server as {u}")
|
|
return {"u": u, "pw": pw}
|
|
|
|
def verify(self, w, sub, t, say):
|
|
import base64 as _b
|
|
a = _b.b64encode(f"{t['u']}:{t['pw']}".encode()).decode()
|
|
rc, code, out = w.app_curl(sub, "/api/v2/users/me", "-H", f"Authorization: Basic {a}") # v2 since komga 1.x; v1 answers 404 (measured 2026-09-23)
|
|
found = code == "200" and t["u"] in (out or "")
|
|
bad = _b.b64encode(f"nope{secrets.token_hex(6)}:{t['pw']}".encode()).decode()
|
|
rc2, code2, _ = w.app_curl(sub, "/api/v2/users/me", "-H", f"Authorization: Basic {bad}")
|
|
if code2 == "200":
|
|
say(" komga: READBACK UNUSABLE — an impossible user authenticated")
|
|
return None
|
|
say(f" komga: readback found={found} (http {code}, control refused {code2})")
|
|
return found
|
|
|
|
|
|
class Immich:
|
|
sub = "photos"; route = "its own /api/auth/admin-sign-up, then an album"
|
|
|
|
def seed(self, w, sub, say):
|
|
if not w.wait_app(sub, "/", want=("200", "302"), tries=90):
|
|
return None
|
|
u = "drill" + secrets.token_hex(4) + "@example.invalid"
|
|
pw = "Drill-" + secrets.token_hex(8)
|
|
rc, code, out = w.app_curl(sub, "/api/auth/admin-sign-up", "-H", "Content-Type: application/json",
|
|
data=json.dumps({"email": u, "password": pw, "name": "Drill"}),
|
|
method="POST")
|
|
if code not in ("200", "201"):
|
|
self.tried = f"POST /api/auth/admin-sign-up -> {code} {out[:150]}"
|
|
say(f" immich: sign-up -> {code} {out[:160]}")
|
|
return None
|
|
rc, code, out = w.app_curl(sub, "/api/auth/login", "-H", "Content-Type: application/json",
|
|
data=json.dumps({"email": u, "password": pw}), method="POST")
|
|
try:
|
|
at = json.loads(out)["accessToken"]
|
|
except Exception:
|
|
self.tried = f"POST /api/auth/login -> {code} {out[:150]}"
|
|
return None
|
|
name = "drillalbum" + secrets.token_hex(4)
|
|
rc, code, out = w.app_curl(sub, "/api/albums", "-H", f"Authorization: Bearer {at}",
|
|
"-H", "Content-Type: application/json",
|
|
data=json.dumps({"albumName": name}), method="POST")
|
|
if code not in ("200", "201"):
|
|
self.tried = f"POST /api/albums -> {code} {out[:150]}"
|
|
say(f" immich: album -> {code} {out[:150]}")
|
|
return None
|
|
say(f" immich: seeded album {name}")
|
|
return {"name": name, "u": u, "pw": pw}
|
|
|
|
def verify(self, w, sub, t, say):
|
|
rc, code, out = w.app_curl(sub, "/api/auth/login", "-H", "Content-Type: application/json",
|
|
data=json.dumps({"email": t["u"], "password": t["pw"]}),
|
|
method="POST")
|
|
try:
|
|
at = json.loads(out)["accessToken"]
|
|
except Exception:
|
|
say(f" immich: could not log back in (http {code})")
|
|
return False
|
|
rc, code, out = w.app_curl(sub, "/api/albums", "-H", f"Authorization: Bearer {at}")
|
|
found = t["name"] in (out or "")
|
|
absent = ("nope" + secrets.token_hex(6)) not in (out or "")
|
|
if not absent:
|
|
say(" immich: READBACK UNUSABLE")
|
|
return None
|
|
say(f" immich: readback found={found} (http {code}, control passed)")
|
|
return found
|
|
|
|
|
|
class _MediaServer:
|
|
"""jellyfin / emby — the startup wizard IS the front door on a fresh install."""
|
|
def seed(self, w, sub, say):
|
|
if not w.wait_app(sub, "/", want=("200", "302"), tries=90):
|
|
return None
|
|
u = "drill" + secrets.token_hex(4)
|
|
pw = "Drill-" + secrets.token_hex(8)
|
|
rc, code, out = w.app_curl(sub, "/Startup/User", "-H", "Content-Type: application/json",
|
|
data=json.dumps({"Name": u, "Password": pw}), method="POST")
|
|
if code not in ("200", "204"):
|
|
self.tried = f"POST /Startup/User -> {code} {out[:150]}"
|
|
say(f" {self.name}: /Startup/User -> {code} {out[:150]}")
|
|
return None
|
|
w.app_curl(sub, "/Startup/Complete", method="POST")
|
|
say(f" {self.name}: seeded first user {u}")
|
|
return {"u": u}
|
|
|
|
def verify(self, w, sub, t, say):
|
|
rc, code, out = w.app_curl(sub, "/Users/Public")
|
|
found = t["u"] in (out or "")
|
|
absent = ("nope" + secrets.token_hex(6)) not in (out or "")
|
|
if not absent:
|
|
say(f" {self.name}: READBACK UNUSABLE")
|
|
return None
|
|
say(f" {self.name}: readback found={found} (http {code}, control passed)")
|
|
return found
|
|
|
|
|
|
class Jellyfin(_MediaServer):
|
|
name = "jellyfin"; sub = "jellyfin"; route = "its own /Startup/User wizard, then /Users/Public"
|
|
|
|
|
|
class Emby(_MediaServer):
|
|
name = "emby"; sub = "emby"; route = "its own /Startup/User wizard, then /Users/Public"
|
|
|
|
|
|
class NoRoute:
|
|
"""An app whose only way in is a browser. The fixture RUNS, states what it tried, and returns
|
|
None. `inconclusive` with the attempts named is a result; a blank is not."""
|
|
def __init__(self, name, sub, tried):
|
|
self.name, self.sub, self.tried = name, sub, tried
|
|
self.route = "none — " + tried
|
|
|
|
def seed(self, w, sub, say):
|
|
w.wait_app(sub, "/", want=("200", "301", "302", "401", "403"), tries=30)
|
|
say(f" {self.name}: no non-browser seed route — {self.tried}")
|
|
return None
|
|
|
|
def verify(self, w, sub, t, say):
|
|
return False
|
|
|
|
|
|
FIXTURES28.update({
|
|
"termix": Termix(), "ghost": Ghost(), "komga": Komga(), "immich": Immich(),
|
|
"jellyfin": Jellyfin(), "emby": Emby(),
|
|
"code-server": NoRoute("code-server", "code", "its front door is a browser IDE behind one "
|
|
"password; it exposes no data API, and writing a file with docker exec "
|
|
"would not be the front door (R-156)"),
|
|
"onlyoffice": NoRoute("onlyoffice", "office", "a stateless document server: it holds no "
|
|
"household data of its own, so there is nothing to seed"),
|
|
"homepage": NoRoute("homepage", "home", "a dashboard rendered from config files in the "
|
|
"template; it stores no household data"),
|
|
"plex": NoRoute("plex", "plex", "the first-run claim needs a token minted at plex.tv by a "
|
|
"real Plex account; no account exists for this venue"),
|
|
# outline and rallly WERE NoRoute here until 2026-09-30: both have a front-door first-run route after all
|
|
# (outline `installation.create`, rallly sign-up + its own e-mail code) — the fixtures are in upgrade_fixtures_box.py.
|
|
})
|