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:
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
هر سوال با دو سیگنال: گزارشِ بازِ کاربران، و درصدِ پاسخِ غلط.
|
||||
|
||||
80
content/tests_security_review.py
Normal file
80
content/tests_security_review.py
Normal 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))
|
||||
@@ -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))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user