Files
felhom.eu/documentation/audits/signup-lock-2026-09-29/B/tricks.py
T

88 lines
6.4 KiB
Python

# Part B: a stranger's sign-up through the public route (traefik), each sign-up route of the app in 7 shapes:
# as written, trailing slash, UPPER, Mixed, one letter percent-encoded, a double slash, a query string.
# Each answer: BLOCK (the box's "sign-up is closed"), APP-REFUSED (the app's own refusal), or GOT-IN (an account was
# made / the form was served). A GOT-IN is a hole.
import json, re, secrets, sys, urllib.parse
sys.path.insert(0, '.')
import ro
from ro import w, host
from browser import Browser
def pw(): return "Xx" + secrets.token_hex(8) + "9!"
def jn(n): return {"username": n, "email": n + "@x.hu", "password": pw()}
FORM = {"Content-Type": "application/x-www-form-urlencoded"}
# app -> list of (label, method, path, body-maker, headers, got-in test)
ROUTES = {
"gitea": [("web form", "GET", "/user/sign_up", None, {}, lambda st, t: st == 200 and 'name="user_name"' in t)],
"calcom": [("API", "POST", "/api/auth/signup", lambda n: dict(jn(n)), {}, lambda st, t: st in (200, 201)),
("web page", "GET", "/signup", None, {}, lambda st, t: st == 200 and "Signup is disabled" not in t and "sign-up is closed" not in t and "nem lehet regisztr" not in t and 'name="password"' in t)],
"gramps-web": [("API", "POST", "/api/users/{n}/register/", lambda n: {"email": n + "@x.hu", "password": pw(), "full_name": "S"}, {}, lambda st, t: st in (200, 201))],
"homebox": [("API", "POST", "/api/v1/users/register", lambda n: {"name": n, "email": n + "@x.hu", "password": pw()}, {}, lambda st, t: st in (200, 201, 204))],
"adventurelog": [("web form action", "POST", "/signup", lambda n: urllib.parse.urlencode({"username": n, "email": n + "@x.hu", "password1": "Xx12345678z9!", "password2": "Xx12345678z9!", "first_name": "S", "last_name": "S"}),
dict(FORM, **{"x-sveltekit-action": "true"}), lambda st, t: '"type":"redirect"' in t and '"location":"/"' in t),
("allauth API", "POST", "/auth/browser/v1/auth/signup", lambda n: jn(n), {}, lambda st, t: st in (200, 201)),
("allauth classic", "POST", "/accounts/signup/", lambda n: urllib.parse.urlencode({"username": n, "email": n + "@x.hu", "password1": "Xx12345678z9!", "password2": "Xx12345678z9!"}), FORM, lambda st, t: st in (200, 302) and "sign-up is closed" not in t and "nem lehet" not in t and "CSRF" not in t and "Forbidden" not in t)],
"papra": [("API", "POST", "/api/auth/sign-up/email", lambda n: {"email": n + "@x.hu", "password": pw(), "name": n}, {}, lambda st, t: st in (200, 201) and "token" in t)],
"sparkyfitness": [("API", "POST", "/api/auth/sign-up/email", lambda n: {"email": n + "@x.hu", "password": pw(), "name": n}, {}, lambda st, t: st in (200, 201) and "token" in t)],
"termix": [("API", "POST", "/users/create", lambda n: {"username": n, "password": pw()}, {}, lambda st, t: st in (200, 201) and "User created" in t)],
"vikunja": [("API", "POST", "/api/v1/register", lambda n: jn(n), {}, lambda st, t: st in (200, 201) and '"id"' in t),
("web page", "GET", "/register", None, {}, lambda st, t: False)],
"opengist": [("web form", "POST", "/-/register", "FORM-CSRF", {}, None)],
"wishlist": [("web form action", "POST", "/signup", lambda n: urllib.parse.urlencode({"name": "S", "username": n, "email": n + "@x.hu", "password": "Xx12345678z9!", "confirmPassword": "Xx12345678z9!"}),
dict(FORM, **{"x-sveltekit-action": "true"}), lambda st, t: '"type":"success"' in t)],
}
def variants(path):
base = path.rstrip("/")
segs = base.split("/")
last = segs[-1] or segs[-2]
enc = last[0] + "%" + format(ord(last[1]), "02X") + last[2:] if len(last) > 2 else last
mixed = "/".join(s[:1].upper() + s[1:] for s in segs)
lastup = "/".join(segs[:-1] + [segs[-1].upper()])
out = [("as written", path), ("trailing slash", base + "/"), ("UPPER", base.upper()), ("Mixed", mixed), ("last part UPPER", lastup),
("percent-encoded", "/".join(segs[:-1] + [enc])), ("double slash", "/" + base), ("query string", base + "?x=1")]
seen, res = set(), []
for k, p in out:
if p not in seen:
seen.add(p); res.append((k, p))
return res
def classify(st, t, got, m="POST"):
if "sign-up is closed" in t or "nem lehet regisztr" in t or "Sign-up closed" in t:
return "BLOCK"
# A POST answered by the app's HTML shell (its fallback for an unknown address) made no account: the app's own
# router is case-sensitive, so the tricked address never reached its sign-up code. Measured 2026-09-29: the
# homebox login of such a name answers "unauthorized".
if m == "POST" and t.lstrip()[:15].lower().startswith(("<!doctype", "<html")) and got(st, t):
return "APP-FALLBACK (no account)"
if got(st, t):
return "GOT-IN"
return "APP-REFUSED"
w.login()
for app in sys.argv[1:]:
for label, m, path, body, hd, got in ROUTES[app]:
for kind, p in variants(path):
n = "trk" + secrets.token_hex(3)
p2 = p.replace("{n}", n).replace("{N}", n.upper())
s = Browser("stranger")
h = dict(hd); h["Origin"] = f"https://{host(app)}"
if body == "FORM-CSRF": # opengist: fetch the form (its CSRF) at the same shape, then post it
st0, html, _ = s.req(f"https://{host(app)}{p2}")
mm = re.search(r'name="_csrf" value="([^"]+)"', html)
if not mm:
verdict = "BLOCK" if ("nem lehet regisztr" in html or "sign-up is closed" in html) else "APP-REFUSED"
print(f"{app:14} {label:16} {kind:16} {p2:42} -> {st0} {verdict} (no form served)")
continue
b = urllib.parse.urlencode({"username": n, "password": "Xx12345678z9!", "_csrf": mm.group(1)})
st, t, hops = s.req(f"https://{host(app)}{p2}", "POST", body=b, headers=dict(FORM, Origin=h["Origin"]))
gotin = st == 200 and hops[-1][2] in ("/", "/-/all") and len(hops) > 1
verdict = "BLOCK" if ("nem lehet regisztr" in t or "sign-up is closed" in t) else ("GOT-IN" if gotin else "APP-REFUSED")
print(f"{app:14} {label:16} {kind:16} {p2:42} -> {st} {verdict}")
continue
b = body(n) if callable(body) else None
accept = "text/html" if m == "GET" else "application/json"
st, t, _ = s.req(f"https://{host(app)}{p2}", m, body=b, accept=accept, headers=h, follow=(m == "GET"))
print(f"{app:14} {label:16} {kind:16} {p2:42} -> {st} {classify(st, t, got, m)}")