Security review: staff admin lockdown, unicode-safe login/phone/grade inputs, demo guest not upgradable, home cache not floodable

- accounts admin: phone/is_guest read-only for non-superusers, device_id filter lookups refused,
  auth.User/Group admin writable only by superusers
- campaigns: delivery log needs view permission
- phone/OTP/birth date: any Unicode digit normalised or rejected with 400; unknown gradeId is 400
- shared demo guest is never turned into a person's account by logging in
- /api/home with unknown gradeId is no longer cached (LocMemCache flood reset throttle counters)
- /api/progress/nodes: one query, huge unit is 400
- BazaarCredential token filter lookups refused

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 09:34:42 +03:30
parent fedd10ff22
commit 28b8527760
15 changed files with 422 additions and 41 deletions

View File

@@ -177,3 +177,156 @@ class SupervisorDoesNotSeeDeviceIdsTests(TestCase):
self.assertNotContains(form, 'secret-device-123')
sessions = self.client.get('/admin/accounts/session/')
self.assertNotContains(sessions, 'secret-device-123')
def _staff_with(*perms, username='staff1'):
from django.contrib.auth.models import Permission
staff = get_user_model().objects.create_user(username, f'{username}@example.com', 'pw-for-tests-only', is_staff=True)
for app_label, codename in perms:
staff.user_permissions.add(Permission.objects.get(content_type__app_label=app_label, codename=codename))
return staff
class DeviceIdNotGuessableByAdminFilterTests(TestCase):
"""فیلترِ querystringِ changelist (?device_id__startswith=…) شناسه‌ی دستگاه را برای غیرِ ابرکاربر لو نمی‌دهد."""
def setUp(self):
call_command('setup_supervisor_group', verbosity=0)
self.guest = User.objects.create(device_id='abc-secret-999', is_guest=True)
from accounts.models import Session
Session.objects.create(user=self.guest, refresh_token='h' * 64, device_id='abc-secret-999',
expires_at=timezone.now() + timezone.timedelta(days=1))
staff = _staff_with(('accounts', 'view_session'))
staff.groups.add(Group.objects.get(name='Supervisor'))
self.client.force_login(staff)
def test_filter_on_device_id_is_refused(self):
for lookup in ('device_id__startswith', 'device_id__regex', 'device_id'):
r = self.client.get('/admin/accounts/user/', {lookup: 'abc'})
self.assertEqual(r.status_code, 400, lookup)
self.assertNotContains(r, f'/admin/accounts/user/{self.guest.pk}/change/', status_code=400)
r = self.client.get('/admin/accounts/session/', {'device_id__startswith': 'abc'})
self.assertEqual(r.status_code, 400)
def test_superuser_still_filters(self):
root = get_user_model().objects.create_superuser('root', 'root@example.com', 'pw-for-tests-only')
self.client.force_login(root)
r = self.client.get('/admin/accounts/user/', {'device_id__startswith': 'abc'})
self.assertContains(r, f'/admin/accounts/user/{self.guest.pk}/change/')
class StaffCannotSwapLoginPhoneTests(TestCase):
"""کارمندی با change_user شماره‌ی کاربر را عوض نمی‌کند؛ وگرنه با شماره‌ی خودش کد می‌گیرد و واردِ آن حساب می‌شود."""
def setUp(self):
cache.clear()
self.victim = User.objects.create(phone='09120000077', is_guest=False, name='victim')
self.client.force_login(_staff_with(('accounts', 'change_user'), ('accounts', 'view_user')))
def tearDown(self):
cache.clear()
def test_phone_and_is_guest_are_read_only(self):
r = self.client.post(f'/admin/accounts/user/{self.victim.pk}/change/', {
'phone': '09129999999', 'name': 'renamed', 'avatar': '', 'grade_id': '', 'is_guest': 'on'})
self.assertEqual(r.status_code, 302)
self.victim.refresh_from_db()
self.assertEqual(self.victim.name, 'renamed')
self.assertEqual(self.victim.phone, '09120000077')
self.assertFalse(self.victim.is_guest)
login = APIClient().post('/api/auth/login', {'phone': '09129999999', 'otp': _otp_row('09129999999')},
format='json')
self.assertNotEqual(login.json().get('userId'), str(self.victim.pk))
class StaffAccountsAdminSuperuserOnlyTests(TestCase):
"""auth.User/Group پنل: با change_group یا change_user (auth) کارمند خودش را ابرکاربر یا همه‌کاره نمی‌کند."""
def test_change_group_cannot_grant_all_permissions(self):
from django.contrib.auth.models import Permission
call_command('setup_supervisor_group', verbosity=0)
staff = _staff_with(('auth', 'change_group'), ('auth', 'view_group'))
group = Group.objects.get(name='Supervisor')
staff.groups.add(group)
self.client.force_login(staff)
r = self.client.post(f'/admin/auth/group/{group.pk}/change/',
{'name': 'Supervisor', 'permissions': list(Permission.objects.values_list('pk', flat=True))})
self.assertEqual(r.status_code, 403)
staff = get_user_model().objects.get(pk=staff.pk)
self.assertFalse(staff.has_perm('accounts.add_otpcode'))
def test_change_user_cannot_make_self_superuser(self):
staff = _staff_with(('auth', 'change_user'), ('auth', 'view_user'))
self.client.force_login(staff)
form = self.client.get(f'/admin/auth/user/{staff.pk}/change/')
self.assertEqual(form.status_code, 200)
self.assertNotContains(form, 'name="is_superuser"')
r = self.client.post(f'/admin/auth/user/{staff.pk}/change/', {
'username': staff.username, 'is_active': 'on', 'is_staff': 'on', 'is_superuser': 'on',
'last_login_0': '', 'last_login_1': '', 'date_joined_0': '2026-01-01', 'date_joined_1': '00:00:00'})
self.assertEqual(r.status_code, 403)
self.assertFalse(get_user_model().objects.get(pk=staff.pk).is_superuser)
class UnicodeInputsAreNormalisedOrRejectedTests(TestCase):
"""رقم‌های تمام‌عرض/ریاضی، کدِ غیرِ ASCII، پایه‌ی ناموجود و deviceIdِ null: ۴۰۰ یا نرمال‌شده، نه ۵۰۰ و نه حسابِ جدا."""
def setUp(self):
cache.clear()
self.addCleanup(cache.clear)
def test_fullwidth_and_math_digits_normalise_to_same_phone(self):
from accounts.phone import normalize_phone
for variant in ('09123456789', '𝟎𝟗𝟏𝟐𝟑𝟒𝟓𝟔𝟕𝟖𝟗', '۰۹۱۲۳۴۵۶۷۸۹', '+98 912 345 6789'):
self.assertEqual(normalize_phone(variant), '09123456789', variant)
self.assertIsNone(normalize_phone('0912345678²'))
def test_non_ascii_otp_is_400_not_500(self):
phone = '09120000099'
with override_settings(TEST_LOGIN_PHONES={phone: '123456'}):
for code in ('²²²²', '١٢٣٤٥٦٧'[:6] + 'x', 'abcd'):
r = APIClient().post('/api/auth/login', {'phone': phone, 'otp': code}, format='json')
self.assertEqual(r.status_code, 400, code)
def test_login_device_id_null_is_400(self):
phone = '09120000098'
code = _otp_row(phone)
r = APIClient().post('/api/auth/login', {'phone': phone, 'otp': code, 'deviceId': None}, format='json')
self.assertEqual(r.status_code, 400)
def test_unknown_or_huge_grade_is_400(self):
user = User.objects.create(phone='09120000097', is_guest=False)
app = _bearer(issue_access_token(user.id))
for gid in (99999, 10 ** 30):
self.assertEqual(app.post('/api/user/grade', {'gradeId': gid}, format='json').status_code, 400, gid)
def test_birth_date_with_superscript_digit_is_400(self):
user = User.objects.create(phone='09120000096', is_guest=False)
r = _bearer(issue_access_token(user.id)).patch('/api/user/profile', {'birthDate': '13²5-04-12'}, format='json')
self.assertEqual(r.status_code, 400)
def test_display_name_nfkc_contacts_removed(self):
from accounts.serializers import clean_display_name as clean
for sample in ('09123456789', '𝟎𝟗𝟏𝟐𝟑𝟒𝟓𝟔𝟕𝟖𝟗', '0912*345*6789', '@kid2014'):
self.assertEqual(clean(sample), '', sample)
self.assertEqual(clean('علی رضایی'), 'علی رضایی')
class DemoGuestNotUpgradableTests(TestCase):
"""مهمانِ نمایشیِ مشترک با ورودِ یک شماره به آن شماره تبدیل نمی‌شود؛ شماره‌ی تازه حسابِ تازه می‌گیرد."""
def test_demo_guest_keeps_its_identity(self):
cache.clear()
self.addCleanup(cache.clear)
s = _guest('frontend-test-device')
demo_id = int(s['userId'])
phone = '09120000095'
code = _otp_row(phone)
r = _bearer(s['token']).post('/api/auth/login', {'phone': phone, 'otp': code}, format='json')
self.assertEqual(r.status_code, 200)
self.assertNotEqual(int(r.json()['userId']), demo_id)
demo = User.objects.get(pk=demo_id)
self.assertIsNone(demo.phone)
self.assertTrue(demo.is_guest)