ungleich-user/dal/tests/test_flows.py
Sajeyks b44e2ffcf4 Escape user input in LDAP filters and DNs
The uid was formatted straight into search filters, so a password
reset for user '*' matched and changed the first entry in the tree
(staff accounts included). Escape filter values with
escape_filter_chars and DN values with escape_rdn.

Also fix the username pattern: [a-z|0-9|\-|_] allowed '|'.
2026-11-06 15:01:06 +03:00

347 lines
15 KiB
Python

"""
End-to-end behaviour of the account webapp against a real LDAP server.
Tests decorated with @expectedFailure document known bugs found during the
upgrade audit. When a bug is fixed the test starts passing, Django reports an
"unexpected success", and the decorator must be removed.
"""
from unittest import expectedFailure
from django.core import mail
from .base import LdapTestCase, unique_uid
REGISTER = "/register/"
RESET = "/resetpassword/"
class LoginTests(LdapTestCase):
def test_landing_page_renders(self):
resp = self.client.get("/")
self.assertEqual(resp.status_code, 200)
self.assertTemplateUsed(resp, "landing.html")
def test_customer_can_log_in(self):
uid = self.make_ldap_user(password="cust-pass-123")
resp = self.client.post("/index/", {"username": uid, "password": "cust-pass-123"})
self.assertTemplateUsed(resp, "useroptions.html")
self.assertIn("_auth_user_id", self.client.session)
def test_staff_in_ou_users_can_log_in(self):
uid = self.make_ldap_user(password="staff-pass-123", ou="users")
resp = self.client.post("/index/", {"username": uid, "password": "staff-pass-123"})
self.assertTemplateUsed(resp, "useroptions.html")
def test_wrong_password_is_rejected(self):
uid = self.make_ldap_user(password="cust-pass-123")
resp = self.client.post("/index/", {"username": uid, "password": "nope"})
self.assertNotIn("_auth_user_id", self.client.session)
self.assertContains(resp, "incorrect")
def test_logged_in_user_gets_options_page(self):
uid = self.make_ldap_user(password="cust-pass-123")
self.login(uid, "cust-pass-123")
self.assertTemplateUsed(self.client.get("/"), "useroptions.html")
def test_logout(self):
uid = self.make_ldap_user(password="cust-pass-123")
self.login(uid, "cust-pass-123")
resp = self.client.get("/logout/")
self.assertEqual(resp.status_code, 200)
self.assertNotIn("_auth_user_id", self.client.session)
class RegisterTests(LdapTestCase):
def form(self, **overrides):
data = {"username": unique_uid("reg"), "password1": "reg-pass-12345",
"password2": "reg-pass-12345", "email": "reg@example.com",
"firstname": "Rita", "lastname": "Register"}
data.update(overrides)
return data
def assert_error(self, data, text):
resp = self.client.post(REGISTER, data)
self.assertTemplateUsed(resp, "error.html")
self.assertContains(resp, text)
self.assertEqual(len(mail.outbox), 0)
def test_form_renders(self):
self.assertTemplateUsed(self.client.get(REGISTER), "registeruser.html")
def test_validation_errors(self):
self.assert_error(self.form(username=""), "Please supply a username")
self.assert_error(self.form(username="Bad Name"), "lowercase letters")
self.assert_error(self.form(username="pipe|name"), "lowercase letters")
self.assert_error(self.form(password2="different"), "match")
self.assert_error(self.form(email="not-an-email"), "email address is invalid")
self.assert_error(self.form(lastname=""), "firstname and lastname")
def test_full_registration_and_activation(self):
data = self.form()
uid = data["username"]
self.track(uid)
resp = self.client.post(REGISTER, data)
self.assertTemplateUsed(resp, "confirm_email.html")
self.assertEqual(len(mail.outbox), 1)
self.assertIsNone(self.ldap_entry(uid), "user must not exist before activation")
link = self.last_mail_link()
resp = self.client.get(link)
self.assertTemplateUsed(resp, "usercreated.html")
entry = self.ldap_entry(uid)
self.assertIsNotNone(entry)
self.assertEqual(entry.mail.value, "reg@example.com")
self.assertEqual(entry.givenName.value, "Rita")
self.assertEqual(int(entry.gidNumber.value), 2000)
self.assertIn("ldapPublicKey", entry.objectClass.values)
self.assertTrue(entry.userPassword.value.startswith(b"{SSHA}"))
self.assertTrue(self.can_bind(uid, "reg-pass-12345"))
self.otp_post.assert_called_once() # OTP seed requested for new user
# Activation link is single use
self.assertEqual(self.client.get(link).status_code, 404)
def test_tampered_activation_token_is_rejected(self):
data = self.form()
self.track(data["username"])
self.client.post(REGISTER, data)
link = self.last_mail_link().rstrip("/")
bad = link.rsplit("/", 1)[0] + "/not-the-token/"
self.assertEqual(self.client.get(bad).status_code, 404)
self.assertIsNone(self.ldap_entry(data["username"]))
def test_uid_numbers_are_unique(self):
uids = []
for _ in range(2):
data = self.form()
self.track(data["username"])
self.client.post(REGISTER, data)
self.client.get(self.last_mail_link())
uids.append(int(self.ldap_entry(data["username"]).uidNumber.value))
self.assertNotEqual(uids[0], uids[1])
@expectedFailure
def test_bug_password_not_in_activation_link(self):
"""SECURITY: link carries base64(password); it lands in mail/proxy logs."""
import base64
data = self.form(password1="Very-Secret-pw-1", password2="Very-Secret-pw-1")
self.track(data["username"])
self.client.post(REGISTER, data)
link = self.last_mail_link()
self.assertNotIn(base64.b64encode(b"Very-Secret-pw-1").decode(), link)
@expectedFailure
def test_bug_cannot_register_name_taken_by_staff(self):
"""Username in ou=users must not be re-registrable under ou=customers."""
staff = self.make_ldap_user(ou="users")
self.track(staff) # in case the bug creates a customer duplicate
self.client.post(REGISTER, self.form(username=staff))
if mail.outbox:
self.client.get(self.last_mail_link())
self.assertIsNone(
self.ldap_entry_in(staff, "ou=customers,dc=ungleich,dc=ch"))
def ldap_entry_in(self, uid, base):
from .base import admin_conn
conn = admin_conn()
conn.search(base, "(uid={})".format(uid), attributes=["uid"])
found = list(conn.entries)
conn.unbind()
return found[0] if found else None
class ChangeDataTests(LdapTestCase):
def test_requires_login(self):
resp = self.client.get("/changedata/")
self.assertEqual(resp.status_code, 302)
def test_shows_and_updates_data(self):
uid = self.make_ldap_user(password="cust-pass-123")
self.login(uid, "cust-pass-123")
resp = self.client.get("/changedata/")
self.assertTemplateUsed(resp, "changeuserdata.html")
self.assertContains(resp, "Tina")
resp = self.client.post("/changedata/", {
"firstname": "Neo", "lastname": "Name", "email": "new@example.com"})
self.assertTemplateUsed(resp, "changeddata.html")
entry = self.ldap_entry(uid)
self.assertEqual(entry.givenName.value, "Neo")
self.assertEqual(entry.sn.value, "Name")
self.assertEqual(entry.mail.value, "new@example.com")
def test_rejects_invalid_email(self):
uid = self.make_ldap_user(password="cust-pass-123")
self.login(uid, "cust-pass-123")
resp = self.client.post("/changedata/", {
"firstname": "Neo", "lastname": "Name", "email": "broken"})
self.assertTemplateUsed(resp, "error.html")
self.assertEqual(self.ldap_entry(uid).mail.value, uid + "@example.com")
class ChangePasswordTests(LdapTestCase):
def setUp(self):
super().setUp()
self.uid = self.make_ldap_user(password="old-pass-1234")
self.login(self.uid, "old-pass-1234")
def post(self, old, new1, new2=None):
return self.client.post("/changepassword/", {
"oldpassword": old, "password1": new1, "password2": new2 or new1})
def test_wrong_old_password(self):
resp = self.post("wrong", "new-pass-1234")
self.assertContains(resp, "Wrong password")
self.assertTrue(self.can_bind(self.uid, "old-pass-1234"))
def test_mismatch_and_too_short(self):
self.assertContains(self.post("old-pass-1234", "new-pass-1234", "other-pass-1234"),
"same password")
self.assertContains(self.post("old-pass-1234", "short"), "too short")
self.assertTrue(self.can_bind(self.uid, "old-pass-1234"))
def test_success_changes_ldap_password_and_logs_out(self):
resp = self.post("old-pass-1234", "new-pass-1234")
self.assertTemplateUsed(resp, "changedpassword.html")
self.assertTrue(self.can_bind(self.uid, "new-pass-1234"))
self.assertFalse(self.can_bind(self.uid, "old-pass-1234"))
self.assertNotIn("_auth_user_id", self.client.session)
class ResetPasswordTests(LdapTestCase):
def test_unknown_user(self):
resp = self.client.post(RESET, {"user": "does-not-exist"})
self.assertContains(resp, "does not exist")
self.assertEqual(len(mail.outbox), 0)
def test_full_reset_flow(self):
uid = self.make_ldap_user(password="old-pass-1234")
resp = self.client.post(RESET, {"user": uid})
self.assertTemplateUsed(resp, "send_resetrequest.html")
self.assertEqual(mail.outbox[0].to, ["{0} <{0}@example.com>".format(uid)])
link = self.last_mail_link()
resp = self.client.get(link)
self.assertTemplateUsed(resp, "resetpasswordnew.html")
self.assertEqual(self.client.get(link).status_code, 404, "link must be single use")
resp = self.client.post("/reset/", {
"user": uid, "password1": "brand-new-1234", "password2": "brand-new-1234"})
self.assertTemplateUsed(resp, "changedpassword.html")
self.assertTrue(self.can_bind(uid, "brand-new-1234"))
def test_rejects_short_new_password(self):
uid = self.make_ldap_user(password="old-pass-1234")
resp = self.client.post("/reset/", {
"user": uid, "password1": "short", "password2": "short"})
self.assertContains(resp, "too short")
@expectedFailure
def test_bug_reset_post_requires_a_valid_token(self):
"""CRITICAL: POST /reset/ sets any user's password without a token."""
victim = self.make_ldap_user(password="victim-pass-1234", ou="users")
self.client.post("/reset/", {
"user": victim, "password1": "attacker-1234", "password2": "attacker-1234"})
self.assertFalse(self.can_bind(victim, "attacker-1234", ou="users"),
"account takeover without any token")
def test_ldap_filter_injection_in_reset(self):
"""user='*' must not match the first entry in the whole tree."""
self.make_ldap_user(password="victim-pass-1234")
self.client.post("/reset/", {
"user": "*", "password1": "attacker-1234", "password2": "attacker-1234"})
from .base import admin_conn
conn = admin_conn()
conn.search("dc=ungleich,dc=ch", "(uid=*)", attributes=["uid"])
uids = [e.uid.value for e in conn.entries]
conn.unbind()
hijacked = [u for u in uids for ou in ("customers", "users")
if self.can_bind(u, "attacker-1234", ou=ou)]
self.assertEqual(hijacked, [])
class DeleteAccountTests(LdapTestCase):
def test_requires_login(self):
self.assertEqual(self.client.get("/deleteaccount/").status_code, 302)
def test_wrong_password_keeps_account(self):
uid = self.make_ldap_user(password="cust-pass-123")
self.login(uid, "cust-pass-123")
resp = self.client.post("/deleteaccount/", {"username": uid, "password": "nope"})
self.assertContains(resp, "Wrong password")
self.assertIsNotNone(self.ldap_entry(uid))
def test_cannot_delete_someone_else(self):
me = self.make_ldap_user(password="cust-pass-123")
other = self.make_ldap_user(password="other-pass-123")
self.login(me, "cust-pass-123")
resp = self.client.post("/deleteaccount/", {"username": other, "password": "other-pass-123"})
self.assertContains(resp, "Unknown user")
self.assertIsNotNone(self.ldap_entry(other))
def test_delete_own_account(self):
uid = self.make_ldap_user(password="cust-pass-123")
self.login(uid, "cust-pass-123")
resp = self.client.post("/deleteaccount/", {"username": uid, "password": "cust-pass-123"})
self.assertTemplateUsed(resp, "deleteduser.html")
self.assertIsNone(self.ldap_entry(uid))
self.assertNotIn("_auth_user_id", self.client.session)
class UserCreateApiTests(LdapTestCase):
def test_validation(self):
self.assertEqual(self.client.post("/create/", {}).status_code, 400)
self.assertEqual(self.client.post("/create/", {
"username": "ok-name", "email": "bad", "firstname": "a", "lastname": "b"}
).status_code, 400)
def test_creates_account_via_emailed_link(self):
uid = unique_uid("api")
self.track(uid)
resp = self.client.post("/create/", {
"username": uid, "email": "api@example.com",
"firstname": "Api", "lastname": "User"})
self.assertEqual(resp.status_code, 200)
body = mail.outbox[0].body
import re
password = re.search(r"Password: (\S+)", body).group(1)
self.assertTemplateUsed(self.client.get(self.last_mail_link()), "usercreated.html")
self.assertTrue(self.can_bind(uid, password))
class OtpSeedTests(LdapTestCase):
def test_seed_list_shows_only_own_seeds(self):
import json
uid = self.make_ldap_user(password="cust-pass-123")
self.otp_get.return_value.text = json.dumps([
{"name": uid, "realm": "ungleich-auth", "seed": "MINE"},
{"name": "someone-else", "realm": "ungleich-auth", "seed": "THEIRS"},
])
self.login(uid, "cust-pass-123")
resp = self.client.get("/seeds/")
self.assertContains(resp, "MINE")
self.assertNotContains(resp, "THEIRS")
def test_api_rejects_bad_credentials(self):
uid = self.make_ldap_user(password="cust-pass-123")
resp = self.client.post("/otp/", {"username": uid, "password": "x", "realm": "ungleich-auth"})
self.assertEqual(resp.status_code, 400)
def test_api_creates_seed_in_allowed_realm_only(self):
uid = self.make_ldap_user(password="cust-pass-123")
resp = self.client.post("/otp/", {"username": uid, "password": "cust-pass-123",
"realm": "evil-realm"})
self.assertEqual(resp.status_code, 403)
resp = self.client.post("/otp/", {"username": uid, "password": "cust-pass-123",
"realm": "ungleich-auth"})
self.assertEqual(resp.status_code, 201)