Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ and this project adheres to

- 🐛(docker) pin collabora image and adapt to its new runtime contract
- 🐛(backend) delete malware detection record when purging an item
- 🔒️(backend) reject unsafe filenames requested by WOPI renames
- 🔒️(backend) analyze file content written through WOPI

## [v0.20.0] - 2026-07-15

Expand Down
27 changes: 17 additions & 10 deletions src/backend/wopi/tests/viewset/test_put_file_content.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
"""Test the PUT file content viewset."""

from unittest import mock

from django.core.files.storage import default_storage

import pytest
from lasuite.malware_detection import malware_detection
from rest_framework.test import APIClient

from core import factories, models
Expand Down Expand Up @@ -38,16 +41,19 @@ def test_put_file_content_connected_user_with_access():
client = APIClient()
assert item.size == 0
updated_at = item.updated_at
response = client.post(
f"/api/v1.0/wopi/files/{item.id}/contents/",
data=b"new content",
content_type="text/plain",
HTTP_AUTHORIZATION=f"Bearer {access_token}",
headers={
"X-WOPI-Override": "PUT",
"X-WOPI-Lock": "1234567890",
},
)
with mock.patch.object(malware_detection, "analyse_file") as mock_analyse_file:
response = client.post(
f"/api/v1.0/wopi/files/{item.id}/contents/",
data=b"new content",
content_type="text/plain",
HTTP_AUTHORIZATION=f"Bearer {access_token}",
headers={
"X-WOPI-Override": "PUT",
"X-WOPI-Lock": "1234567890",
},
)

mock_analyse_file.assert_called_once_with(item.file_key, item_id=item.id)
assert response.status_code == 200
assert "X-WOPI-ItemVersion" in response.headers

Expand All @@ -61,6 +67,7 @@ def test_put_file_content_connected_user_with_access():
assert response.headers.get("X-WOPI-ItemVersion") == file["ETag"].strip('"')
item.refresh_from_db()
assert item.size == 11 # the size should have been updated
assert item.upload_state == models.ItemUploadStateChoices.READY
assert item.updated_at > updated_at


Expand Down
59 changes: 59 additions & 0 deletions src/backend/wopi/tests/viewset/test_rename.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from unittest.mock import patch

from django.core.files.storage import default_storage
from django.test import override_settings

import botocore
import pytest
Expand All @@ -17,6 +18,33 @@
pytestmark = pytest.mark.django_db


def request_rename(filename, requested_name):
"""Create a file and request a WOPI rename."""
folder = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER)
item = factories.ItemFactory(
parent=folder,
type=models.ItemTypeChoices.FILE,
filename=filename,
update_upload_state=models.ItemUploadStateChoices.READY,
link_reach=models.LinkReachChoices.RESTRICTED,
link_role=models.LinkRoleChoices.EDITOR,
)
user = factories.UserFactory()
factories.UserItemAccessFactory(item=item, user=user, role=models.RoleChoices.EDITOR)
access_token, _ = AccessUserItemService().insert_new_access(item, user)
default_storage.save(item.file_key, BytesIO(b"content"))

response = APIClient().post(
f"/api/v1.0/wopi/files/{item.id}/",
HTTP_AUTHORIZATION=f"Bearer {access_token}",
headers={
"X-WOPI-Override": "RENAME_FILE",
"X-WOPI-RequestedName": requested_name,
},
)
return item, response


