Django – Consecutive Request Override Session Attribute

Question:

I want to generate an invoice for each order, and in some cases, there are two generated invoices for one order.

For those cases, the first invoice request fails and I receive a 400 error with "invalid signature" message (as I defined in my view logic), while the second remaining success.

views.py:

from django.core import signing
from django.contrib import messages

class OrderView(MyMultiFormsView):
    forms = {‘create’: OrderForm}

    … # view logic for get method: display a list of orders

    def post(self, request, *args, **kwargs):
        form = self.forms[‘create’](request.POST, request.FILES, prefix=‘create’)
        if form.is_valid():
            order = form.save()
            messages.success(request, signing.dump(order.id))
            if order.is_paid:
                messages.info(request, signing.dump(order.id))
            return redirect(request.get_full_path())
        … # not valid: render form and show errors

class ExportView(View):
    http_method_names = [‘get’]
    actions = {
        'invoice': {
            'url_token': 'print-invoice', 
            'session_prop': '_invoice_token',
        },
        'receipt': {
            'url_token': 'print-receipt',
            'session_prop': '_receipt_token',
        },
    }
    def get(self, request, *args, **kwargs):
        if kwargs['action'] not in self.actions:
            return render(request, '400.html', {'msg': 'undefined action'}, status=400)
        action = self.actions[kwargs['action']]
        if kwargs['token'] == action['url_token']:
            try:
                print(request.session.get(action['session_prop']))
                token = request.session.pop(action['session_prop'])
                sign = signing.load(token)
            except:
                return render(request, '400.html', {'msg': 'invalid signature', status=400)
            return getattr(self, kwargs['action'])(sign)
        else:
            request.session[action['session_prop']] = kwargs['token']
            print(request.session.__dict__)
            redirect_url = request.path.replace(kwargs['token'], action['url_token'])
            return redirect(redirect_url)  
     def invoice(self, sign):
         inv = Invoice(sign)
         # inv.as_file() returns a pdf file with io.BytesIO object type
         return FileResponse(inv.as_file(), filename=inv.filename)
     def receipt(self, sign):
         rcp = Receipt(sign)
         return FileResponse(rcp.as_file(), filename=rcp.filename)

Template orders.html:

<!-- template logic to render orders -->
<script>
  ...
  {% if messages %}
    {% for msg in messages %}
      {% if msg.level == DEFAULT_MESSAGE_LEVELS.SUCCESS %}
        window.open("{% url 'sale:export' 'invoice' msg %}", "_blank");
      {% elif msg.level == DEFAULT_MESSAGE_LEVELS.INFO %}
        window.open("{% url 'sale:export' 'receipt' msg %}", "_blank");
      {% endif %]
    {% endfor %}
  {% endif %}
</script>

urls.py:

app_name = 'sale'
urls = [
    ... 
    path('orders/', views.OrderView.as_view(), name='orders'),
    path('orders/export/<action>/<token>/', views.ExportView.as_view(), name='export'),
]

I am using Django’s development server, and print(request.session.__dict__) shows:

{'_SessionBase__session_key': 'ljm62w2z50jrdlolemrthk6bu9btjxry', 'accessed': True, 'modified': True, 'serializer': <class 'django.core.signing.JSONSerializer'>, 'model': <class 'django.contrib.sessions.models.Session'>, '_session_cache': {'_auth_user_id': '1', '_auth_user_backend': 'django.contrib.auth.backends.ModelBackend', '_auth_user_hash': 'c0d671f349e6efdadfe3afae7e1e21eb5e82c16e2a363d6706984a6a1d755c55', '_invoice_token': 'NTgxMzE:1oA3vp:iyEhWL3HV6Ai1xOgogB5yDQ6fMtEUE5eKgr4aGLQonU'}}
{'_SessionBase__session_key': 'ljm62w2z50jrdlolemrthk6bu9btjxry', 'accessed': True, 'modified': True, 'serializer': <class 'django.core.signing.JSONSerializer'>, 'model': <class 'django.contrib.sessions.models.Session'>, '_session_cache': {'_auth_user_id': '1', '_auth_user_backend': 'django.contrib.auth.backends.ModelBackend', '_auth_user_hash': 'c0d671f349e6efdadfe3afae7e1e21eb5e82c16e2a363d6706984a6a1d755c55', '_receipt_token': 'NTgxMzE:1oA3vp:iyEhWL3HV6Ai1xOgogB5yDQ6fMtEUE5eKgr4aGLQonU'}}
[09/Jul/2022 14:27:09] "GET /sale/orders/export/invoice/NTgxMzE:1oA3vp:iyEhWL3HV6Ai1xOgogB5yDQ6fMtEUE5eKgr4aGLQonU/ HTTP/1.1" 302 0
[09/Jul/2022 14:27:09] "GET /sale/orders/export/receipt/NTgxMzE:1oA3vp:iyEhWL3HV6Ai1xOgogB5yDQ6fMtEUE5eKgr4aGLQonU/ HTTP/1.1" 302 0
None
NTg1MTA:1oDhuT:56B_0sCC5svW0kReAxTF_Y3CHzehkbZ4B1NRDk7M4QE
Bad Request: /sale/orders/export/invoice/print-invoice/
[09/Jul/2022 14:27:09] "GET /sale/orders/export/invoice/print-invoice/ HTTP/1.1" 400 1307
[09/Jul/2022 14:27:09] "GET /sale/orders/export/receipt/print-receipt/ HTTP/1.1" 200 18804

Looks like session attribute ‘_invoice_token’ somehow gets overridden.

Why the session attribute gets overridden? And how can I work around this?

PS:
Remove the whole redirect part and use the signature token directly in the url gives me desired results, but this would be the last option, as I want to keep the token as secret as possible.

Asked By: Ezon Zhao

||

Answers:

The issue is here:

messages.success(request, signing.dump(order.id))
if order.is_paid:
    messages.info(request, signing.dump(order.id))

So when is_paid is true, two messages are generated, one for success, and one for info and both of them are executed. Try:

if order.is_paid:
    messages.info(request, signing.dump(order.id))
else:
    messages.success(request, signing.dump(order.id))
Answered By: Bhavya Peshavaria

Django stores session as JSON and does not handle race conditions from concurrent requests.
#10760 (Some session data gets lost between multiple concurrent request) was closed (invalid).

You can override:

  • __setitem__ to use an atomic transaction, and
  • _get_session_from_db to do a SELECT ... FOR UPDATE.
# mysite/session.py

import logging

from django.contrib.sessions.backends import db
from django.core.exceptions import SuspiciousOperation
from django.db import transaction
from django.utils import timezone


class SessionStore(db.SessionStore):

    def __setitem__(self, key, value):
        # self._session[key] = value    # -
        # self.modified = True          # -
        with transaction.atomic():      # +
            self._session[key] = value  # +
            self.save()                 # +

    def _get_session_from_db(self):
        queryset = self.model.objects                     # +
        if transaction.get_connection().in_atomic_block:  # +
            queryset = queryset.select_for_update()       # +
        try:
            # return self.model.objects.get(  # -
            return queryset.get(              # +
                session_key=self.session_key, expire_date__gt=timezone.now()
            )
        except (self.model.DoesNotExist, SuspiciousOperation) as e:
            if isinstance(e, SuspiciousOperation):
                logger = logging.getLogger("django.security.%s" % e.__class__.__name__)
                logger.warning(str(e))
            self._session_key = None

Use your session engine:

# mysite/settings.py

...

SESSION_ENGINE = 'mysite.session'
Answered By: aaron