@@ -147,6 +147,8 @@ def _handle_replay_request(self, request: HttpRequest, sdk) -> HttpResponse:
147147 with SpanUtils .with_span (span_info ):
148148 response = self .get_response (request )
149149 # REPLAY mode: don't capture the span (it's already recorded)
150+ # But do normalize CSRF tokens in the response so comparison succeeds
151+ response = self ._normalize_csrf_in_response (response )
150152 return response
151153 finally :
152154 # Reset context
@@ -262,6 +264,43 @@ def process_view(
262264 if route :
263265 request ._drift_route_template = route # type: ignore
264266
267+ def _normalize_csrf_in_response (self , response : HttpResponse ) -> HttpResponse :
268+ """Normalize CSRF tokens in the actual response body for REPLAY mode.
269+
270+ In REPLAY mode, we need the actual HTTP response to match the recorded
271+ response (which had CSRF tokens normalized during recording). This modifies
272+ the response body to replace real CSRF tokens with the normalized placeholder.
273+
274+ This only affects HTML responses.
275+
276+ Args:
277+ response: Django HttpResponse object
278+
279+ Returns:
280+ Modified response with normalized CSRF tokens
281+ """
282+ content_type = response .get ("Content-Type" , "" )
283+ if "text/html" not in content_type .lower ():
284+ return response
285+
286+ # Skip normalization for compressed responses - decoding gzip/deflate as UTF-8 would corrupt the body
287+ content_encoding = response .get ("Content-Encoding" , "" ).lower ()
288+ if content_encoding and content_encoding != "identity" :
289+ return response
290+
291+ # Get response body and normalize CSRF tokens
292+ if hasattr (response , "content" ) and response .content :
293+ from .csrf_utils import normalize_csrf_in_body
294+
295+ normalized_body = normalize_csrf_in_body (response .content )
296+ if normalized_body is not None and normalized_body != response .content :
297+ response .content = normalized_body
298+ # Update Content-Length header if present
299+ if "Content-Length" in response :
300+ response ["Content-Length" ] = len (normalized_body )
301+
302+ return response
303+
265304 def _capture_span (self , request : HttpRequest , response : HttpResponse , span_info : SpanInfo ) -> None :
266305 """Create and collect a span from request/response data.
267306
@@ -301,6 +340,17 @@ def _capture_span(self, request: HttpRequest, response: HttpResponse, span_info:
301340 if isinstance (content , bytes ) and len (content ) > 0 :
302341 response_body = content
303342
343+ # Normalize CSRF tokens in HTML responses for consistent record/replay comparison
344+ # This only affects what is stored in the span, not what the browser receives
345+ if response_body :
346+ content_type = response_headers .get ("Content-Type" , "" )
347+ content_encoding = response_headers .get ("Content-Encoding" , "" ).lower ()
348+ # Skip normalization for compressed responses - decoding gzip/deflate as UTF-8 would corrupt the body
349+ if "text/html" in content_type .lower () and (not content_encoding or content_encoding == "identity" ):
350+ from .csrf_utils import normalize_csrf_in_body
351+
352+ response_body = normalize_csrf_in_body (response_body )
353+
304354 output_value = build_output_value (
305355 status_code ,
306356 status_message ,
0 commit comments