def test_rename_file_success():
"""User having access to the item can rename the file."""
folder = factories.ItemFactory(
Expand Down Expand Up @@ -240,6 +268,37 @@ def test_rename_file_with_invalid_lock():
assert response.headers.get(X_WOPI_LOCK) == "1234567890"


@pytest.mark.parametrize("requested_name", ["bridge+AC8-", "bridge+AFw-"])
def test_rename_file_rejects_path_separator(requested_name):
"""A path separator decoded from UTF-7 should make the filename invalid."""
item, response = request_rename("wopi_test.txt", requested_name)

assert response.status_code == 400
assert response.headers.get(X_WOPI_INVALIDFILENAMERROR) == "Invalid filename"
item.refresh_from_db()
assert item.filename == "wopi_test.txt"


def test_rename_file_rejects_disallowed_target_extension():
"""An extensionless file cannot be renamed to acquire a disallowed extension."""
item, response = request_rename("wopi_test", "malware.exe")

assert response.status_code == 400
assert response.headers.get(X_WOPI_INVALIDFILENAMERROR) == "This file extension is not allowed"
item.refresh_from_db()
assert item.filename == "wopi_test"


@override_settings(RESTRICT_UPLOAD_FILE_TYPE=True, FILE_EXTENSIONS_ALLOWED=[".STEP"])
def test_rename_file_allows_extension_case_insensitively():
"""Allowed extensions should be matched case-insensitively."""
item, response = request_rename("model.STEP", "renamed")

assert response.status_code == 200
item.refresh_from_db()
assert item.filename == "renamed.STEP"


def test_rename_file_storage_error():
"""File rename should fail when storage operation fails."""
folder = factories.ItemFactory(
Expand Down
36 changes: 29 additions & 7 deletions src/backend/wopi/viewsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from django.db import transaction
from django.http import StreamingHttpResponse

from lasuite.malware_detection import malware_detection
from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
Expand All @@ -35,6 +36,8 @@
X_WOPI_LOCK = "X-WOPI-Lock"
S3_VERSION_ID = "VersionId"

ILLEGAL_FILENAME_CHARS = ("/", "\\")


class WopiViewSet(viewsets.ViewSet):
"""
Expand Down Expand Up @@ -185,8 +188,12 @@
s3_client = default_storage.connection.meta.client
default_storage.save(item.file_key, file)
item.size = file.size
# Keep the item READY during re-analysis: non-creators cannot open
# non-READY files in WOPI.
item.save(update_fields=["size", "updated_at"])

malware_detection.analyse_file(item.file_key, item_id=item.id)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This approach leave a window during which a dangerous file is in READY state.

That being said, to avoid degrading live edition I understand we have no better choice for now.
The fine grade solution is indeed only allowing wopi editors to access the wopi API via this PR: #416


head_response = s3_client.head_object(Bucket=default_storage.bucket_name, Key=item.file_key)
return Response(
status=200,
Expand Down Expand Up @@ -332,25 +339,40 @@
return Response(status=401)

new_filename = request.META.get("HTTP_X_WOPI_REQUESTEDNAME")

if not new_filename:
invalid_filename_error = "No filename provided"
else:
# Convert it to utf-7 to avoid issues with special characters
new_filename = new_filename.encode("ascii").decode("utf-7")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why utf-7, why not utf-8 as utf-7 is considered obsolete?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

utf-7 is part of wopi specs…

new_filename_with_extension = f"{new_filename}{splitext(item.filename)[1]}"
_, target_extension = splitext(new_filename_with_extension)

invalid_filename_error = None
if any(char in new_filename for char in ILLEGAL_FILENAME_CHARS):
invalid_filename_error = "Invalid filename"
elif settings.RESTRICT_UPLOAD_FILE_TYPE and target_extension.lower() not in {
extension.lower() for extension in settings.FILE_EXTENSIONS_ALLOWED
}:
logger.info(
"rename_file: file extension not allowed %r for filename %r",
target_extension,
new_filename_with_extension,
)

Check warning on line 360 in src/backend/wopi/viewsets.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Change this code to not log user-controlled data.

See more on https://sonarcloud.io/project/issues?id=suitenumerique_drive&issues=AZ_IIVsdtL8FJV4YUqQk&open=AZ_IIVsdtL8FJV4YUqQk&pullRequest=795
invalid_filename_error = "This file extension is not allowed"

if invalid_filename_error:
return Response(
status=400,
headers={X_WOPI_INVALIDFILENAMERROR: "No filename provided"},
headers={X_WOPI_INVALIDFILENAMERROR: invalid_filename_error},
)

# Convert it to utf-7 to avoid issues with special characters
new_filename = new_filename.encode("ascii").decode("utf-7")
lock_service = LockService(item)
if lock_service.is_locked():
current_lock_value = lock_service.get_lock(default="")
lock_value = request.META.get(HTTP_X_WOPI_LOCK)
if current_lock_value != lock_value:
return Response(status=409, headers={X_WOPI_LOCK: current_lock_value})

_, current_extension = splitext(item.filename)
new_filename_with_extension = f"{new_filename}{current_extension}"

parent_path = item.path[:-1]
# Filter on siblings with the desired filename
queryset = (
Expand Down
Loading