✨ feat(web-chat): загрузка иконок виджета с очисткой SVG

POST /api/v1/organizations/{id}/integrations/{id}/assets/ принимает SVG или
PNG до 256 КБ (PNG не меньше 96×96) и возвращает { url }. SVG очищается до
сохранения; файл лежит в хранилище организации на диске или в S3 и
отдаётся виджету без сессии по непредсказуемому UUID.
This commit is contained in:
Andrey committed 2026-09-29 12:32:53 +03:00
1 parent cc091dbd03
commit 811a164c44
17 files changed
+915

No files matched your search

@@ -0,0 +1,26 @@
---
id: T-019
title: Загрузка иконок виджета с очисткой SVG
milestone: M04
status: done
depends_on: []
order: 1
spec: "0021"
created: 2026-09-29
branch: skaro/T-019-zagruzka-ikonok-vidzheta-s-och
---
## Цель
Администратор загружает SVG или PNG для кнопки и шапки; файл безопасен и отдаётся публично (R-3, R-4).
## Критерии приёмки
- [x] POST /api/v1/integrations/{id}/assets/ принимает SVG/PNG до 256 КБ, PNG от 96×96, возвращает {url}; остальное отклоняется понятной ошибкой
- [x] SVG очищается на сервере: script, on*, foreignObject, внешние href/xlink:href, javascript: удалены (тест с вредным SVG)
- [x] Файл лежит в хранилище организации organizations/{public_id}/…, работает и на диске, и на S3
- [x] Файл отдаётся виджету на чужом сайте без авторизации, чужая организация не может перезаписать
## Итог
Добавлен POST /api/v1/organizations/{org}/integrations/{id}/assets/: он принимает SVG или PNG до 256 КБ (PNG от 96×96) и возвращает { url }. SVG очищается на сервере до сохранения. Файл хранится в папке организации на диске или в S3 и отдаётся без авторизации по непредсказуемому UUID (GET /api/v1/webchat/assets/<uuid>/). Модель WidgetAsset защищена RLS, триггерами связей и каталогом входа. При удалении интеграции её иконки стираются.
@@ -159,6 +159,11 @@ MESSAGES: dict[str, object] = {
"settings.storages_missing": "django-storages[s3] is not installed",
"settings.ttl_is_seconds": "The lifetime is a number of seconds",
"settings.unknown_storage_kind": "Unknown storage type",
"webchat.asset_choose_file": "Choose an icon file",
"webchat.asset_formats": "SVG and PNG are supported",
"webchat.asset_png_too_small": "PNG must be at least 96×96 pixels",
"webchat.asset_svg_unreadable": "Could not read the SVG. Export the file again from your graphics editor",
"webchat.asset_too_large": "The icon must not exceed 256 KB",
"webchat.invalid_phone": "Invalid phone number",
"webchat.no_active_invite": "No active invitation found",
"webchat.session_not_found": "Session not found",
@@ -265,6 +270,7 @@ MESSAGES: dict[str, object] = {
"audit.action_integrations_integration_created": "Integration added",
"audit.action_integrations_integration_deleted": "Integration deleted",
"audit.action_integrations_integration_updated": "Integration changed",
"audit.action_integrations_widget_asset_uploaded": "Widget icon uploaded",
"audit.action_organization_created": "Organization created from the interface",
"audit.action_organization_owner_activated": "Organization owner activated",
"audit.action_organization_owner_invitation_requested": "Owner invitation sent",
@@ -163,6 +163,11 @@ MESSAGES: dict[str, object] = {
"settings.storages_missing": "django-storages[s3] не установлен",
"settings.ttl_is_seconds": "Время жизни — число секунд",
"settings.unknown_storage_kind": "Неизвестный тип хранилища",
"webchat.asset_choose_file": "Выберите файл иконки",
"webchat.asset_formats": "Поддерживаются SVG и PNG",
"webchat.asset_png_too_small": "PNG должен быть не меньше 96×96 пикселей",
"webchat.asset_svg_unreadable": "Не удалось прочитать SVG. Сохраните файл заново в графическом редакторе",
"webchat.asset_too_large": "Размер иконки не должен превышать 256 КБ",
"webchat.invalid_phone": "Некорректный номер телефона",
"webchat.no_active_invite": "Активное приглашение не найдено",
"webchat.session_not_found": "Сессия не найдена",
@@ -269,6 +274,7 @@ MESSAGES: dict[str, object] = {
"audit.action_integrations_integration_created": "Добавлена интеграция",
"audit.action_integrations_integration_deleted": "Удалена интеграция",
"audit.action_integrations_integration_updated": "Изменена интеграция",
"audit.action_integrations_widget_asset_uploaded": "Загружена иконка виджета",
"audit.action_organization_created": "Создана организация из интерфейса",
"audit.action_organization_owner_activated": "Активирован владелец организации",
"audit.action_organization_owner_invitation_requested": "Отправлено приглашение владельцу",
@@ -118,6 +118,7 @@ AUDIT_ACTION_LABELS: dict[str, str] = {
"integrations.integration_created": "audit.action_integrations_integration_created",
"integrations.integration_updated": "audit.action_integrations_integration_updated",
"integrations.integration_deleted": "audit.action_integrations_integration_deleted",
"integrations.widget_asset_uploaded": "audit.action_integrations_widget_asset_uploaded",
"channels.channel_created": "audit.action_channels_channel_created",
"channels.channel_updated": "audit.action_channels_channel_updated",
"channels.channel_deleted": "audit.action_channels_channel_deleted",
@@ -4,6 +4,7 @@ from django.db.models.deletion import ProtectedError
from chatballs.i18n import t
from chatballs.integrations.models import Integration
from chatballs.tenancy.context import TenantContext
from chatballs.webchat.assets import discard_widget_asset_files, widget_asset_files
class IntegrationInUse(Exception):
@@ -13,7 +14,9 @@ class IntegrationInUse(Exception):
def delete_integration(*, context: TenantContext, integration: Integration) -> None:
if integration.organization_id != context.organization_id:
raise ValidationError({"integration": t("settings.integration_other_organization")})
asset_files = widget_asset_files(integration)
try:
integration.delete()
except ProtectedError as error:
raise IntegrationInUse(t("settings.integration_in_use")) from error
discard_widget_asset_files(context=context, files=asset_files)
@@ -1,9 +1,11 @@
from django.urls import path
from chatballs.integrations import views
from chatballs.webchat.asset_views import WidgetAssetUploadView
urlpatterns = [
path("", views.IntegrationListView.as_view(), name="integration-list"),
path("<int:integration_id>/", views.IntegrationDetailView.as_view(), name="integration-detail"),
path("<int:integration_id>/test/", views.IntegrationTestView.as_view(), name="integration-test"),
path("<int:integration_id>/assets/", WidgetAssetUploadView.as_view(), name="integration-widget-assets"),
]
@@ -90,6 +90,10 @@ def web_widget_route(public_key: str) -> IngressRoute | None:
return _unique_route("web_widget_directory", public_key)
def widget_asset_route(public_id: str) -> IngressRoute | None:
return _unique_route("widget_asset_directory", public_id)
def support_portal_route(hostname: str) -> IngressRoute | None:
return _unique_route("support_portal_directory", hostname.strip().lower().rstrip("."))
@@ -0,0 +1,68 @@
# Иконки виджета (webchat_widgetasset): tenant-таблица с organization_id, RLS и
# триггерами связей по образцу tenancy/0027. Иконка показывается на чужом сайте
# без сессии, поэтому строка попадает в ingress-каталог: публичная отдача
# находит организацию по непредсказуемому public_id.
from django.db import migrations
TABLE = "webchat_widgetasset"
FORWARD_SQL = f"""
ALTER TABLE {TABLE} OWNER TO chatballs_schema;
ALTER TABLE {TABLE} ENABLE ROW LEVEL SECURITY;
ALTER TABLE {TABLE} FORCE ROW LEVEL SECURITY;
REVOKE ALL ON TABLE {TABLE} FROM PUBLIC;
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE {TABLE} TO chatballs_runtime_app;
GRANT ALL ON TABLE {TABLE} TO chatballs_schema;
GRANT USAGE, SELECT ON SEQUENCE {TABLE}_id_seq
TO chatballs_runtime_app, chatballs_runtime_platform, chatballs_schema;
DROP POLICY IF EXISTS chatballs_tenant_isolation ON {TABLE};
CREATE POLICY chatballs_tenant_isolation ON {TABLE}
FOR ALL TO chatballs_runtime_app
USING (organization_id = chatballs.current_organization_id())
WITH CHECK (organization_id = chatballs.current_organization_id());
DROP POLICY IF EXISTS chatballs_schema_access ON {TABLE};
CREATE POLICY chatballs_schema_access ON {TABLE}
FOR ALL TO chatballs_schema USING (true) WITH CHECK (true);
DROP TRIGGER IF EXISTS webchat_asset_integration ON {TABLE};
CREATE CONSTRAINT TRIGGER webchat_asset_integration
AFTER INSERT OR UPDATE ON {TABLE}
DEFERRABLE INITIALLY IMMEDIATE FOR EACH ROW EXECUTE FUNCTION
chatballs.enforce_tenant_fk('integrations_integration', 'integration_id');
DROP TRIGGER IF EXISTS webchat_asset_user ON {TABLE};
CREATE CONSTRAINT TRIGGER webchat_asset_user
AFTER INSERT OR UPDATE ON {TABLE}
DEFERRABLE INITIALLY IMMEDIATE FOR EACH ROW EXECUTE FUNCTION
chatballs.enforce_tenant_user('uploaded_by_id');
CREATE OR REPLACE VIEW chatballs.widget_asset_directory
WITH (security_barrier = true) AS
SELECT asset.id AS resource_id,
asset.organization_id,
asset.public_id::text AS lookup_key
FROM {TABLE} asset;
ALTER VIEW chatballs.widget_asset_directory OWNER TO chatballs_schema;
REVOKE ALL ON chatballs.widget_asset_directory FROM PUBLIC;
GRANT SELECT ON chatballs.widget_asset_directory
TO chatballs_runtime_app, chatballs_runtime_platform;
"""
REVERSE_SQL = f"""
DROP VIEW IF EXISTS chatballs.widget_asset_directory;
DROP TRIGGER IF EXISTS webchat_asset_user ON {TABLE};
DROP TRIGGER IF EXISTS webchat_asset_integration ON {TABLE};
DROP POLICY IF EXISTS chatballs_tenant_isolation ON {TABLE};
DROP POLICY IF EXISTS chatballs_schema_access ON {TABLE};
ALTER TABLE {TABLE} NO FORCE ROW LEVEL SECURITY;
ALTER TABLE {TABLE} DISABLE ROW LEVEL SECURITY;
"""
class Migration(migrations.Migration):
dependencies = [
("tenancy", "0037_queue_policy_guards"),
("webchat", "0006_widget_asset"),
]
operations = [migrations.RunSQL(FORWARD_SQL, REVERSE_SQL)]
@@ -0,0 +1,97 @@
from django.core.exceptions import ValidationError
from django.http import FileResponse, Http404
from rest_framework.parsers import FormParser, MultiPartParser
from rest_framework.permissions import AllowAny
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from chatballs.api.permissions import HasCapability
from chatballs.i18n import t
from chatballs.identity.audit import record_audit_event
from chatballs.integrations.models import Integration, IntegrationProvider
from chatballs.integrations.selectors import integration_for_context
from chatballs.tenancy.context import TenantContext
from chatballs.tenancy.database import tenant_atomic
from chatballs.tenancy.ingress import widget_asset_route
from chatballs.tenancy.lookup import load_organization
from chatballs.webchat.assets import upload_widget_asset, widget_asset_url
from chatballs.webchat.models import WidgetAsset
# Адрес иконки неизменен: каждая загрузка получает новый UUID.
_IMMUTABLE_CACHE = "public, max-age=31536000, immutable"
class WidgetAssetUploadView(APIView):
"""Загрузка иконки кнопки или шапки веб-виджета → ``{ url }``."""
parser_classes = [MultiPartParser, FormParser]
permission_classes = [HasCapability]
required_capability = "integrations.manage"
def post(self, request: Request, integration_id: int) -> Response:
try:
integration = integration_for_context(
context=request.tenant_context, integration_id=integration_id
)
except Integration.DoesNotExist:
integration = None
if integration is None or integration.provider != IntegrationProvider.WEB:
return Response({"detail": t("settings.integration_not_found")}, status=404)
upload = request.FILES.get("file")
if upload is None:
detail = t("webchat.asset_choose_file")
return Response({"detail": detail, "errors": {"file": detail}}, status=400)
try:
asset = upload_widget_asset(
context=request.tenant_context,
integration=integration,
upload=upload,
uploaded_by=request.user,
)
except ValidationError as error:
detail = error.message_dict["file"][0]
return Response({"detail": detail, "errors": {"file": detail}}, status=400)
record_audit_event(
action="integrations.widget_asset_uploaded",
actor=request.user,
organization=request.tenant_context.organization,
object_type="Integration",
object_id=str(integration.id),
request=request,
)
return Response({"url": widget_asset_url(asset)}, status=201)
class WidgetAssetView(APIView):
"""Иконка виджета по публичной ссылке.
Её показывает лоадер на чужом сайте, где нет сессии: защита —
непредсказуемый UUID и резолв организации через ingress-каталог.
"""
permission_classes = [AllowAny]
authentication_classes: list = []
def get(self, request: Request, public_id) -> FileResponse:
route = widget_asset_route(str(public_id))
organization = load_organization(route.organization_id) if route else None
if organization is None:
raise Http404
with tenant_atomic(TenantContext.for_resource(organization)):
asset = WidgetAsset.objects.filter(
id=route.resource_id, public_id=public_id, organization=organization
).first()
if asset is None:
raise Http404
opened_file = asset.file.open("rb")
content_type = asset.content_type
response = FileResponse(opened_file, content_type=content_type)
# SVG очищен при загрузке; заголовки — второй рубеж на случай, если
# адрес откроют как документ: ничего не исполнять, никуда не ходить,
# тип не угадывать. Картинку можно встраивать на любой сайт.
response["Content-Security-Policy"] = "default-src 'none'; style-src 'unsafe-inline'; sandbox"
response["X-Content-Type-Options"] = "nosniff"
response["Cross-Origin-Resource-Policy"] = "cross-origin"
response["Cache-Control"] = _IMMUTABLE_CACHE
return response
+125
View File
@@ -0,0 +1,125 @@
"""Иконки кнопки и шапки виджета (SPEC-0021 R-3, R-4).
Принимаются SVG и PNG до 256 КБ; PNG — не меньше 96×96, чтобы кнопка не
расплывалась на экранах с высокой плотностью. SVG очищается до сохранения.
Тип определяется по содержимому, а не по имени файла и заявленному типу.
"""
from __future__ import annotations
import uuid
from django.core.exceptions import ValidationError
from django.core.files.base import ContentFile
from django.core.files.uploadedfile import UploadedFile
from django.db import transaction
from django.urls import reverse
from chatballs.i18n import t
from chatballs.identity.logo_svg import SVG_CONTENT_TYPE, looks_like_svg
from chatballs.integrations.models import Integration
from chatballs.tenancy.context import TenantContext
from chatballs.tenancy.database import tenant_atomic
from chatballs.tenancy.storage import adjust_storage_usage
from chatballs.tenancy.storage_quota import finalize_storage, release_storage, reserve_storage
from chatballs.webchat.models import WidgetAsset
from chatballs.webchat.svg_sanitizer import UnsafeSvg, sanitize_svg
MAX_ASSET_BYTES = 256 * 1024
MIN_PNG_SIDE = 96
PNG_CONTENT_TYPE = "image/png"
_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
def _png_size(data: bytes) -> tuple[int, int] | None:
"""Ширина и высота из заголовка IHDR — первого блока любого PNG."""
if len(data) < 24 or data[12:16] != b"IHDR":
return None
return int.from_bytes(data[16:20], "big"), int.from_bytes(data[20:24], "big")
def _asset_content(data: bytes) -> tuple[bytes, str, str]:
if data.startswith(_PNG_SIGNATURE):
size = _png_size(data)
if size is None:
raise ValidationError({"file": t("webchat.asset_formats")})
if min(size) < MIN_PNG_SIDE:
raise ValidationError({"file": t("webchat.asset_png_too_small")})
return data, PNG_CONTENT_TYPE, ".png"
if looks_like_svg(data):
try:
return sanitize_svg(data), SVG_CONTENT_TYPE, ".svg"
except UnsafeSvg as error:
raise ValidationError({"file": t("webchat.asset_svg_unreadable")}) from error
raise ValidationError({"file": t("webchat.asset_formats")})
def _read(upload: UploadedFile) -> bytes:
if upload.size is not None and upload.size > MAX_ASSET_BYTES:
raise ValidationError({"file": t("webchat.asset_too_large")})
data = upload.read(MAX_ASSET_BYTES + 1)
if not data:
raise ValidationError({"file": t("webchat.asset_choose_file")})
if len(data) > MAX_ASSET_BYTES:
raise ValidationError({"file": t("webchat.asset_too_large")})
return data
@transaction.atomic
def upload_widget_asset(
*,
context: TenantContext,
integration: Integration,
upload: UploadedFile,
uploaded_by=None,
) -> WidgetAsset:
content, content_type, suffix = _asset_content(_read(upload))
asset = WidgetAsset(
organization_id=context.organization_id,
integration=integration,
content_type=content_type,
size=len(content),
uploaded_by=uploaded_by,
)
reservation_key = f"widget-asset:{uuid.uuid4()}"
reserve_storage(context=context, expected_bytes=len(content), idempotency_key=reservation_key)
try:
asset.file.save(suffix, ContentFile(content), save=False)
asset.save()
except Exception:
release_storage(context=context, idempotency_key=reservation_key)
raise
finalize_storage(context=context, idempotency_key=reservation_key, actual_bytes=len(content))
return asset
def widget_asset_url(asset: WidgetAsset) -> str:
"""Адрес от корня установки: лоадер дописывает к нему свой origin."""
return reverse("webchat-asset", kwargs={"public_id": asset.public_id})
def widget_asset_files(integration: Integration) -> list[tuple[str, int]]:
"""Файлы иконок интеграции: строки уходят с ней каскадом, файлы — нет."""
return list(integration.widget_assets.values_list("file", "size"))
def discard_widget_asset_files(*, context: TenantContext, files: list[tuple[str, int]]) -> None:
"""Освобождает место сразу, а файлы стирает после коммита удаления."""
if not files:
return
adjust_storage_usage(context=context, delta_bytes=-sum(size for _name, size in files))
storage = WidgetAsset._meta.get_field("file").storage
organization_id = context.organization_id
def delete_files() -> None:
# Ограждение хранилища требует контекст организации, а после коммита
# транзакции с SET LOCAL его уже нет.
with tenant_atomic(organization_id):
for name, _size in files:
storage.delete(name)
transaction.on_commit(delete_files)
@@ -0,0 +1,38 @@
# Иконки кнопки и шапки виджета (SPEC-0021 R-3). RLS, триггеры связей и
# каталог входа для публичной отдачи — в tenancy/0038.
import chatballs.webchat.models
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('identity', '0040_instance_public_port'),
('integrations', '0010_integration_runtime_revision'),
('webchat', '0005_remove_widget_mode'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='WidgetAsset',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('public_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('file', models.FileField(max_length=512, upload_to=chatballs.webchat.models.widget_asset_upload_path)),
('content_type', models.CharField(max_length=64)),
('size', models.PositiveIntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('integration', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='widget_assets', to='integrations.integration')),
('organization', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization')),
('uploaded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at', '-id'],
},
),
]
+45
View File
@@ -1,5 +1,8 @@
import secrets
import uuid
from pathlib import PurePath
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
@@ -83,3 +86,45 @@ class WebSession(TenantRelationModel):
def __str__(self) -> str:
return f"websession:{self.identity_id}"
def widget_asset_upload_path(instance: "WidgetAsset", filename: str) -> str:
organization = instance.integration.organization
return (
f"organizations/{organization.public_id}/webchat/"
f"{instance.integration_id}/{instance.public_id}{PurePath(filename).suffix}"
)
class WidgetAsset(TenantRelationModel):
"""Иконка кнопки или шапки виджета (SPEC-0021 R-3).
Файл показывается на чужом сайте без сессии, поэтому ссылка публичная и
защищена непредсказуемым UUID, как у файлов статей портала. Каждая
загрузка — новый ключ: прежний адрес не перезаписывается.
"""
tenant_relation_fields = ("integration",)
integration = models.ForeignKey(
"integrations.Integration",
on_delete=models.CASCADE,
related_name="widget_assets",
)
public_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
file = models.FileField(upload_to=widget_asset_upload_path, max_length=512)
content_type = models.CharField(max_length=64)
size = models.PositiveIntegerField(default=0)
uploaded_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
related_name="+",
null=True,
blank=True,
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-created_at", "-id"]
def __str__(self) -> str:
return f"widget-asset:{self.integration_id}/{self.public_id}"
@@ -0,0 +1,119 @@
"""Очистка SVG-иконки виджета.
Иконка кнопки и шапки показывается на чужих сайтах и отдаётся с адреса
приложения, поэтому SVG чистится до сохранения. В отличие от логотипа
организации (``identity.logo_svg``) файл не отклоняется целиком: опасное
вырезается, картинка остаётся.
Удаляется: элементы вне пространства имён SVG, script, foreignObject и
встраиваемые iframe/embed/object/audio/video; атрибуты-обработчики on*;
href и xlink:href, которые ведут не на якорь ``#``; любые значения с
javascript:/vbscript:/data:text; анимации, подменяющие ссылки и обработчики;
стили с @import, expression и внешними url(). Отклоняется то, что очистить
нельзя: не XML, не SVG, DOCTYPE и сущности.
"""
from __future__ import annotations
import re
import xml.etree.ElementTree as ET
SVG_NAMESPACE = "http://www.w3.org/2000/svg"
XLINK_NAMESPACE = "http://www.w3.org/1999/xlink"
# Без регистрации ElementTree пишет ns0:svg. Параметр default_namespace не
# подходит: он требует пространство имён и у атрибутов.
ET.register_namespace("", SVG_NAMESPACE)
ET.register_namespace("xlink", XLINK_NAMESPACE)
_REMOVED_TAGS = frozenset(
{"script", "foreignobject", "iframe", "embed", "object", "audio", "video"}
)
_ANIMATION_TAGS = frozenset({"animate", "set", "animatemotion", "animatetransform"})
_DECLARATION = re.compile(rb"<!\s*(DOCTYPE|ENTITY)", re.IGNORECASE)
_EXTERNAL_URL = re.compile(r"url\(\s*['\"]?\s*(?!#)", re.IGNORECASE)
_UNSAFE_SCHEMES = ("javascript:", "vbscript:", "data:text")
class UnsafeSvg(ValueError):
"""SVG не разбирается или не может быть очищен."""
def _local(name: str) -> str:
return name.rsplit("}", 1)[-1].lower()
def _namespace(name: str) -> str:
return name[1:].split("}", 1)[0] if name.startswith("{") else ""
def _compact(value: str) -> str:
return re.sub(r"[\s\x00-\x1f]+", "", value).lower()
def _unsafe_value(value: str) -> bool:
compact = _compact(value)
return any(scheme in compact for scheme in _UNSAFE_SCHEMES)
def _unsafe_css(text: str) -> bool:
compact = _compact(text)
return "@import" in compact or "expression(" in compact or bool(_EXTERNAL_URL.search(text))
def _unsafe_attribute(name: str, value: str) -> bool:
local = _local(name)
if local.startswith("on") or _unsafe_value(value):
return True
if local == "href":
return not value.strip().startswith("#")
return local == "style" and _unsafe_css(value)
def _unsafe_animation(element: ET.Element) -> bool:
target = _local(element.get("attributeName", ""))
return target == "href" or target.startswith("on")
def _removed(element: ET.Element) -> bool:
tag = element.tag if isinstance(element.tag, str) else ""
if _namespace(tag) != SVG_NAMESPACE:
return True
local = _local(tag)
if local in _REMOVED_TAGS:
return True
if local in _ANIMATION_TAGS and _unsafe_animation(element):
return True
return local == "style" and _unsafe_css(element.text or "")
def _qualify(root: ET.Element) -> None:
"""SVG без xmlns браузер в <img> не рисует: переводим его в пространство SVG."""
for element in root.iter():
if isinstance(element.tag, str) and not element.tag.startswith("{"):
element.tag = f"{{{SVG_NAMESPACE}}}{element.tag}"
def _clean(element: ET.Element) -> None:
for child in list(element):
if _removed(child):
element.remove(child)
else:
_clean(child)
for name in [name for name, value in element.attrib.items() if _unsafe_attribute(name, value)]:
del element.attrib[name]
def sanitize_svg(data: bytes) -> bytes:
if _DECLARATION.search(data):
raise UnsafeSvg("DOCTYPE and entities are not allowed")
try:
root = ET.fromstring(data)
except ET.ParseError as error:
raise UnsafeSvg("SVG is not well-formed XML") from error
_qualify(root)
if not isinstance(root.tag, str) or root.tag != f"{{{SVG_NAMESPACE}}}svg":
raise UnsafeSvg("Root element is not svg")
_clean(root)
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
@@ -0,0 +1,78 @@
"""Очистка SVG-иконки виджета: опасное вырезается, картинка остаётся (SPEC-0021 R-4)."""
from __future__ import annotations
from django.test import SimpleTestCase
from chatballs.webchat.svg_sanitizer import UnsafeSvg, sanitize_svg
HARMFUL_SVG = b"""<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 64 64" onload="alert('load')">
<script type="text/javascript">alert('script')</script>
<foreignObject width="10" height="10">
<div xmlns="http://www.w3.org/1999/xhtml" onclick="alert('html')">x</div>
</foreignObject>
<defs><circle id="dot" r="4"/></defs>
<a xlink:href="javascript:alert('link')"><rect width="8" height="8" onclick="alert('click')"/></a>
<a href="java&#9;script:alert('tab')"><rect width="4" height="4"/></a>
<image href="https://evil.example/pixel.png" width="1" height="1"/>
<image xlink:href="data:image/svg+xml;base64,PHN2Zy8+" width="1" height="1"/>
<use xlink:href="#dot" x="32" y="32"/>
<use href="https://evil.example/sprite.svg#icon"/>
<set attributeName="href" to="javascript:alert('smil')"/>
<animate attributeName="onclick" values="alert('smil')"/>
<animate attributeName="opacity" values="0;1" dur="1s"/>
<rect width="16" height="16" fill="url(#dot)" style="fill:url(https://evil.example/x)"/>
<style>@import url(https://evil.example/a.css); .mark { fill: #1677ff; }</style>
<circle class="mark" cx="32" cy="32" r="30"/>
</svg>
"""
class SvgSanitizerTests(SimpleTestCase):
def test_harmful_parts_are_removed(self) -> None:
cleaned = sanitize_svg(HARMFUL_SVG).decode("utf-8")
lowered = cleaned.lower()
for forbidden in (
"<script",
"foreignobject",
"onload",
"onclick",
"javascript:",
"evil.example",
"data:",
"@import",
"alert(",
):
with self.subTest(forbidden):
self.assertNotIn(forbidden, lowered)
def test_picture_and_local_references_stay(self) -> None:
cleaned = sanitize_svg(HARMFUL_SVG).decode("utf-8")
self.assertIn('viewBox="0 0 64 64"', cleaned)
self.assertIn('<circle class="mark" cx="32" cy="32" r="30" />', cleaned)
self.assertIn('xlink:href="#dot"', cleaned)
self.assertIn('fill="url(#dot)"', cleaned)
self.assertIn('attributeName="opacity"', cleaned)
self.assertTrue(cleaned.startswith("<?xml"))
self.assertIn('<svg xmlns="http://www.w3.org/2000/svg"', cleaned)
def test_svg_without_namespace_gets_it(self) -> None:
cleaned = sanitize_svg(b'<svg viewBox="0 0 4 4"><path d="M0 0h4v4z"/></svg>')
self.assertIn(b'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 4 4">', cleaned)
self.assertIn(b'<path d="M0 0h4v4z" />', cleaned)
def test_unreadable_svg_is_refused(self) -> None:
samples = {
"doctype": b'<?xml version="1.0"?><!DOCTYPE svg [<!ENTITY x "y">]><svg xmlns="http://www.w3.org/2000/svg"/>',
"broken xml": b'<svg xmlns="http://www.w3.org/2000/svg"><rect></svg>',
"not svg": b"<html><body>hi</body></html>",
"foreign root": b'<svg xmlns="http://evil.example/ns"/>',
}
for name, sample in samples.items():
with self.subTest(name), self.assertRaises(UnsafeSvg):
sanitize_svg(sample)
@@ -0,0 +1,217 @@
"""Иконки виджета: загрузка, очистка, хранилище организации и публичная отдача.
SPEC-0021 R-3, R-4: SVG или PNG до 256 КБ (PNG не меньше 96×96) →
``{ url }``; файл лежит под ``organizations/{public_id}/`` на диске или в S3 и
открывается на чужом сайте без сессии.
"""
from __future__ import annotations
import struct
import zlib
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest import mock
from django.core.files.storage import FileSystemStorage
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings
from rest_framework.test import APIClient
from chatballs.channels.models import Channel
from chatballs.identity.bootstrap import bootstrap_owner
from chatballs.identity.models import EmployeeRole, HumanUser, Organization, OrganizationMembership
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
from chatballs.tenancy import storage_settings as ss
from chatballs.tenancy.storage_backends import DynamicTenantStorage
from chatballs.testing import TenantAPIClient
from chatballs.webchat.models import WidgetAsset
from chatballs.webchat.test_svg_sanitizer import HARMFUL_SVG
from chatballs.webchat.testing import create_web_widget
def png(width: int, height: int) -> bytes:
def chunk(kind: bytes, body: bytes) -> bytes:
return struct.pack(">I", len(body)) + kind + body + struct.pack(">I", zlib.crc32(kind + body))
header = struct.pack(">IIBBBBB", width, height, 8, 0, 0, 0, 0)
pixels = zlib.compress(b"".join(b"\x00" + b"\x80" * width for _ in range(height)))
return b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", header) + chunk(b"IDAT", pixels) + chunk(b"IEND", b"")
class WidgetAssetApiTests(TestCase):
def setUp(self) -> None:
self.media = TemporaryDirectory()
self.bucket = TemporaryDirectory()
self.addCleanup(self.media.cleanup)
self.addCleanup(self.bucket.cleanup)
override = override_settings(MEDIA_ROOT=self.media.name)
override.enable()
self.addCleanup(override.disable)
ss.invalidate_cache()
self.addCleanup(ss.invalidate_cache)
DynamicTenantStorage._s3_cache = None
result = bootstrap_owner(email="assets-owner@example.com", password="temporary-password")
self.organization = Organization.objects.get(slug="demo")
channel = Channel.objects.create(organization=self.organization, code="site", name="Сайт")
self.integration = create_web_widget(channel).integration
self.client = TenantAPIClient()
self.client.force_authenticate(result.owner)
def _url(self, integration_id: int, organization: Organization | None = None) -> str:
public_id = (organization or self.organization).public_id
return f"/api/v1/organizations/{public_id}/integrations/{integration_id}/assets/"
def _upload(self, name: str, data: bytes, *, client=None, url: str | None = None):
return (client or self.client).post(
url or self._url(self.integration.id),
{"file": SimpleUploadedFile(name, data, content_type="application/octet-stream")},
format="multipart",
)
def _anonymous_get(self, url: str):
return APIClient().get(url, HTTP_ORIGIN="https://customer-site.example")
def _enable_s3(self) -> None:
patcher = mock.patch.object(
ss, "build_s3_storage", lambda config, **kwargs: FileSystemStorage(location=self.bucket.name)
)
patcher.start()
self.addCleanup(patcher.stop)
row = ss.StorageSettings.load()
row.backend = ss.StorageBackend.S3
row.s3_bucket = "demo"
row.s3_access_key = "AKIA-demo-access"
row.s3_secret_key = "very-secret"
row.save()
DynamicTenantStorage._s3_cache = None
def _second_owner_client(self) -> tuple[Organization, TenantAPIClient]:
other = Organization.objects.create(name="Other", slug="other")
owner = HumanUser.objects.create_user(email="other-owner@example.com", password="Password-123", full_name="Other")
OrganizationMembership.objects.create(organization=other, user=owner, role=EmployeeRole.OWNER, position_title="Owner")
client = TenantAPIClient()
client.force_authenticate(owner)
return other, client
# --- приём и отказ -------------------------------------------------------
def test_harmful_svg_is_cleaned_and_served_without_session(self) -> None:
response = self._upload("logo.svg", HARMFUL_SVG)
self.assertEqual(response.status_code, 201, response.content)
url = response.json()["url"]
self.assertRegex(url, r"^/api/v1/webchat/assets/[0-9a-f-]{36}/$")
served = self._anonymous_get(url)
self.assertEqual(served.status_code, 200)
self.assertEqual(served["Content-Type"], "image/svg+xml")
self.assertEqual(served["Cross-Origin-Resource-Policy"], "cross-origin")
self.assertEqual(served["X-Content-Type-Options"], "nosniff")
self.assertIn("sandbox", served["Content-Security-Policy"])
body = b"".join(served.streaming_content).lower()
for forbidden in (b"<script", b"foreignobject", b"onload", b"onclick", b"javascript:", b"evil.example"):
self.assertNotIn(forbidden, body)
self.assertIn(b'<circle class="mark"', body)
def test_png_from_96_pixels_is_stored_as_is(self) -> None:
data = png(96, 128)
response = self._upload("icon.png", data)
self.assertEqual(response.status_code, 201, response.content)
served = self._anonymous_get(response.json()["url"])
self.assertEqual(served["Content-Type"], "image/png")
self.assertEqual(b"".join(served.streaming_content), data)
def test_unsuitable_files_are_refused_with_a_clear_message(self) -> None:
cases = {
"small png": ("icon.png", png(95, 200), "PNG должен быть не меньше 96×96 пикселей"),
"too large": ("icon.png", png(96, 96) + b"\x00" * (256 * 1024), "Размер иконки не должен превышать 256 КБ"),
"gif": ("icon.gif", b"GIF89a\x01\x00\x01\x00", "Поддерживаются SVG и PNG"),
"svg named png": ("icon.png", b"<html><script>alert(1)</script></html>", "Поддерживаются SVG и PNG"),
"broken svg": ("icon.svg", b'<svg xmlns="http://www.w3.org/2000/svg"><rect></svg>', "Не удалось прочитать SVG. Сохраните файл заново в графическом редакторе"),
"empty": ("icon.svg", b"", "Выберите файл иконки"),
}
for name, (filename, data, message) in cases.items():
with self.subTest(name):
response = self._upload(filename, data)
self.assertEqual(response.status_code, 400, response.content)
self.assertEqual(response.json()["detail"], message)
self.assertEqual(response.json()["errors"], {"file": message})
self.assertFalse(WidgetAsset.objects.exists())
def test_only_web_widget_accepts_icons(self) -> None:
provider = Integration.objects.create(
organization=self.organization,
kind=IntegrationKind.LLM_PROVIDER,
provider=IntegrationProvider.DEMO,
name="Demo",
)
response = self._upload("icon.png", png(96, 96), url=self._url(provider.id))
self.assertEqual(response.status_code, 404)
# --- хранилище ----------------------------------------------------------
def test_file_lives_in_organization_folder_on_disk(self) -> None:
response = self._upload("icon.png", png(96, 96))
self.assertEqual(response.status_code, 201, response.content)
asset = WidgetAsset.objects.get()
prefix = f"organizations/{self.organization.public_id}/webchat/{self.integration.id}/"
self.assertTrue(asset.file.name.startswith(prefix), asset.file.name)
self.assertTrue((Path(self.media.name) / asset.file.name).is_file())
def test_file_lives_in_organization_folder_on_s3(self) -> None:
self._enable_s3()
response = self._upload("logo.svg", HARMFUL_SVG)
self.assertEqual(response.status_code, 201, response.content)
asset = WidgetAsset.objects.get()
self.assertTrue(asset.file.name.startswith(f"organizations/{self.organization.public_id}/webchat/"))
self.assertTrue((Path(self.bucket.name) / asset.file.name).is_file())
self.assertFalse((Path(self.media.name) / asset.file.name).exists())
served = self._anonymous_get(response.json()["url"])
self.assertEqual(served.status_code, 200)
self.assertNotIn(b"<script", b"".join(served.streaming_content))
def test_deleting_widget_integration_removes_icon_files(self) -> None:
self._upload("icon.png", png(96, 96))
path = Path(self.media.name) / WidgetAsset.objects.get().file.name
with self.captureOnCommitCallbacks(execute=True):
response = self.client.delete(
f"/api/v1/organizations/{self.organization.public_id}/integrations/{self.integration.id}/"
)
self.assertEqual(response.status_code, 204, response.content)
self.assertFalse(WidgetAsset.objects.exists())
self.assertFalse(path.exists())
# --- изоляция -----------------------------------------------------------
def test_other_organization_cannot_upload_to_foreign_widget(self) -> None:
other, client = self._second_owner_client()
through_own = self._upload("icon.png", png(96, 96), client=client, url=self._url(self.integration.id, other))
through_foreign = self._upload("icon.png", png(96, 96), client=client)
self.assertEqual(through_own.status_code, 404)
self.assertIn(through_foreign.status_code, (403, 404))
self.assertFalse(WidgetAsset.objects.exists())
def test_every_upload_gets_its_own_address(self) -> None:
first = self._upload("icon.png", png(96, 96)).json()["url"]
second = self._upload("icon.png", png(128, 128)).json()["url"]
self.assertNotEqual(first, second)
served = self._anonymous_get(first)
self.assertEqual(b"".join(served.streaming_content), png(96, 96))
def test_unknown_address_is_not_found(self) -> None:
response = self._anonymous_get("/api/v1/webchat/assets/00000000-0000-4000-8000-000000000000/")
self.assertEqual(response.status_code, 404)
@@ -0,0 +1,78 @@
"""Иконки виджета под реальной ролью backend-app: RLS, триггеры и каталог входа.
Обычные тесты ходят в базу ролью-владельцем схемы, для которой RLS открыта.
Здесь запросы идут ролью ``chatballs_runtime_app``, как в production.
"""
from __future__ import annotations
from tempfile import TemporaryDirectory
from django.core.files.uploadedfile import SimpleUploadedFile
from django.db import connection
from django.test import TransactionTestCase, override_settings
from rest_framework.test import APIClient
from chatballs.channels.models import Channel
from chatballs.identity.bootstrap import bootstrap_owner
from chatballs.identity.models import EmployeeRole, HumanUser, Organization, OrganizationMembership
from chatballs.tenancy import storage_settings as ss
from chatballs.testing import TenantAPIClient
from chatballs.webchat.test_widget_assets import png
from chatballs.webchat.testing import create_web_widget
class WidgetAssetRuntimeRoleTests(TransactionTestCase):
def setUp(self) -> None:
media = TemporaryDirectory()
self.addCleanup(media.cleanup)
override = override_settings(MEDIA_ROOT=media.name)
override.enable()
self.addCleanup(override.disable)
ss.invalidate_cache()
self.addCleanup(ss.invalidate_cache)
result = bootstrap_owner(email="rls-assets@example.com", password="temporary-password")
self.organization = Organization.objects.get(slug="demo")
channel = Channel.objects.create(organization=self.organization, code="site", name="Сайт")
self.integration = create_web_widget(channel).integration
self.owner = result.owner
other = Organization.objects.create(name="Other", slug="rls-assets-other")
self.other_owner = HumanUser.objects.create_user(email="rls-assets-other@example.com")
OrganizationMembership.objects.create(
organization=other, user=self.other_owner, role=EmployeeRole.OWNER, position_title="Owner"
)
self.other = other
def _as_app_role(self, request):
with connection.cursor() as cursor:
cursor.execute("SET ROLE chatballs_runtime_app")
try:
return request()
finally:
with connection.cursor() as cursor:
cursor.execute("RESET ROLE")
def _upload(self, user, organization: Organization):
client = TenantAPIClient()
client.force_authenticate(user)
return client.post(
f"/api/v1/organizations/{organization.public_id}/integrations/{self.integration.id}/assets/",
{"file": SimpleUploadedFile("icon.png", png(96, 96))},
format="multipart",
)
def test_upload_and_public_download_work_under_app_role(self) -> None:
uploaded = self._as_app_role(lambda: self._upload(self.owner, self.organization))
self.assertEqual(uploaded.status_code, 201, uploaded.content)
served = self._as_app_role(lambda: APIClient().get(uploaded.json()["url"]))
self.assertEqual(served.status_code, 200)
self.assertEqual(b"".join(served.streaming_content), png(96, 96))
def test_foreign_organization_cannot_reach_the_widget_under_app_role(self) -> None:
response = self._as_app_role(lambda: self._upload(self.other_owner, self.other))
self.assertEqual(response.status_code, 404)
+2
View File
@@ -1,8 +1,10 @@
from django.urls import path
from chatballs.webchat import views
from chatballs.webchat.asset_views import WidgetAssetView
urlpatterns = [
path("assets/<uuid:public_id>/", WidgetAssetView.as_view(), name="webchat-asset"),
path("config/", views.WebchatConfigView.as_view(), name="webchat-config"),
path("session/", views.WebchatSessionView.as_view(), name="webchat-session"),
path("messages/", views.WebchatMessagesView.as_view(), name="webchat-messages"),