Repository: readthedocs/readthedocs.org

Issue:
Gold users seeing ads after Django2 upgrade
After the Django 2 upgrade today, gold users are currently seeing ads. This is due to setting `SameSite=Lax` on our session cookie. The cookie is not sent with the ads API request.

While we could revert that part of this change, in the next couple weeks [Chrome is going to start not sending cookies for cross-domain requests](https://www.chromium.org/updates/same-site) even if `SameSite` is unset. Instead, I think we need a workaround or some alternative here. We may do something along the lines of what [Auth0 is doing](https://auth0.com/blog/browser-behavior-changes-what-developers-need-to-know/#SameSite.None) by setting 2 cookies: one with `SameSite` and one without.

This does not affect projects that have been designated as ad-free. Those are still ad-free.


Discussion:

Resolving patch:
diff --git a/readthedocs/core/middleware.py b/readthedocs/core/middleware.py
--- a/readthedocs/core/middleware.py
+++ b/readthedocs/core/middleware.py
@@ -1,12 +1,17 @@
 import logging
+import time
 
 from django.conf import settings
 from django.contrib.sessions.backends.base import SessionBase
+from django.contrib.sessions.backends.base import UpdateError
 from django.contrib.sessions.middleware import SessionMiddleware
+from django.core.exceptions import SuspiciousOperation
 from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist
 from django.http import Http404, HttpResponseBadRequest
 from django.urls.base import set_urlconf
+from django.utils.cache import patch_vary_headers
 from django.utils.deprecation import MiddlewareMixin
+from django.utils.http import http_date
 from django.utils.translation import ugettext_lazy as _
 from django.shortcuts import render
 
@@ -188,34 +193,136 @@ def process_request(self, request):
         return None
 
 
-class FooterNoSessionMiddleware(SessionMiddleware):
+class ReadTheDocsSessionMiddleware(SessionMiddleware):
 
     """
-    Middleware that doesn't create a session on logged out doc views.
+    An overridden session middleware with a few changes.
 
-    This will reduce the size of our session table drastically.
+    - Doesn't create a session on logged out doc views.
+    - Uses a fallback cookie for browsers that reject SameSite=None cookies
+    - Modifies Django's behavior of treating SESSION_COOKIE_SAMESITE=None
+      to mean SameSite unset and instead makes it mean SameSite=None
+
+    This overrides and replaces Django's built-in SessionMiddleware completely.
+    Much of this middleware is duplicated from Django 2.2's SessionMiddleware.
+
+    In Django 3.1, Django will fully support SameSite=None cookies.
+    However, we may still need this middleware to support browsers that reject
+    SameSite=None cookies.
+    https://www.chromium.org/updates/same-site/incompatible-clients
     """
 
+    # Don't set a session cookie on these URLs unless the cookie is already set
     IGNORE_URLS = [
         '/api/v2/footer_html', '/sustainability/view', '/sustainability/click',
     ]
 
+    # This is a fallback cookie for the regular session cookie
+    # It is only used by clients that reject cookies with `SameSite=None`
+    cookie_name_fallback = f"{settings.SESSION_COOKIE_NAME}-samesiteunset"
+
     def process_request(self, request):
         for url in self.IGNORE_URLS:
             if (
                 request.path_info.startswith(url) and
-                settings.SESSION_COOKIE_NAME not in request.COOKIES
+                settings.SESSION_COOKIE_NAME not in request.COOKIES and
+                self.cookie_name_fallback not in request.COOKIES
             ):
                 # Hack request.session otherwise the Authentication middleware complains.
                 request.session = SessionBase()  # create an empty session
                 return
-        super().process_request(request)
+
+        if settings.SESSION_COOKIE_SAMESITE:
+            super().process_request(request)
+        else:
+            if settings.SESSION_COOKIE_NAME in request.COOKIES:
+                session_key = request.COOKIES.get(settings.SESSION_COOKIE_NAME)
+            else:
+                session_key = request.COOKIES.get(self.cookie_name_fallback)
+
+            request.session = self.SessionStore(session_key)
 
     def process_response(self, request, response):
         for url in self.IGNORE_URLS:
             if (
                 request.path_info.startswith(url) and
-                settings.SESSION_COOKIE_NAME not in request.COOKIES
+                settings.SESSION_COOKIE_NAME not in request.COOKIES and
+                self.cookie_name_fallback not in request.COOKIES
             ):
                 return response
-        return super().process_response(request, response)
+
+        # Most of the code below is taken directly from Django's SessionMiddleware.
+        # Some changes (marked with NOTE:) were added to support the fallback cookie.
+
+        try:
+            accessed = request.session.accessed
+            modified = request.session.modified
+            empty = request.session.is_empty()
+        except AttributeError:
+            pass
+        else:
+            # First check if we need to delete this cookie.
+            # The session should be deleted only if the session is entirely empty
+            # NOTE: This was changed to support both cookies
+            if (
+                settings.SESSION_COOKIE_NAME in request.COOKIES or
+                self.cookie_name_fallback in request.COOKIES
+            ) and empty:
+                for cookie_name in (settings.SESSION_COOKIE_NAME, self.cookie_name_fallback):
+                    if cookie_name in request.COOKIES:
+                        response.delete_cookie(
+                            cookie_name,
+                            path=settings.SESSION_COOKIE_PATH,
+                            domain=settings.SESSION_COOKIE_DOMAIN,
+                        )
+            else:
+                if accessed:
+                    patch_vary_headers(response, ('Cookie',))
+                if (modified or settings.SESSION_SAVE_EVERY_REQUEST) and not empty:
+                    if request.session.get_expire_at_browser_close():
+                        max_age = None
+                        expires = None
+                    else:
+                        max_age = request.session.get_expiry_age()
+                        expires_time = time.time() + max_age
+                        expires = http_date(expires_time)
+                    # Save the session data and refresh the client cookie.
+                    # Skip session save for 500 responses, refs #3881.
+                    if response.status_code != 500:
+                        try:
+                            request.session.save()
+                        except UpdateError:
+                            raise SuspiciousOperation(
+                                "The request's session was deleted before the "
+                                "request completed. The user may have logged "
+                                "out in a concurrent request, for example."
+                            )
+
+                        response.set_cookie(
+                            settings.SESSION_COOKIE_NAME,
+                            request.session.session_key, max_age=max_age,
+                            expires=expires, domain=settings.SESSION_COOKIE_DOMAIN,
+                            path=settings.SESSION_COOKIE_PATH,
+                            secure=settings.SESSION_COOKIE_SECURE or None,
+                            httponly=settings.SESSION_COOKIE_HTTPONLY or None,
+                            samesite=settings.SESSION_COOKIE_SAMESITE,
+                        )
+
+                        # NOTE: This was added to support the fallback cookie
+                        if not settings.SESSION_COOKIE_SAMESITE:
+                            # Forcibly set the session cookie to SameSite=None
+                            # This isn't supported in Django<3.1
+                            # https://github.com/django/django/pull/11894
+                            response.cookies[settings.SESSION_COOKIE_NAME]["samesite"] = "None"
+
+                            # Set the fallback cookie in case the above cookie is rejected
+                            response.set_cookie(
+                                self.cookie_name_fallback,
+                                request.session.session_key, max_age=max_age,
+                                expires=expires, domain=settings.SESSION_COOKIE_DOMAIN,
+                                path=settings.SESSION_COOKIE_PATH,
+                                secure=settings.SESSION_COOKIE_SECURE or None,
+                                httponly=settings.SESSION_COOKIE_HTTPONLY or None,
+                                samesite=settings.SESSION_COOKIE_SAMESITE,
+                            )
+        return response
diff --git a/readthedocs/settings/base.py b/readthedocs/settings/base.py
--- a/readthedocs/settings/base.py
+++ b/readthedocs/settings/base.py
@@ -66,6 +66,8 @@ class CommunityBaseSettings(Settings):
     SESSION_COOKIE_HTTPONLY = True
     SESSION_COOKIE_AGE = 30 * 24 * 60 * 60  # 30 days
     SESSION_SAVE_EVERY_REQUEST = True
+    # This cookie is used in cross-origin API requests from *.readthedocs.io to readthedocs.org
+    SESSION_COOKIE_SAMESITE = None
 
     # CSRF
     CSRF_COOKIE_HTTPONLY = True
@@ -180,7 +182,7 @@ def USE_PROMOS(self):  # noqa
         return 'readthedocsext.donate' in self.INSTALLED_APPS
 
     MIDDLEWARE = (
-        'readthedocs.core.middleware.FooterNoSessionMiddleware',
+        'readthedocs.core.middleware.ReadTheDocsSessionMiddleware',
         'django.middleware.locale.LocaleMiddleware',
         'django.middleware.common.CommonMiddleware',
         'django.middleware.security.SecurityMiddleware',


Test patch:
diff --git a/readthedocs/rtd_tests/tests/test_footer.py b/readthedocs/rtd_tests/tests/test_footer.py
--- a/readthedocs/rtd_tests/tests/test_footer.py
+++ b/readthedocs/rtd_tests/tests/test_footer.py
@@ -12,7 +12,7 @@
 )
 from readthedocs.builds.constants import BRANCH, LATEST, TAG
 from readthedocs.builds.models import Version
-from readthedocs.core.middleware import FooterNoSessionMiddleware
+from readthedocs.core.middleware import ReadTheDocsSessionMiddleware
 from readthedocs.projects.constants import PUBLIC
 from readthedocs.projects.models import Project
 
@@ -104,7 +104,7 @@ def test_epub_not_mentioned_in_footer_when_doesnt_exists(self):
         self.assertNotIn('epub', response.data['html'])
 
     def test_no_session_logged_out(self):
-        mid = FooterNoSessionMiddleware()
+        mid = ReadTheDocsSessionMiddleware()
 
         # Null session here
         request = self.factory.get('/api/v2/footer_html/')
diff --git a/readthedocs/rtd_tests/tests/test_middleware.py b/readthedocs/rtd_tests/tests/test_middleware.py
--- a/readthedocs/rtd_tests/tests/test_middleware.py
+++ b/readthedocs/rtd_tests/tests/test_middleware.py
@@ -3,13 +3,14 @@
 from django.contrib.auth.middleware import AuthenticationMiddleware
 from django.contrib.sessions.middleware import SessionMiddleware
 from django.http import Http404
+from django.http import HttpResponse
 from django.test import TestCase
 from django.test.client import RequestFactory
 from django.test.utils import override_settings
 from django.urls.base import get_urlconf, set_urlconf
 from django_dynamic_fixture import get
 
-from readthedocs.core.middleware import SubdomainMiddleware
+from readthedocs.core.middleware import SubdomainMiddleware, ReadTheDocsSessionMiddleware
 from readthedocs.projects.models import Domain, Project, ProjectRelationship
 from readthedocs.rtd_tests.utils import create_user
 
@@ -244,3 +245,32 @@ def test_apiv2_endpoint_not_allowed(self):
         )
         resp = self.middleware.process_response(request, {})
         self.assertNotIn('Access-Control-Allow-Origin', resp)
+
+
+class TestSessionMiddleware(TestCase):
+
+    def setUp(self):
+        self.factory = RequestFactory()
+        self.middleware = ReadTheDocsSessionMiddleware()
+
+        self.user = create_user(username='owner', password='test')
+
+    def test_fallback_cookie(self):
+        request = self.factory.get('/')
+        response = HttpResponse()
+        self.middleware.process_request(request)
+        request.session['test'] = 'value'
+        response = self.middleware.process_response(request, response)
+
+        self.assertTrue(settings.SESSION_COOKIE_NAME in response.cookies)
+        self.assertTrue(self.middleware.cookie_name_fallback in response.cookies)
+
+    def test_main_cookie_samesite_none(self):
+        request = self.factory.get('/')
+        response = HttpResponse()
+        self.middleware.process_request(request)
+        request.session['test'] = 'value'
+        response = self.middleware.process_response(request, response)
+
+        self.assertEqual(response.cookies[settings.SESSION_COOKIE_NAME]['samesite'], 'None')
+        self.assertEqual(response.cookies[self.middleware.cookie_name_fallback]['samesite'], '')