diff --git a/acore/settings/base.py b/acore/settings/base.py
index 405b661..a9d52b1 100644
--- a/acore/settings/base.py
+++ b/acore/settings/base.py
@@ -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
diff --git a/apps/nextcloud_connector/access.py b/apps/nextcloud_connector/access.py
index 551e9f5..7c4e7fb 100644
--- a/apps/nextcloud_connector/access.py
+++ b/apps/nextcloud_connector/access.py
@@ -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
diff --git a/apps/nextcloud_connector/tests.py b/apps/nextcloud_connector/tests.py
index 945b9a3..90bc995 100644
--- a/apps/nextcloud_connector/tests.py
+++ b/apps/nextcloud_connector/tests.py
@@ -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 не должно вырваться из '}},
+ 'api_js': 'https://ds.example/api.js',
+ 'back': {'kind': 'documents'},
+ 'filename': 'x.docx', 'mode': 'edit',
+ })
+ self.assertNotIn(' и U+2028/2029 — защита от XSS через имя файла).
+ 'editor_config': config,
'api_js': settings['api_js'],
'filename': resolved['filename'],
'mode': mode,
diff --git a/apps/rro/tests.py b/apps/rro/tests.py
index 41946ee..dfd87c0 100644
--- a/apps/rro/tests.py
+++ b/apps/rro/tests.py
@@ -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: поле реестра не должно вырваться из ',
+ )
+ self._login()
+ html = self.client.get('/rro/', HTTP_HX_REQUEST='true').content.decode()
+ self.assertNotIn('
- {% if rro_form_data_json %}{% 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 %}{% 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));