Ticket #37249: session_resurrection_race_repro.py

File session_resurrection_race_repro.py, 9.4 KB (added by Shai Berger, 104 minutes ago)
Line 
1#!/usr/bin/env python
2"""
3Standalone Django session resurrection race reproduction.
4
5Run from a Django checkout with:
6
7 python session_resurrection_race_repro.py
8
9The script builds a minimal Django app with real auth/session middleware and
10three endpoints:
11
12 /slow-save/ - authenticated endpoint that reads and modifies the session.
13 /logout/ - calls django.contrib.auth.logout(request).
14 /whoami/ - reports authentication state and session key.
15
16For positive cache/file cases, the script instruments the backend write window
17so that logout deletes the session after the backend has observed the old
18session as existing, but before the final write recreates it.
19"""
20
21import json
22import os
23import subprocess
24import sys
25import textwrap
26
27
28CASE_SCRIPT = r"""
29import json
30import os
31import tempfile
32import threading
33from importlib import import_module
34
35from django.conf import settings
36
37label = os.environ["LABEL"]
38session_engine = os.environ["SESSION_ENGINE"]
39base_dir = tempfile.mkdtemp(prefix="django-session-race-")
40
41view_entered = threading.Event()
42view_release = threading.Event()
43save_window_entered = threading.Event()
44save_window_release = threading.Event()
45pause_save_window = threading.Event()
46
47if label == "cache":
48 from django.core.cache.backends.locmem import LocMemCache
49
50 class PausingLocMemCache(LocMemCache):
51 def set(self, key, value, timeout=None, version=None):
52 if (
53 pause_save_window.is_set()
54 and key.startswith("django.contrib.sessions.cache")
55 and not save_window_entered.is_set()
56 ):
57 save_window_entered.set()
58 if not save_window_release.wait(timeout=10):
59 raise RuntimeError("save-window release timed out")
60 return super().set(key, value, timeout=timeout, version=version)
61
62 cache_backend = "__main__.PausingLocMemCache"
63else:
64 cache_backend = "django.core.cache.backends.locmem.LocMemCache"
65
66settings.configure(
67 SECRET_KEY="secret",
68 DEBUG=False,
69 ALLOWED_HOSTS=["testserver"],
70 ROOT_URLCONF="__main__",
71 DEFAULT_AUTO_FIELD="django.db.models.AutoField",
72 INSTALLED_APPS=[
73 "django.contrib.auth",
74 "django.contrib.contenttypes",
75 "django.contrib.sessions",
76 ],
77 DATABASES={
78 "default": {
79 "ENGINE": "django.db.backends.sqlite3",
80 "NAME": os.path.join(base_dir, "db.sqlite3"),
81 }
82 },
83 MIDDLEWARE=[
84 "django.contrib.sessions.middleware.SessionMiddleware",
85 "django.contrib.auth.middleware.AuthenticationMiddleware",
86 ],
87 SESSION_ENGINE=session_engine,
88 SESSION_CACHE_ALIAS="default",
89 SESSION_FILE_PATH=os.path.join(base_dir, "sessions"),
90 SESSION_COOKIE_NAME="sessionid",
91 SESSION_COOKIE_AGE=1209600,
92 SESSION_COOKIE_DOMAIN=None,
93 SESSION_COOKIE_PATH="/",
94 SESSION_COOKIE_SECURE=False,
95 SESSION_COOKIE_HTTPONLY=True,
96 SESSION_COOKIE_SAMESITE="Lax",
97 SESSION_SAVE_EVERY_REQUEST=False,
98 CACHES={
99 "default": {
100 "BACKEND": cache_backend,
101 "LOCATION": "session-race-" + label,
102 }
103 },
104 PASSWORD_HASHERS=["django.contrib.auth.hashers.MD5PasswordHasher"],
105 USE_TZ=True,
106)
107os.makedirs(settings.SESSION_FILE_PATH, exist_ok=True)
108
109import django
110
111django.setup()
112
113from django.contrib.auth import logout
114from django.contrib.auth.models import User
115from django.core.cache import caches
116from django.core.management import call_command
117from django.http import JsonResponse
118from django.test import Client
119from django.urls import path
120
121call_command("migrate", verbosity=0, interactive=False)
122User.objects.create_user(username="alice", password="password")
123caches["default"].clear()
124
125if label == "file":
126 from django.contrib.sessions.backends import file as file_backend
127
128 original_move = file_backend.shutil.move
129
130 def pausing_move(src, dst, *args, **kwargs):
131 if (
132 pause_save_window.is_set()
133 and os.path.basename(dst).startswith(settings.SESSION_COOKIE_NAME)
134 and not save_window_entered.is_set()
135 ):
136 save_window_entered.set()
137 if not save_window_release.wait(timeout=10):
138 raise RuntimeError("save-window release timed out")
139 return original_move(src, dst, *args, **kwargs)
140
141 file_backend.shutil.move = pausing_move
142
143
144def session_exists(key):
145 if not key:
146 return False
147 store = import_module(settings.SESSION_ENGINE).SessionStore()
148 return bool(store.exists(key))
149
150
151def slow_save(request):
152 if not request.user.is_authenticated:
153 return JsonResponse({"error": "not authenticated"}, status=401)
154 old_key = request.session.session_key
155 request.session.get("_auth_user_id") # Force session data to load.
156 request.session["race_marker"] = label
157 request.session["counter"] = request.session.get("counter", 0) + 1
158 view_entered.set()
159 released = view_release.wait(timeout=10)
160 return JsonResponse(
161 {
162 "ok": released,
163 "slow_key": old_key,
164 "session_key_now": request.session.session_key,
165 }
166 )
167
168
169def logout_view(request):
170 old_key = request.session.session_key
171 before = session_exists(old_key)
172 logout(request)
173 after = session_exists(old_key)
174 return JsonResponse(
175 {
176 "old_key": old_key,
177 "exists_before_logout": before,
178 "exists_after_logout_view": after,
179 }
180 )
181
182
183def whoami(request):
184 return JsonResponse(
185 {
186 "authenticated": bool(request.user.is_authenticated),
187 "username": request.user.get_username()
188 if request.user.is_authenticated
189 else "",
190 "session_key": request.session.session_key,
191 "race_marker": request.session.get("race_marker"),
192 "auth_user_id": request.session.get("_auth_user_id"),
193 }
194 )
195
196
197urlpatterns = [
198 path("slow-save/", slow_save),
199 path("logout/", logout_view),
200 path("whoami/", whoami),
201]
202
203login_client = Client(raise_request_exception=False)
204assert login_client.login(username="alice", password="password")
205old_sessionid = login_client.cookies[settings.SESSION_COOKIE_NAME].value
206
207slow_client = Client(raise_request_exception=False)
208logout_client = Client(raise_request_exception=False)
209slow_client.cookies[settings.SESSION_COOKIE_NAME] = old_sessionid
210logout_client.cookies[settings.SESSION_COOKIE_NAME] = old_sessionid
211
212pause_save_window.set()
213slow_box = {}
214
215
216def run_slow_request():
217 slow_box["response"] = slow_client.get("/slow-save/")
218
219
220thread = threading.Thread(target=run_slow_request)
221thread.start()
222assert view_entered.wait(timeout=10), "slow-save did not reach view barrier"
223view_release.set()
224assert save_window_entered.wait(timeout=10), "slow-save did not reach save window"
225
226logout_response = logout_client.get("/logout/")
227logout_cookie = logout_response.cookies.get(settings.SESSION_COOKIE_NAME)
228exists_after_logout = session_exists(old_sessionid)
229
230save_window_release.set()
231thread.join(timeout=10)
232
233slow_response = slow_box["response"]
234slow_cookie = slow_response.cookies.get(settings.SESSION_COOKIE_NAME)
235exists_after_slow = session_exists(old_sessionid)
236
237# Apply browser-like final cookie state:
238# initial cookie -> logout deletion -> late slow-save Set-Cookie.
239final_cookie = old_sessionid
240if logout_cookie is not None:
241 if logout_cookie.value == "" or logout_cookie.get("max-age") in (0, "0"):
242 final_cookie = None
243 else:
244 final_cookie = logout_cookie.value
245if slow_cookie is not None:
246 if slow_cookie.value == "" or slow_cookie.get("max-age") in (0, "0"):
247 final_cookie = None
248 else:
249 final_cookie = slow_cookie.value
250
251who_client = Client(raise_request_exception=False)
252if final_cookie:
253 who_client.cookies[settings.SESSION_COOKIE_NAME] = final_cookie
254whoami_response = who_client.get("/whoami/")
255whoami = json.loads(whoami_response.content.decode())
256
257result = {
258 "backend": session_engine,
259 "case": label,
260 "django": django.get_version(),
261 "old_sessionid": old_sessionid,
262 "logout_status": logout_response.status_code,
263 "logout_json": json.loads(logout_response.content.decode()),
264 "logout_deleted_cookie": bool(
265 logout_cookie is not None
266 and logout_cookie.value == ""
267 and logout_cookie.get("max-age") in (0, "0")
268 ),
269 "exists_after_logout": exists_after_logout,
270 "slow_status": slow_response.status_code,
271 "slow_json": json.loads(slow_response.content.decode()),
272 "slow_set_cookie_same_old_key": bool(
273 slow_cookie is not None and slow_cookie.value == old_sessionid
274 ),
275 "exists_after_slow": exists_after_slow,
276 "final_cookie": final_cookie,
277 "whoami": whoami,
278}
279print(json.dumps(result, sort_keys=True))
280"""
281
282
283def run_case(label, session_engine):
284 env = os.environ.copy()
285 env["LABEL"] = label
286 env["SESSION_ENGINE"] = session_engine
287 return subprocess.run(
288 [sys.executable, "-c", CASE_SCRIPT],
289 text=True,
290 capture_output=True,
291 env=env,
292 timeout=60,
293 )
294
295
296def main():
297 cases = [
298 ("cache", "django.contrib.sessions.backends.cache"),
299 ("file", "django.contrib.sessions.backends.file"),
300 ]
301 for label, engine in cases:
302 proc = run_case(label, engine)
303 print(f"=== {label} ===")
304 if proc.stdout:
305 print(proc.stdout.strip())
306 if proc.stderr:
307 print("STDERR:")
308 print(textwrap.indent(proc.stderr.strip(), " "))
309 if proc.returncode:
310 raise SystemExit(proc.returncode)
311
312
313if __name__ == "__main__":
314 main()
Back to Top