tusd: Reject tusd terminations after we insert them into our database.

The tusd protocol allows DELETE requests ("terminations") at any
point, including after a file has successfully been uploaded.  This
can allow tusd to remove a file from the bucket, out from under Zulip.

We use the new-in-2.7.0 pre-terminate hook to look up the file which
the client is requesting to terminate, and reject the termination if
it is a file that the Zulip database is already aware of.
This commit is contained in:
Alex Vandiver
2025-04-11 14:41:11 +00:00
committed by Tim Abbott
parent 21eff33875
commit cf51013bb7
3 changed files with 99 additions and 3 deletions

View File

@@ -55,7 +55,7 @@ class Command(BaseCommand):
"-behind-proxy",
f"-hooks-http={hooks_http}",
"-hooks-http-forward-headers=Cookie,Authorization",
"--hooks-enabled-events=pre-create,pre-finish",
"--hooks-enabled-events=pre-create,pre-finish,pre-terminate",
"-disable-download",
"--show-startup-logs=false",
]

View File

@@ -8,7 +8,12 @@ from typing_extensions import ParamSpec
from zerver.lib.cache import cache_delete, get_realm_used_upload_space_cache_key
from zerver.lib.test_classes import ZulipTestCase
from zerver.lib.test_helpers import create_s3_buckets, find_key_by_email, use_s3_backend
from zerver.lib.upload import sanitize_name, upload_backend, upload_message_attachment
from zerver.lib.upload import (
create_attachment,
sanitize_name,
upload_backend,
upload_message_attachment,
)
from zerver.lib.upload.s3 import S3UploadBackend
from zerver.lib.utils import assert_is_not_none
from zerver.models import Attachment, PreregistrationRealm, Realm
@@ -662,3 +667,76 @@ class TusdPreFinishTest(ZulipTestCase):
response = bucket.Object(f"{path_id}.info").get()
self.assertEqual(response["ContentType"], "binary/octet-stream")
class TusdPreTerminateTest(ZulipTestCase):
def request(self, info: TusUpload) -> TusHook:
return TusHook(
type="pre-terminate",
event=TusEvent(
upload=info,
http_request=TusHTTPRequest(
method="PATCH",
uri=f"/api/v1/tus/{info.id}",
remote_addr="12.34.56.78",
header={},
),
),
)
def test_tusd_pre_terminate_hook(self) -> None:
self.login("hamlet")
hamlet = self.example_user("hamlet")
# Act like tusd does -- put the file and its .info in place
path_id = upload_backend.generate_message_upload_path(
str(hamlet.realm.id), sanitize_name("zulip.txt")
)
upload_backend.upload_message_attachment(
path_id, "zulip.txt", "text/plain", b"zulip!", hamlet
)
info = TusUpload(
id=path_id,
size=len("zulip!"),
offset=0,
size_is_deferred=False,
meta_data={
"filename": "zulip.txt",
"filetype": "text/plain",
"name": "zulip.txt",
"type": "text/plain",
},
is_final=False,
is_partial=False,
partial_uploads=None,
storage=None,
)
# Try to terminate the upload before it's in Attachments
result = self.client_post(
"/api/internal/tusd",
self.request(info).model_dump(),
content_type="application/json",
)
self.assertEqual(result.status_code, 200)
self.assertEqual(result.json(), {})
# Make the attachment
create_attachment(
"zulip.txt",
path_id,
"text/plain",
b"zulip!",
hamlet,
hamlet.realm,
)
# The terminate should get rejected now
result = self.client_post(
"/api/internal/tusd",
self.request(info).model_dump(),
content_type="application/json",
)
self.assertEqual(result.status_code, 200)
self.assertEqual(result.json(), {"RejectTermination": True})

View File

@@ -26,7 +26,7 @@ from zerver.lib.upload import (
sanitize_name,
upload_backend,
)
from zerver.models import PreregistrationRealm, Realm, UserProfile
from zerver.models import ArchivedAttachment, Attachment, PreregistrationRealm, Realm, UserProfile
# See https://tus.github.io/tusd/advanced-topics/hooks/ for the spec
@@ -219,6 +219,22 @@ def handle_upload_pre_finish_hook(
)
def handle_upload_pre_terminate_hook(
request: HttpRequest, user_profile: UserProfile, data: TusUpload
) -> HttpResponse:
path_id = data.id.partition("+")[0]
if (
Attachment.objects.filter(path_id=path_id).exists()
or ArchivedAttachment.objects.filter(path_id=path_id).exists()
):
# Once we have it in our Attachments table (i.e. the
# pre-upload-finished hook has run), it is ours to manage and
# we no longer accept terminations.
return tusd_json_response({"RejectTermination": True})
return tusd_json_response({})
def authenticate_user(request: HttpRequest) -> UserProfile | AnonymousUser:
# This acts like the authenticated_rest_api_view wrapper, while
# allowing fallback to session-based request.user
@@ -291,6 +307,8 @@ def handle_tusd_hook(
return handle_upload_pre_create_hook(request, maybe_user, payload.event.upload)
elif hook_name == "pre-finish":
return handle_upload_pre_finish_hook(request, maybe_user, payload.event.upload)
elif hook_name == "pre-terminate":
return handle_upload_pre_terminate_hook(request, maybe_user, payload.event.upload)
else:
return HttpResponseNotFound()