security: устранение двух Stored XSS (JSON в <script>) + харднинг

Аудит по OWASP: найдены и исправлены два Stored XSS класса «json.dumps|safe
в <script>» — вырыв из тега через </script> в пользовательских данных.

- OnlyOffice-редактор (documents-editor): имя файла (title) выводилось как
  var cfg = {{ editor_config|safe }} → перешли на |json_script
- РРО (rro.html): поля реестра (notes/model_name/...) в <script
  application/json> → |json_script; из view убран ручной json.dumps
- safe_filename: отсекает < > : " | ? * и управляющие символы (defense-in-depth)
- SECURE_CONTENT_TYPE_NOSNIFF = True

Тесты: XSS-пэйлоад </script> в имени файла и в поле РРО экранируется;
safe_filename отклоняет HTML-опасные символы. Прогон: 791 тест, 0 провалов.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-11-01 16:39:30 +02:00
parent 59377b24ab
commit 2a350f7301
8 changed files with 52 additions and 13 deletions
+2
View File
@@ -241,6 +241,8 @@ SESSION_COOKIE_SAMESITE = 'Lax'
CSRF_COOKIE_HTTPONLY = True
CSRF_COOKIE_SAMESITE = 'Lax'
X_FRAME_OPTIONS = 'DENY'
# Запрет MIME-sniffing (важно при отдаче пользовательских файлов/документов).
SECURE_CONTENT_TYPE_NOSNIFF = True
# File upload limits
DATA_UPLOAD_MAX_MEMORY_SIZE = 15 * 1024 * 1024 # 15 MB
+7 -1
View File
@@ -9,6 +9,12 @@ safe_filename() — так исключён обход каталога (path tr
from __future__ import annotations
import posixpath
import re
# Запрещённые в имени файла символы: разделители путей, Windows-недопустимые и
# HTML-опасные (< >), кавычки, управляющие (0x00-0x1f). Защита от XSS через имя
# файла и несовместимых имён в хранилище.
_UNSAFE_FILENAME_RE = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
from apps.nextcloud_connector.services.sync_service import pharmacy_mountpoint
@@ -324,6 +330,6 @@ def safe_filename(name: str | None) -> str | None:
return None
if posixpath.basename(name) != name: # содержал '/' или иной разделитель
return None
if any(c in name for c in ('/', '\\', '\x00')):
if _UNSAFE_FILENAME_RE.search(name): # < > : " | ? * \ / и управляющие
return None
return name
+21
View File
@@ -116,6 +116,27 @@ class SafeFilenameTests(TestCase):
'.hidden', 'x\x00y'):
self.assertIsNone(safe_filename(n), f'должно отклонить: {n!r}')
def test_rejects_html_dangerous_chars(self):
# Имя файла с < > " : | ? * недопустимо (защита от XSS в редакторе и
# несовместимых имён в хранилище).
for n in ('x</script><script>.docx', 'a<b>.pdf', 'q"uote.txt',
'col:on.docx', 'pi|pe.txt', 'q?.txt', 'st*ar.txt'):
self.assertIsNone(safe_filename(n), f'должно отклонить: {n!r}')
class OnlyOfficeEditorXssTests(TestCase):
def test_editor_config_escapes_script_breakout(self):
"""Имя файла с </script> не должно вырваться из <script> редактора."""
from django.template.loader import render_to_string
html = render_to_string('pages/documents-editor.html', {
'editor_config': {'document': {'title': '</script><script>alert(1)</script>'}},
'api_js': 'https://ds.example/api.js',
'back': {'kind': 'documents'},
'filename': 'x.docx', 'mode': 'edit',
})
self.assertNotIn('</script><script>alert(1)', html) # не вырвался
self.assertIn('\\u003C', html) # json_script экранировал '<'
class AccessControlTests(TestCase):
@classmethod
+3 -1
View File
@@ -1136,7 +1136,9 @@ def document_editor_view(request):
user=request.user,
)
return render_page(request, 'documents-editor.html', {
'editor_config': json.dumps(config, ensure_ascii=False),
# dict, а не json-строка: в шаблоне безопасно выводим через |json_script
# (экранирует </script> и U+2028/2029 — защита от XSS через имя файла).
'editor_config': config,
'api_js': settings['api_js'],
'filename': resolved['filename'],
'mode': mode,
+12
View File
@@ -157,6 +157,18 @@ class CashRegisterPortalTests(TestCase):
self.assertNotContains(resp, 'id="rro-form-overlay"')
self.assertNotContains(resp, 'translateX(100%)')
def test_rro_json_is_xss_safe(self):
"""Stored XSS: поле реестра не должно вырваться из <script>."""
CashRegister.objects.create(
pharmacy=self.pharmacy, kind=CashRegister.Kind.RRO,
fiscal_number='FN-XSS', status=self.status_active,
model_name='</script><script>alert(document.cookie)</script>',
)
self._login()
html = self.client.get('/rro/', HTTP_HX_REQUEST='true').content.decode()
self.assertNotIn('</script><script>alert', html) # не вырвался из тега
self.assertIn('\\u003C', html) # json_script экранировал '<'
def test_pharmacy_detail_rro_tab(self):
self._login()
resp = self.client.get(f'/pharmacies/{self.pharmacy.pk}/', HTTP_HX_REQUEST='true')
+2 -7
View File
@@ -1,9 +1,7 @@
from datetime import datetime
import json
from django.contrib.auth.decorators import login_required
from django.core.exceptions import ValidationError
from django.core.serializers.json import DjangoJSONEncoder
from django.db import IntegrityError, transaction
from django.db.models import Q
from django.http import HttpResponseForbidden
@@ -190,7 +188,7 @@ def _rro_list_context(request, **extra):
'can_edit_rro': can_edit,
'rro_pharmacy_scoped': not has_all_locations(user),
'registers': register_list,
'registers_json': json.dumps(registers_js, cls=DjangoJSONEncoder, ensure_ascii=False),
'registers_js': registers_js, # в шаблон через |json_script (анти-XSS)
'statuses': statuses,
'vendors': vendors,
'kind_choices': CashRegister.Kind.choices,
@@ -216,10 +214,7 @@ def _rro_list_context(request, **extra):
# Explicit open create panel on validation errors
if context.get('rro_form_errors') and not context.get('rro_edit_id'):
context['show_create_panel'] = True
if context.get('rro_form_data'):
context['rro_form_data_json'] = json.dumps(
context['rro_form_data'], cls=DjangoJSONEncoder, ensure_ascii=False,
)
# rro_form_data выводится в шаблоне через |json_script — без ручного json.dumps.
return context
+2 -1
View File
@@ -16,11 +16,12 @@
<div id="onlyoffice-editor" class="oo-editor-host"></div>
</div>
{{ editor_config|json_script:"onlyoffice-config" }}
<script src="{{ api_js }}"></script>
<script>
(function () {
var host = document.getElementById('onlyoffice-editor');
var cfg = {{ editor_config|safe }};
var cfg = JSON.parse(document.getElementById('onlyoffice-config').textContent);
if (typeof DocsAPI === 'undefined') {
host.innerHTML =
'<p style="padding:1.25rem; font-size:0.875rem; color:#d63333;">Не удалось загрузить OnlyOffice. Проверьте хост Document Server.</p>';
+3 -3
View File
@@ -227,8 +227,8 @@
</div>
</div>
<script type="application/json" id="rro-registers-json">{{ registers_json|safe }}</script>
{% if rro_form_data_json %}<script type="application/json" id="rro-form-data-json">{{ rro_form_data_json|safe }}</script>{% endif %}
{{ registers_js|json_script:"rro-registers-json" }}
{% if rro_form_data %}{{ rro_form_data|json_script:"rro-form-data-json" }}{% endif %}
{% endif %}
{% if not accounting_embed %}</div>{% endif %}
@@ -321,7 +321,7 @@
{% if rro_edit_id %}
rroOpenEdit({{ rro_edit_id }});
{% endif %}
{% if rro_form_data_json %}
{% if rro_form_data %}
(function () {
try {
applyFormData(JSON.parse(document.getElementById('rro-form-data-json').textContent));