Skip to content

Commit 2d392af

Browse files
authored
fix: Data leak in ThreadingIntegration between threads (#4281)
It is possible to leak data from started threads into the main thread via the scopes. (Because the same scope object from the main thread could be changed in the started thread.) This change always makes a fork (copy) of the scopes of the main thread before it propagates those scopes into the started thread.
1 parent 706d2d2 commit 2d392af

File tree

3 files changed

+151
-5
lines changed

3 files changed

+151
-5
lines changed

sentry_sdk/integrations/threading.py

+31-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import sys
2+
import warnings
23
from functools import wraps
34
from threading import Thread, current_thread
45

@@ -49,6 +50,15 @@ def setup_once():
4950
# type: () -> None
5051
old_start = Thread.start
5152

53+
try:
54+
from django import VERSION as django_version # noqa: N811
55+
import channels # type: ignore[import-not-found]
56+
57+
channels_version = channels.__version__
58+
except ImportError:
59+
django_version = None
60+
channels_version = None
61+
5262
@wraps(old_start)
5363
def sentry_start(self, *a, **kw):
5464
# type: (Thread, *Any, **Any) -> Any
@@ -57,8 +67,27 @@ def sentry_start(self, *a, **kw):
5767
return old_start(self, *a, **kw)
5868

5969
if integration.propagate_scope:
60-
isolation_scope = sentry_sdk.get_isolation_scope()
61-
current_scope = sentry_sdk.get_current_scope()
70+
if (
71+
sys.version_info < (3, 9)
72+
and channels_version is not None
73+
and channels_version < "4.0.0"
74+
and django_version is not None
75+
and django_version >= (3, 0)
76+
and django_version < (4, 0)
77+
):
78+
warnings.warn(
79+
"There is a known issue with Django channels 2.x and 3.x when using Python 3.8 or older. "
80+
"(Async support is emulated using threads and some Sentry data may be leaked between those threads.) "
81+
"Please either upgrade to Django channels 4.0+, use Django's async features "
82+
"available in Django 3.1+ instead of Django channels, or upgrade to Python 3.9+.",
83+
stacklevel=2,
84+
)
85+
isolation_scope = sentry_sdk.get_isolation_scope()
86+
current_scope = sentry_sdk.get_current_scope()
87+
88+
else:
89+
isolation_scope = sentry_sdk.get_isolation_scope().fork()
90+
current_scope = sentry_sdk.get_current_scope().fork()
6291
else:
6392
isolation_scope = None
6493
current_scope = None

tests/integrations/django/asgi/test_asgi.py

+19-3
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,25 @@ async def test_basic(sentry_init, capture_events, application):
3838

3939
events = capture_events()
4040

41-
comm = HttpCommunicator(application, "GET", "/view-exc?test=query")
42-
response = await comm.get_response()
43-
await comm.wait()
41+
import channels # type: ignore[import-not-found]
42+
43+
if (
44+
sys.version_info < (3, 9)
45+
and channels.__version__ < "4.0.0"
46+
and django.VERSION >= (3, 0)
47+
and django.VERSION < (4, 0)
48+
):
49+
# We emit a UserWarning for channels 2.x and 3.x on Python 3.8 and older
50+
# because the async support was not really good back then and there is a known issue.
51+
# See the TreadingIntegration for details.
52+
with pytest.warns(UserWarning):
53+
comm = HttpCommunicator(application, "GET", "/view-exc?test=query")
54+
response = await comm.get_response()
55+
await comm.wait()
56+
else:
57+
comm = HttpCommunicator(application, "GET", "/view-exc?test=query")
58+
response = await comm.get_response()
59+
await comm.wait()
4460

4561
assert response["status"] == 500
4662

tests/integrations/threading/test_threading.py

+101
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import gc
22
from concurrent import futures
3+
from textwrap import dedent
34
from threading import Thread
45

56
import pytest
@@ -172,3 +173,103 @@ def target():
172173
assert Thread.run.__qualname__ == original_run.__qualname__
173174
assert t.run.__name__ == "run"
174175
assert t.run.__qualname__ == original_run.__qualname__
176+
177+
178+
@pytest.mark.parametrize(
179+
"propagate_scope",
180+
(True, False),
181+
ids=["propagate_scope=True", "propagate_scope=False"],
182+
)
183+
def test_scope_data_not_leaked_in_threads(sentry_init, propagate_scope):
184+
sentry_init(
185+
integrations=[ThreadingIntegration(propagate_scope=propagate_scope)],
186+
)
187+
188+
sentry_sdk.set_tag("initial_tag", "initial_value")
189+
initial_iso_scope = sentry_sdk.get_isolation_scope()
190+
191+
def do_some_work():
192+
# check if we have the initial scope data propagated into the thread
193+
if propagate_scope:
194+
assert sentry_sdk.get_isolation_scope()._tags == {
195+
"initial_tag": "initial_value"
196+
}
197+
else:
198+
assert sentry_sdk.get_isolation_scope()._tags == {}
199+
200+
# change data in isolation scope in thread
201+
sentry_sdk.set_tag("thread_tag", "thread_value")
202+
203+
t = Thread(target=do_some_work)
204+
t.start()
205+
t.join()
206+
207+
# check if the initial scope data is not modified by the started thread
208+
assert initial_iso_scope._tags == {
209+
"initial_tag": "initial_value"
210+
}, "The isolation scope in the main thread should not be modified by the started thread."
211+
212+
213+
@pytest.mark.parametrize(
214+
"propagate_scope",
215+
(True, False),
216+
ids=["propagate_scope=True", "propagate_scope=False"],
217+
)
218+
def test_spans_from_multiple_threads(
219+
sentry_init, capture_events, render_span_tree, propagate_scope
220+
):
221+
sentry_init(
222+
traces_sample_rate=1.0,
223+
integrations=[ThreadingIntegration(propagate_scope=propagate_scope)],
224+
)
225+
events = capture_events()
226+
227+
def do_some_work(number):
228+
with sentry_sdk.start_span(
229+
op=f"inner-run-{number}", name=f"Thread: child-{number}"
230+
):
231+
pass
232+
233+
threads = []
234+
235+
with sentry_sdk.start_transaction(op="outer-trx"):
236+
for number in range(5):
237+
with sentry_sdk.start_span(
238+
op=f"outer-submit-{number}", name="Thread: main"
239+
):
240+
t = Thread(target=do_some_work, args=(number,))
241+
t.start()
242+
threads.append(t)
243+
244+
for t in threads:
245+
t.join()
246+
247+
(event,) = events
248+
if propagate_scope:
249+
assert render_span_tree(event) == dedent(
250+
"""\
251+
- op="outer-trx": description=null
252+
- op="outer-submit-0": description="Thread: main"
253+
- op="inner-run-0": description="Thread: child-0"
254+
- op="outer-submit-1": description="Thread: main"
255+
- op="inner-run-1": description="Thread: child-1"
256+
- op="outer-submit-2": description="Thread: main"
257+
- op="inner-run-2": description="Thread: child-2"
258+
- op="outer-submit-3": description="Thread: main"
259+
- op="inner-run-3": description="Thread: child-3"
260+
- op="outer-submit-4": description="Thread: main"
261+
- op="inner-run-4": description="Thread: child-4"\
262+
"""
263+
)
264+
265+
elif not propagate_scope:
266+
assert render_span_tree(event) == dedent(
267+
"""\
268+
- op="outer-trx": description=null
269+
- op="outer-submit-0": description="Thread: main"
270+
- op="outer-submit-1": description="Thread: main"
271+
- op="outer-submit-2": description="Thread: main"
272+
- op="outer-submit-3": description="Thread: main"
273+
- op="outer-submit-4": description="Thread: main"\
274+
"""
275+
)

0 commit comments

Comments
 (0)