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