litestar-org/litestar

Bug: Some session data got lost when trying to set session with big payload

wallseat opened this issue · 6 comments

Description

I'm trying to store acess and refresh tokens in request.session with some other user data, and got an error. In middleware AuthRequiredMiddleware i have no user data, but it should be. If I remove two long fields with tokens, it works normaly.

Additionaly, i got a second issue - SerializationException when it trying to decode session with big payload, but can't reproduce it

URL to code causing the issue

No response

MCVE

import secrets
import time
from typing import Any

from litestar import Litestar, Request, Router, get
from litestar.config.cors import CORSConfig
from litestar.middleware.base import DefineMiddleware, MiddlewareProtocol
from litestar.middleware.session.client_side import CookieBackendConfig
from litestar.openapi import OpenAPIConfig
from litestar.openapi.plugins import SwaggerRenderPlugin
from litestar.response import Redirect
from litestar.response.redirect import ASGIRedirectResponse
from litestar.types import ASGIApp, Receive, Scope, Send
from pydantic import BaseModel, Field

USER_SESSION_KEY = "user"
AUTH_REDIRECT_KEY = "auth_redirect_from"


class UserSchema(BaseModel):
    username: str
    fullname: str | None = Field(default=None)
    firstname: str | None = Field(default=None)
    lastname: str | None = Field(default=None)
    email: str | None = Field(default=None)
    position: str | None = Field(default=None)


class UserSessionSchema(BaseModel):
    user_info: UserSchema | None = Field(default=None)
    access_token: str | None = Field(default=None)
    refresh_token: str | None = Field(default=None)
    expire_at: int | None = Field(default=None)


class AuthRequiredMiddleware(MiddlewareProtocol):
    def __init__(
        self,
        app: ASGIApp,
        api_path: str,
        auth_controller_path: str,
        login_endpoint_path: str = "/login",
        **_: Any,
    ) -> None:
        self.app = app

        self.api_path = api_path
        self.auth_controller_path = auth_controller_path
        self.login_endpoint_path = login_endpoint_path
        self.login_url = self.api_path + self.auth_controller_path + self.login_endpoint_path

    async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
        request = Request(scope)
        print("In middleware session:", request.session)

        user_session = UserSessionSchema.model_validate(request.session.get(USER_SESSION_KEY, {}))

        def _redirect_login():
            response = ASGIRedirectResponse(path=self.login_url)

            request.session[USER_SESSION_KEY] = user_session.model_dump()
            request.session[AUTH_REDIRECT_KEY] = str(request.url)

            return response(scope, receive, send)

        if self.auth_controller_path not in request.url.path:
            if not user_session.user_info:
                return await _redirect_login()

            if AUTH_REDIRECT_KEY in request.session:
                del request.session[AUTH_REDIRECT_KEY]

        await self.app(scope, receive, send)


@get("/protected_resource")
async def test(request: Request) -> str:
    user_session = UserSessionSchema.model_validate(request.session[USER_SESSION_KEY])

    assert user_session.user_info

    return f"Hello, {user_session.user_info.username}!"


@get("/auth/login")
async def login(request: Request) -> Redirect:
    request.session[USER_SESSION_KEY] = UserSessionSchema(
        user_info=UserSchema(
            username="some_username",
            fullname="Иванов Ivan Jhon",
            firstname="Ivan",
            lastname="Иванов",
            email="some-email-ivan@notmail.com",
            position="Sernior999lvl",
        ),
        expire_at=int(time.time()) + 900,
        # XXX: Uncoment to reproduce
        # access_token="a" * 2078,
        # refresh_token="b" * 1074,
    )

    print("In login session:", request.session)

    if AUTH_REDIRECT_KEY in request.session:
        return Redirect(request.session.pop(AUTH_REDIRECT_KEY))

    return Redirect("/")


router = Router("/", route_handlers=[test, login])

app = Litestar(
    route_handlers=[router],
    openapi_config=OpenAPIConfig(
        title="Insourcing API",
        version="0.1.0",
        render_plugins=[SwaggerRenderPlugin()],
        path="/api/v1/docs/",
    ),
    cors_config=CORSConfig(
        allow_origins=["http://localhost:8000"],
        allow_credentials=True,
    ),
    middleware=[
        CookieBackendConfig(secret=secrets.token_urlsafe(24).encode()).middleware,
        DefineMiddleware(
            middleware=AuthRequiredMiddleware,
            api_path="",
            auth_controller_path="/auth",
        ),
    ],
    debug=True,
)

Steps to reproduce

1. Install litestar + pydantic2 + uvicorn + uvloop
2. Do not uncomment lines and run with uvicorn FILE_NAME:app
3. open localhost:8000/protected_resource. it should work without errors and you'll see - Hello, some_username!
4. Uncomment lines and run again. You will fall into redirect cycle

Screenshots

No response

Logs

No response

Litestar Version

2.8.2

Platform

WSL 2.1.5.0 + Ubuntu 22.04

  • Linux
  • Mac
  • Windows
  • Other (Please specify in the description above)

Note

While we are open for sponsoring on GitHub Sponsors and
OpenCollective, we also utilize Polar.sh to engage in pledge-based sponsorship.

Check out all issues funded or available for funding on our Polar.sh dashboard

  • If you would like to see an issue prioritized, make a pledge towards it!
  • We receive the pledge once the issue is completed & verified
  • This, along with engagement in the community, helps us know which features are a priority to our users.
Fund with Polar

Thanks @wallseat - good find!

Once cookies get above a certain size, we chunk them and store across multiple cookies. When this happens, cookies get stored with an enumeration, e.g., session-0, session-1 etc.

In this case, the session is persisted in the headers for the first time when the request to /protected_route is redirected to the auth route. At this time, the session cookie is not greater than the chunk size and so it gets stored in the cookie under the name session.

After authentication, when the size of the session is much larger due to the presence of the tokens, the serialized session is greater than the chunk size, so the session cookie gets chunked and stored under session-0, session-1.

There is an issue with the algorithm that detects cookies that should be cleared under the condition where the cookie grows in size greater than a single chunk, and that is what we're hitting here. The original session cookie was not being cleared when it is superseded by a cookie called session-0.

This issue has been closed in #3446. The change will be included in the upcoming patch release.

A fix for this issue has been released in v2.9.0