Skip to content

Commit fad1f1e

Browse files
🧵 [#3858] Fix concurrent session updates with multiple uploads
Multiple uploads fire in parallel by the browser, which apparently causes the views/middleware to start processing the session data. This means that when an upload completes and it writes the upload UUID to the session, that each upload thinks there were no previous uploads, so it only writes itself to the session. The last completed upload is then the only one saved in the session data, and available for permission checks. Due to earlier uploads not being present in the session data, deleting those results in HTTP 403 errors, as we use the session in this case for access control. Starting multiple submissions (for different forms) would have had the same problem if they happen in quick succession, so we may have fixed another unreported problem. Note that updating the session here is very tricky code - however using some kind of "signed URL" is no proper solution either. We already use near-impossible to guess UUIDs in the URLs, but the risk remains the same: if you have the URL, you can read/delete potentially sensitive data that should only be available to the uploader. So, we *need* to maintain server-side state to guarantee this. As for the fix itself, I've opted to not solve this in a generic way that would allow concurrency on session data in general, the fact that Django doesn't handle this out of the box seems like good warning about the complexity involved in solving such a problem. In particular, tracking which changes are deliberate and which changes are stale does not seem feasible. Instead, a very localized solution is applied in problem areas known to suffer from concurrency issues - for which the bulk upload of files in the browser is probably the most common. The locking parameters have been guesstimated, taking into account current browser behaviour in amount of parallel requests they actually execute (typically 6). Local profiling shows that the actual lock acquiring is very fast. This makes redis a hard-dependency in the codebase, but we didn't really support alternative cache backends anyway. Backport-of: #3869
1 parent 3e034cb commit fad1f1e

File tree

4 files changed

+158
-15
lines changed

4 files changed

+158
-15
lines changed

src/openforms/conf/base.py

+7-3
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,8 @@
112112
"IGNORE_EXCEPTIONS": True,
113113
},
114114
},
115+
# TODO: rename to 'redis-locks' and get rid of portalocker in favour of plain
116+
# redis locks?
115117
"portalocker": {
116118
"BACKEND": "django_redis.cache.RedisCache",
117119
"LOCATION": f"redis://{config('CACHE_PORTALOCKER', 'localhost:6379/0')}",
@@ -465,9 +467,11 @@
465467
"level": "DEBUG",
466468
},
467469
"log_outgoing_requests": {
468-
"handlers": ["log_outgoing_requests", "save_outgoing_requests"]
469-
if LOG_REQUESTS
470-
else [],
470+
"handlers": (
471+
["log_outgoing_requests", "save_outgoing_requests"]
472+
if LOG_REQUESTS
473+
else []
474+
),
471475
"level": "DEBUG",
472476
"propagate": True,
473477
},

src/openforms/formio/tests/test_api_fileupload.py

+53-1
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import os
22
import tempfile
3+
from concurrent.futures import ThreadPoolExecutor, as_completed
34
from pathlib import Path
45
from unittest.mock import patch
56

7+
from django.conf import settings
68
from django.core.files.uploadedfile import SimpleUploadedFile
79
from django.test import override_settings, tag
810
from django.utils.translation import gettext as _
@@ -11,7 +13,7 @@
1113
from privates.test import temp_private_root
1214
from rest_framework import status
1315
from rest_framework.reverse import reverse
14-
from rest_framework.test import APITestCase
16+
from rest_framework.test import APITestCase, APITransactionTestCase
1517

1618
from openforms.config.models import GlobalConfiguration
1719
from openforms.submissions.attachments import temporary_upload_from_url
@@ -344,3 +346,53 @@ def test_cannot_connect_to_clamdav(self, m_config):
344346
tmpdir_contents = os.listdir(tmpdir)
345347

346348
self.assertEqual(0, len(tmpdir_contents))
349+
350+
351+
@override_settings(
352+
# Deliberately set to cache backend to not fall in the trap of using DB row-level
353+
# locking. This also reflects how we deploy in prod.
354+
SESSION_ENGINE="django.contrib.sessions.backends.cache",
355+
SESSION_CACHE_ALIAS="session",
356+
CACHES={
357+
**settings.CACHES,
358+
"session": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"},
359+
},
360+
)
361+
class ConcurrentUploadTests(SubmissionsMixin, APITransactionTestCase):
362+
363+
@tag("gh-3858")
364+
def test_concurrent_file_uploads(self):
365+
submission = SubmissionFactory.from_components(
366+
[
367+
{
368+
"type": "file",
369+
"key": "file",
370+
"label": "Some upload",
371+
"multiple": True,
372+
}
373+
]
374+
)
375+
self._add_submission_to_session(submission)
376+
endpoint = reverse("api:formio:temporary-file-upload")
377+
378+
def do_upload() -> str:
379+
file = SimpleUploadedFile(
380+
"my-file.txt", b"my content", content_type="text/plain"
381+
)
382+
response = self.client.post(endpoint, {"file": file}, format="multipart")
383+
assert response.status_code == status.HTTP_200_OK
384+
resp_data = response.json()
385+
return resp_data["url"]
386+
387+
# do both uploads in parallel in their own thread
388+
with ThreadPoolExecutor(max_workers=2) as executor:
389+
futures = [executor.submit(do_upload) for _ in range(0, 2)]
390+
urls = [future.result() for future in as_completed(futures)]
391+
392+
uuids = {
393+
url.removeprefix("http://testserver/api/v2/submissions/files/")
394+
for url in urls
395+
}
396+
397+
session_uuids = set(self.client.session[UPLOADS_SESSION_KEY])
398+
self.assertEqual(session_uuids, uuids)

