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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user