Security review fixes: admin permissions, guest sessions, payment edge cases, assistant leaks

- Admin: refund/cancel/mark-resolved/Bazaar-link actions need change permission; question and
  curriculum import need add/delete; content browser needs view_question; guest device ids are
  shown to superusers only.
- Accounts: registering a guest's phone revokes the guest's earlier sessions; self-deletion keeps
  Redemption, UserOffer and the phone's OTP rows; the public demo guest cannot be deleted
  (DEMO_GUEST_DEVICE_IDS); display-name contact filter covers any separator and social handles.
- Payment: callback without Authority is 400; a second Bazaar purchase on a paid order is 409;
  orders snapshot credits/hearts/days at creation (payment.0011).
- Assistant: a broken diagram no longer makes a streamed answer free; a late off-topic marker
  refuses the answer (not stored, not cached); a disconnect saves the partial answer; only
  KNOWN_COLLECTIONS are searched.
- Infra: secret-key file is created atomically (workers no longer split keys); no default
  INTERNAL_SERVICE_KEY; Kavenegar key and Bazaar token kept out of error logs; Swagger/ReDoc
  pinned; schema throttled; docker compose bound to 127.0.0.1; non-ASCII digit ids are 400;
  ticket text capped at 5000.
- docs/security-before-production.md: remaining open items 24-31 and the guard tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 04:52:25 +03:30
parent 645baf846b
commit a973c41db2
36 changed files with 944 additions and 85 deletions

View File

@@ -0,0 +1,80 @@
"""
حفاظ‌های بازبینیِ امنیتیِ پیش از تحویل (محتوا، پنل و ورودی‌ها). هر تست رفتاری را نگه می‌دارد که
پیش‌تر غلط بود.
"""
import io
import json
from django.contrib.auth.models import Group, User as AuthUser
from django.core.cache import cache
from django.core.management import call_command
from django.test import TestCase
from rest_framework.test import APIClient
from accounts.authentication import issue_access_token
from accounts.models import User
from content.models import Choice, Grade, Lesson, Node, Question, QuestionReport, Unit, UserAnswer
class SupervisorCannotEditContentTests(TestCase):
"""admin_view فقط is_staff را می‌سنجد؛ صفحه‌های ورودِ فایل اجازه‌ی مدل را جدا چک نمی‌کردند."""
def setUp(self):
cache.clear()
call_command('setup_supervisor_group', stdout=io.StringIO())
sup = AuthUser.objects.create_user('sup', 's@x.c', 'pw12345!', is_staff=True)
sup.groups.add(Group.objects.get(name='Supervisor'))
self.client.force_login(sup)
grade = Grade.objects.create(title='G', icon='', color='', number=55)
unit = Unit.objects.create(grade=grade, title='U', order=1)
node = Node.objects.create(unit=unit, title='N', order=1)
self.lesson = Lesson.objects.create(node=node, title='L', intro_text='x')
self.q = Question.objects.create(lesson=self.lesson, type='multiple_choice', prompt='p?')
ok = Choice.objects.create(question=self.q, value='1', is_correct=True)
Choice.objects.create(question=self.q, value='2', is_correct=False)
self.student = User.objects.create(phone='09120009999', is_guest=False)
UserAnswer.objects.create(user_id=self.student.id, question=self.q, choice=ok, correct=True)
def test_question_import_with_replace_is_refused(self):
f = io.BytesIO(json.dumps([{
'lessonId': self.lesson.id, 'prompt': 'HACKED', 'type': 'multiple_choice',
'choices': ['a', 'b'], 'correct': 0,
}]).encode())
f.name = 'q.json'
r = self.client.post('/admin/content/question/import/', {'file': f, 'replace': 'on'})
self.assertEqual(r.status_code, 403)
self.assertEqual(list(Question.objects.filter(lesson=self.lesson).values_list('prompt', flat=True)), ['p?'])
self.assertEqual(UserAnswer.objects.filter(user_id=self.student.id).count(), 1)
def test_curriculum_import_and_content_browser_are_refused(self):
self.assertEqual(self.client.get('/admin/content/grade/import-curriculum/').status_code, 403)
self.assertEqual(self.client.get('/admin/content/question/import/').status_code, 403)
self.assertEqual(self.client.get(f'/admin/content/question/browse/questions/{self.lesson.id}/').status_code, 403)
def test_mark_resolved_action_is_refused(self):
report = QuestionReport.objects.create(question=self.q, reason='wrong_answer', user_id=self.student.id)
self.client.post('/admin/content/questionreport/', {'action': 'mark_resolved', '_selected_action': [report.pk]})
report.refresh_from_db()
self.assertNotEqual(report.status, 'resolved')
class NonAsciiDigitsAre400Tests(TestCase):
"""'²'.isdigit() هم True است ولی int('²') خطا می‌دهد؛ این ورودی‌ها ۵۰۰ می‌دادند."""
def setUp(self):
cache.clear()
user = User.objects.create(device_id='digits', is_guest=True)
self.app = APIClient()
self.app.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}')
def test_character_and_unit_ids(self):
self.assertEqual(self.app.post('/api/characters/select', {'characterId': 'abc'}, format='json').status_code, 400)
self.assertEqual(self.app.post('/api/characters/select', {'characterId': '²'}, format='json').status_code, 400)
self.assertEqual(self.app.get('/api/progress/nodes', {'unit': '²'}).status_code, 400)
def test_ticket_text_is_capped(self):
r = self.app.post('/api/support/bug-reports', {'title': 't', 'description': 'x' * 5001}, format='json')
self.assertEqual(r.status_code, 400)
ok = self.app.post('/api/support/bug-reports', {'title': 't', 'description': 'x' * 5000}, format='json')
self.assertIn(ok.status_code, (200, 201))