src/openforms/submissions/api/viewsets.py

-1
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,6 @@ def perform_create(self, serializer):
172172

173173
# store the submission ID in the session, so that only the session owner can
174174
# mutate/view the submission
175-
# note: possible race condition with concurrent requests
176175
add_submmission_to_session(serializer.instance, self.request.session)
177176

178177
logevent.submission_start(serializer.instance)

src/openforms/submissions/utils.py

+98-10
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import logging
2+
from contextlib import contextmanager
23
from typing import Any, Union
34

45
from django.conf import settings
56
from django.contrib.sessions.backends.base import SessionBase
7+
from django.core.cache import caches
68
from django.http import HttpRequest
79
from django.utils import translation
810

@@ -37,23 +39,109 @@
3739

3840
logger = logging.getLogger(__name__)
3941

42+
# with the interval of 0.1s, this gives us 2.0 / 0.1 = 20 concurrent requests,
43+
# which is far above the typical browser concurrency mode (~6-8 requests).
44+
SESSION_LOCK_TIMEOUT_SECONDS = 2.0
45+
46+
47+
@contextmanager
48+
def _session_lock(session: SessionBase, key: str):
49+
"""
50+
Helper to manage session data mutations for the specified key.
51+
52+
Concurrent session updates see stale data from when the request initially
53+
got processed, so any added items from parallel requests is not taken into
54+
account. This context manager refreshes the session data just-in-time and uses
55+
a Redis distributed lock to synchronize access.
56+
57+
.. note:: this is pretty deep in Django internals, there doesn't appear to be a
58+
public API for things like these :(
59+
"""
60+
# only existing session have an existing key. If this is a new session, it hasn't
61+
# been persisted to the backend yet, so there is also no possible race condition.
62+
is_new = session.session_key is None
63+
if is_new:
64+
yield
65+
return
66+
67+
# See TODO in settings about renaming this cache
68+
redis_cache = caches["portalocker"]
69+
70+
# make the lock tied to the session itself, so that we don't affect other people's
71+
# sessions.
72+
cache_key = f"django:session-update:{session.session_key}"
73+
74+
# this is... tricky. To ensure we aren't still operating on stale data, we refresh
75+
# the session data after acquiring a lock so that we're the only one that will be
76+
# writing to it.
77+
#
78+
# For the locking interface, see redis-py :meth:`redis.client.Redis.lock`
79+
80+
logger.debug("Acquiring session lock for session %s", session.session_key)
81+
with redis_cache.lock(
82+
cache_key,
83+
# max lifetime for the lock itself, must always be provided in case something
84+
# crashes and we fail to call release
85+
timeout=SESSION_LOCK_TIMEOUT_SECONDS,
86+
# wait rather than failing immediately, we are trying to handle parallel
87+
# requests here. Can't explicitly specify this, see
88+
# https://github.com/jazzband/django-redis/issues/596. redis-py default is True.
89+
# blocking=True,
90+
# how long we can try to acquire the lock
91+
blocking_timeout=SESSION_LOCK_TIMEOUT_SECONDS,
92+
):
93+
logger.debug("Got session lock for session %s", session.session_key)
94+
# nasty bit... the session itself can already be modified with *other*
95+
# information that isn't relevant. So, we load the data from the storage again
96+
# and only look at the provided key. If that one is different, we update our
97+
# local data. We can not just reset to the result of session.load(), as that
98+
# would discard modifications that should be persisted.
99+
persisted_data = session.load()
100+
if (data_slice := persisted_data.get(key)) != (current := session.get(key)):
101+
logger.debug(
102+
"Data from storage is different than what we currently have. "
103+
"Session %s, key '%s' - in storage: %s, our view: %s",
104+
session.session_key,
105+
key,
106+
data_slice,
107+
current,
108+
)
109+
session[key] = data_slice
110+
logger.debug(
111+
"Updated key '%s' from storage for session %s", key, session.session_key
112+
)
113+
114+
# execute the calling code and exit, clearing the lock.
115+
yield
116+
117+
logger.debug(
118+
"New session data for session %s is: %s",
119+
session.session_key,
120+
session._session,
121+
)
122+
123+
# ensure we save in-between to persist the modifications, before the request
124+
# may even be finished
125+
session.save()
126+
logger.debug("Saved session data for session %s", session.session_key)
127+
40128

41129
def append_to_session_list(session: SessionBase, session_key: str, value: Any) -> None:
42-
# note: possible race condition with concurrent requests
43-
active = session.get(session_key, [])
44-
if value not in active:
45-
active.append(value)
46-
session[session_key] = active
130+
with _session_lock(session, session_key):
131+
active = session.get(session_key, [])
132+
if value not in active:
133+
active.append(value)
134+
session[session_key] = active
47135

48136

49137
def remove_from_session_list(
50138
session: SessionBase, session_key: str, value: Any
51139
) -> None:
52-
# note: possible race condition with concurrent requests
53-
active = session.get(session_key, [])
54-
if value in active:
55-
active.remove(value)
56-
session[session_key] = active
140+
with _session_lock(session, session_key):
141+
active = session.get(session_key, [])
142+
if value in active:
143+
active.remove(value)
144+
session[session_key] = active
57145

58146

59147
def add_submmission_to_session(submission: Submission, session: SessionBase) -> None:

0 commit comments

Comments
 (0)