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

@@ -1,6 +1,7 @@
import json
from django.contrib import admin, messages
from django.core.exceptions import PermissionDenied
from django.db.models import Count, Q
from django.http import HttpResponse
from django.shortcuts import redirect, render
@@ -54,6 +55,9 @@ class GradeAdmin(admin.ModelAdmin):
return response
def import_curriculum_view(self, request):
# admin_view فقط is_staff را می‌سنجد؛ این صفحه پایه/فصل/درس می‌سازد و متنِ درس را عوض می‌کند.
if not (self.has_add_permission(request) and self.has_change_permission(request)):
raise PermissionDenied
if request.method == 'POST':
uploaded = request.FILES.get('file')
if uploaded is None:
@@ -178,6 +182,9 @@ class QuestionAdmin(admin.ModelAdmin):
return response
def import_view(self, request):
# admin_view فقط is_staff را می‌سنجد؛ «جایگزینی» سوال‌ها و جواب‌های دانش‌آموزان به آن‌ها را پاک می‌کند.
if not self.has_add_permission(request):
raise PermissionDenied
if request.method == 'POST':
uploaded = request.FILES.get('file')
if uploaded is None:
@@ -185,6 +192,8 @@ class QuestionAdmin(admin.ModelAdmin):
return redirect(request.path)
replace = request.POST.get('replace') == 'on'
if replace and not self.has_delete_permission(request):
raise PermissionDenied
try:
rows = parse(uploaded.read())
result = import_questions(rows, replace=replace)
@@ -238,7 +247,7 @@ class QuestionReportAdmin(admin.ModelAdmin):
autocomplete_fields = ['question']
actions = ['mark_resolved']
@admin.action(description='علامت زدن به‌عنوانِ بررسی‌شده')
@admin.action(description='علامت زدن به‌عنوانِ بررسی‌شده', permissions=['change'])
def mark_resolved(self, request, queryset):
updated = queryset.update(status='resolved', resolved_at=timezone.now())
self.message_user(request, f'{updated} گزارش بررسی‌شده علامت خورد.', messages.SUCCESS)

View File

@@ -10,9 +10,12 @@
گزارش‌های بازِ کاربران، و درصدِ پاسخِ غلط — یعنی همان سیگنالی که می‌گوید کدام
سوال ایراد دارد یا بیش از حد سخت است.
فقط برای کارمندی که اجازه‌ی دیدنِ سوال دارد، چون کلیدِ جواب‌ها را نشان می‌دهد.
وابستگیِ تازه‌ای اضافه نمی‌کند: چند اندپوینتِ کوچکِ JSON و JavaScript ساده.
"""
from django.contrib.admin.views.decorators import staff_member_required
from django.contrib.auth.decorators import permission_required
from django.db.models import Count, Q
from django.http import JsonResponse
from django.shortcuts import render
@@ -22,6 +25,7 @@ from .models import Grade, Lesson, Node, Question, Unit
@staff_member_required
@permission_required('content.view_question', raise_exception=True)
def browser_view(request):
return render(request, 'admin/content/browser.html', {
'title': 'مرورِ محتوا',
@@ -30,6 +34,7 @@ def browser_view(request):
@staff_member_required
@permission_required('content.view_question', raise_exception=True)
def units_json(request, grade_id: int):
rows = (
Unit.objects.filter(grade_id=grade_id)
@@ -41,6 +46,7 @@ def units_json(request, grade_id: int):
@staff_member_required
@permission_required('content.view_question', raise_exception=True)
def nodes_json(request, unit_id: int):
rows = (
Node.objects.filter(unit_id=unit_id)
@@ -52,6 +58,7 @@ def nodes_json(request, unit_id: int):
@staff_member_required
@permission_required('content.view_question', raise_exception=True)
def lessons_json(request, node_id: int):
rows = (
Lesson.objects.filter(node_id=node_id)
@@ -63,6 +70,7 @@ def lessons_json(request, node_id: int):
@staff_member_required
@permission_required('content.view_question', raise_exception=True)
def questions_json(request, lesson_id: int):
"""
هر سوال با دو سیگنال: گزارشِ بازِ کاربران، و درصدِ پاسخِ غلط.

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))

View File

@@ -741,7 +741,7 @@ class NodeProgressListView(APIView):
qs = NodeProgress.objects.filter(user_id=request.user.id).select_related('node')
unit_id = request.query_params.get('unit')
if unit_id:
if not unit_id.isdigit():
if not (unit_id.isascii() and unit_id.isdigit()):
raise ValidationError({'unit': 'شناسه‌ی فصل باید عدد باشد.'})
qs = qs.filter(node__unit_id=int(unit_id))