@@ -108,23 +108,43 @@ def test_get_tainted_ranges_returns_empty_without_context():
108108 assert get_tainted_ranges ("xyz" ) == tuple ()
109109
110110
111- def test_in_taint_map_scans_container_across_active_maps ():
111+ def test_is_pyobject_tainted_is_scoped_to_active_slot ():
112+ """Taint queries resolve only within the active request slot.
113+
114+ Two independent slots each hold their own tainted object. Each object is
115+ reported tainted only while its owning slot is the active ``IAST_CONTEXT``.
116+ With no active request the query short-circuits to ``False`` instead of
117+ scanning every slot in native code -- that no-context scan runs
118+ ``is_text(obj)`` on the argument and can fault on a type freed during
119+ GC/finalization (see the regression tests below).
120+ """
112121 clear_all_request_context_slots ()
113122 _end_iast_context_and_oce ()
114123
115- # Start two independent maps
116124 ctx1 = start_request_context ()
117125 ctx2 = start_request_context ()
118126 assert ctx1 is not None and ctx2 is not None and ctx1 != ctx2
119127
120- target = "TAINT_ME"
121- # Taint the string into ctx1 explicitly without setting ContextVar
122- target_tainted1 = _taint_pyobject_base (target , "p" , target , OriginType .PARAMETER , contextid = ctx1 )
123- target_tainted2 = _taint_pyobject_base (target , "p" , target , OriginType .PARAMETER , contextid = ctx2 )
128+ # Distinct values -> distinct objects, so each lives in exactly one slot.
129+ tainted_in_1 = _taint_pyobject_base ("TAINT_ONE" , "p" , "TAINT_ONE" , OriginType .PARAMETER , contextid = ctx1 )
130+ tainted_in_2 = _taint_pyobject_base ("TAINT_TWO" , "p" , "TAINT_TWO" , OriginType .PARAMETER , contextid = ctx2 )
124131
125- assert is_pyobject_tainted (target ) is False
126- assert is_pyobject_tainted (target_tainted1 ) is True
127- assert is_pyobject_tainted (target_tainted2 ) is True
132+ IAST_CONTEXT .set (ctx1 )
133+ assert is_pyobject_tainted (tainted_in_1 ) is True
134+ assert is_pyobject_tainted (tainted_in_2 ) is False
135+
136+ IAST_CONTEXT .set (ctx2 )
137+ assert is_pyobject_tainted (tainted_in_2 ) is True
138+ assert is_pyobject_tainted (tainted_in_1 ) is False
139+
140+ # No active request => queries short-circuit to False (native is not consulted).
141+ IAST_CONTEXT .set (None )
142+ assert is_pyobject_tainted (tainted_in_1 ) is False
143+ assert is_pyobject_tainted (tainted_in_2 ) is False
144+
145+ finish_request_context (ctx1 )
146+ finish_request_context (ctx2 )
147+ IAST_CONTEXT .set (None )
128148
129149
130150def test_num_objects_tainted_is_per_current_context ():
@@ -229,3 +249,92 @@ def test_get_ranges_public_api_does_not_leak_across_request_slots():
229249 finish_request_context (ctx_a )
230250 finish_request_context (ctx_b )
231251 IAST_CONTEXT .set (None )
252+
253+
254+ def test_is_pyobject_tainted_without_context_does_not_consult_native ():
255+ """Regression for a teardown/GC crash (SIGSEGV in ``is_text``).
256+
257+ With no active request context the query must return ``False`` *without*
258+ entering native code. Previously ``is_pyobject_tainted`` forwarded to the
259+ native ``is_in_taint_map`` which, with a ``None`` context id, ran the
260+ multi-slot resolver whose first step is ``is_text(obj)``. Invoked from a
261+ uvloop async-generator/task finalizer during shutdown, that dereferenced the
262+ type of an object already freed by the cyclic GC and crashed the process.
263+ """
264+ clear_all_request_context_slots ()
265+ _end_iast_context_and_oce ()
266+
267+ ctx = start_request_context ()
268+ assert ctx is not None
269+ tainted = _taint_pyobject_base (
270+ "http://dummy.location.com" ,
271+ "location" ,
272+ "http://dummy.location.com" ,
273+ OriginType .PARAMETER ,
274+ contextid = ctx ,
275+ )
276+ IAST_CONTEXT .set (ctx )
277+ assert is_pyobject_tainted (tainted ) is True # sanity: visible within its own slot
278+
279+ # Detach the context, as happens once the request finishes but the tainted
280+ # object is still referenced by a soon-to-be-finalized structure.
281+ IAST_CONTEXT .set (None )
282+ assert is_pyobject_tainted (tainted ) is False
283+ assert get_tainted_ranges (tainted ) == tuple ()
284+
285+ finish_request_context (ctx )
286+ IAST_CONTEXT .set (None )
287+
288+
289+ def test_taint_query_from_finalizer_without_context_is_safe ():
290+ """A ``__del__`` that queries taint outside any request must not crash.
291+
292+ This mirrors the production stack: a finalizer runs under the GC while no
293+ IAST request context is active. The query must resolve to ``False`` in
294+ Python without touching the native taint map.
295+ """
296+ import gc
297+
298+ clear_all_request_context_slots ()
299+ _end_iast_context_and_oce ()
300+ IAST_CONTEXT .set (None )
301+
302+ results = []
303+
304+ class _QueriesTaintOnDel :
305+ def __init__ (self , value ):
306+ self .value = value
307+
308+ def __del__ (self ):
309+ results .append (is_pyobject_tainted (self .value ))
310+
311+ obj = _QueriesTaintOnDel ("some-tainted-looking-string" )
312+ del obj
313+ gc .collect ()
314+
315+ assert results == [False ]
316+
317+
318+ def test_get_ranges_public_api_returns_empty_without_context ():
319+ """The raw public ``get_ranges`` (used by aspects/sinks) also short-circuits.
320+
321+ With no active request the wrapper must return empty rather than running the
322+ native no-context resolver, which is the same ``is_text``-on-a-freed-type
323+ crash path guarded above.
324+ """
325+ from ddtrace .appsec ._iast ._taint_tracking import get_ranges
326+
327+ clear_all_request_context_slots ()
328+ _end_iast_context_and_oce ()
329+
330+ ctx = start_request_context ()
331+ assert ctx is not None
332+ tainted = _taint_pyobject_base ("secret" , "p" , "secret" , OriginType .PARAMETER , contextid = ctx )
333+
334+ # The tainted object lives in a real slot, but with no active context the
335+ # public wrapper must not consult the native multi-slot resolver.
336+ IAST_CONTEXT .set (None )
337+ assert list (get_ranges (tainted )) == []
338+
339+ finish_request_context (ctx )
340+ IAST_CONTEXT .set (None )
0 commit comments