| 1 | #!/usr/bin/env python
|
|---|
| 2 | """
|
|---|
| 3 | Standalone Django session resurrection race reproduction.
|
|---|
| 4 |
|
|---|
| 5 | Run from a Django checkout with:
|
|---|
| 6 |
|
|---|
| 7 | python session_resurrection_race_repro.py
|
|---|
| 8 |
|
|---|
| 9 | The script builds a minimal Django app with real auth/session middleware and
|
|---|
| 10 | three 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 |
|
|---|
| 16 | For positive cache/file cases, the script instruments the backend write window
|
|---|
| 17 | so that logout deletes the session after the backend has observed the old
|
|---|
| 18 | session as existing, but before the final write recreates it.
|
|---|
| 19 | """
|
|---|
| 20 |
|
|---|
| 21 | import json
|
|---|
| 22 | import os
|
|---|
| 23 | import subprocess
|
|---|
| 24 | import sys
|
|---|
| 25 | import textwrap
|
|---|
| 26 |
|
|---|
| 27 |
|
|---|
| 28 | CASE_SCRIPT = r"""
|
|---|
| 29 | import json
|
|---|
| 30 | import os
|
|---|
| 31 | import tempfile
|
|---|
| 32 | import threading
|
|---|
| 33 | from importlib import import_module
|
|---|
| 34 |
|
|---|
| 35 | from django.conf import settings
|
|---|
| 36 |
|
|---|
| 37 | label = os.environ["LABEL"]
|
|---|
| 38 | session_engine = os.environ["SESSION_ENGINE"]
|
|---|
| 39 | base_dir = tempfile.mkdtemp(prefix="django-session-race-")
|
|---|
| 40 |
|
|---|
| 41 | view_entered = threading.Event()
|
|---|
| 42 | view_release = threading.Event()
|
|---|
| 43 | save_window_entered = threading.Event()
|
|---|
| 44 | save_window_release = threading.Event()
|
|---|
| 45 | pause_save_window = threading.Event()
|
|---|
| 46 |
|
|---|
| 47 | if 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"
|
|---|
| 63 | else:
|
|---|
| 64 | cache_backend = "django.core.cache.backends.locmem.LocMemCache"
|
|---|
| 65 |
|
|---|
| 66 | settings.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 | )
|
|---|
| 107 | os.makedirs(settings.SESSION_FILE_PATH, exist_ok=True)
|
|---|
| 108 |
|
|---|
| 109 | import django
|
|---|
| 110 |
|
|---|
| 111 | django.setup()
|
|---|
| 112 |
|
|---|
| 113 | from django.contrib.auth import logout
|
|---|
| 114 | from django.contrib.auth.models import User
|
|---|
| 115 | from django.core.cache import caches
|
|---|
| 116 | from django.core.management import call_command
|
|---|
| 117 | from django.http import JsonResponse
|
|---|
| 118 | from django.test import Client
|
|---|
| 119 | from django.urls import path
|
|---|
| 120 |
|
|---|
| 121 | call_command("migrate", verbosity=0, interactive=False)
|
|---|
| 122 | User.objects.create_user(username="alice", password="password")
|
|---|
| 123 | caches["default"].clear()
|
|---|
| 124 |
|
|---|
| 125 | if 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 |
|
|---|
| 144 | def 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 |
|
|---|
| 151 | def 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 |
|
|---|
| 169 | def 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 |
|
|---|
| 183 | def 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 |
|
|---|
| 197 | urlpatterns = [
|
|---|
| 198 | path("slow-save/", slow_save),
|
|---|
| 199 | path("logout/", logout_view),
|
|---|
| 200 | path("whoami/", whoami),
|
|---|
| 201 | ]
|
|---|
| 202 |
|
|---|
| 203 | login_client = Client(raise_request_exception=False)
|
|---|
| 204 | assert login_client.login(username="alice", password="password")
|
|---|
| 205 | old_sessionid = login_client.cookies[settings.SESSION_COOKIE_NAME].value
|
|---|
| 206 |
|
|---|
| 207 | slow_client = Client(raise_request_exception=False)
|
|---|
| 208 | logout_client = Client(raise_request_exception=False)
|
|---|
| 209 | slow_client.cookies[settings.SESSION_COOKIE_NAME] = old_sessionid
|
|---|
| 210 | logout_client.cookies[settings.SESSION_COOKIE_NAME] = old_sessionid
|
|---|
| 211 |
|
|---|
| 212 | pause_save_window.set()
|
|---|
| 213 | slow_box = {}
|
|---|
| 214 |
|
|---|
| 215 |
|
|---|
| 216 | def run_slow_request():
|
|---|
| 217 | slow_box["response"] = slow_client.get("/slow-save/")
|
|---|
| 218 |
|
|---|
| 219 |
|
|---|
| 220 | thread = threading.Thread(target=run_slow_request)
|
|---|
| 221 | thread.start()
|
|---|
| 222 | assert view_entered.wait(timeout=10), "slow-save did not reach view barrier"
|
|---|
| 223 | view_release.set()
|
|---|
| 224 | assert save_window_entered.wait(timeout=10), "slow-save did not reach save window"
|
|---|
| 225 |
|
|---|
| 226 | logout_response = logout_client.get("/logout/")
|
|---|
| 227 | logout_cookie = logout_response.cookies.get(settings.SESSION_COOKIE_NAME)
|
|---|
| 228 | exists_after_logout = session_exists(old_sessionid)
|
|---|
| 229 |
|
|---|
| 230 | save_window_release.set()
|
|---|
| 231 | thread.join(timeout=10)
|
|---|
| 232 |
|
|---|
| 233 | slow_response = slow_box["response"]
|
|---|
| 234 | slow_cookie = slow_response.cookies.get(settings.SESSION_COOKIE_NAME)
|
|---|
| 235 | exists_after_slow = session_exists(old_sessionid)
|
|---|
| 236 |
|
|---|
| 237 | # Apply browser-like final cookie state:
|
|---|
| 238 | # initial cookie -> logout deletion -> late slow-save Set-Cookie.
|
|---|
| 239 | final_cookie = old_sessionid
|
|---|
| 240 | if 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
|
|---|
| 245 | if 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 |
|
|---|
| 251 | who_client = Client(raise_request_exception=False)
|
|---|
| 252 | if final_cookie:
|
|---|
| 253 | who_client.cookies[settings.SESSION_COOKIE_NAME] = final_cookie
|
|---|
| 254 | whoami_response = who_client.get("/whoami/")
|
|---|
| 255 | whoami = json.loads(whoami_response.content.decode())
|
|---|
| 256 |
|
|---|
| 257 | result = {
|
|---|
| 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 | }
|
|---|
| 279 | print(json.dumps(result, sort_keys=True))
|
|---|
| 280 | """
|
|---|
| 281 |
|
|---|
| 282 |
|
|---|
| 283 | def 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 |
|
|---|
| 296 | def 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 |
|
|---|
| 313 | if __name__ == "__main__":
|
|---|
| 314 | main()
|
|---|