miguelgrinberg/microdot

Session

Closed this issue · 0 comments

Preamble:

First, I wanted to thank you for the work you have done with this framework, especially since it is as pleasant to use as its big brother Flask. Thank you!

Environment:

OS: Win 11
CPU: Intel i9-13900K
RAM: 32 GB
Python: 3.11.9
Virtual Environment: UV

Context:

I am working on a POC for an e-commerce site and have encountered several issues with the use of sessions. The first issue encountered is when I secured the header:

The application uses sub-applications, for example:

File: main.py

def create_app():
    app = Microdot()

    app.mount(auth, url_prefix='/auth')

    return app

app = create_app()

Session(app, secret_key='top-secret')
Response.default_content_type = 'text/html'
Template.initialize(template_dir='templates/', enable_async=True)

I then modified the header response to add a bit of security:

File: main.py

@app.after_request
async def secure_headers(req, resp):
    resp.headers['Cache-Control']                       = "no-store, no-cache"
    resp.headers['Content-Security-Policy']             = "base-uri 'self'; connect-src 'self'; font-src 'self'; form-action 'self'; frame-ancestors 'none'; frame-src 'none'; img-src 'self'; media-src 'self'; object-src 'self'; script-src 'self'; worker-src 'self'"
    resp.headers['Content-Security-Policy-Report-Only'] = "default-src 'self'; script-src 'self'; connect-src 'self'; img-src 'self'; style-src 'self'; font-src 'self'; object-src 'none';"
    resp.headers['Cross-Origin-Embedder-Policy']        = "require-same-origin"
    resp.headers['Cross-Origin-Opener-Policy']          = 'same-origin'
    resp.headers['Cross-Origin-Resource-Policy']        = 'same-site'
    resp.headers['Feature-Policy']                      = "geolocation 'none'; midi 'none'; sync-xhr 'none'; microphone 'none'; camera 'none'; usb 'none'; encrypted-media 'none';"
    resp.headers['Permissions-Policy']                  = "accelerometer=(), ambient-light-sensor=(), autoplay=(), camera=(), encrypted-media=(), fullscreen=(), geolocation=(), gyroscope=(), interest-cohort=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(), speaker=(), sync-xhr=(), usb=(), vr=()"
    resp.headers['Referrer-Policy']                     = "strict-origin"
    resp.headers['Set-Cookie']                          = "; HttpOnly; Secure; SameSite=Lax"
    resp.headers['Strict-Transport-Security']           = "max-age=31536000; includeSubDomains; preload"
    resp.headers['X-Content-Type-Options']              = "nosniff"
    resp.headers['X-DNS-Prefetch-Control']              = 'off'
    resp.headers['X-Download-Options']                  = 'noopen'
    resp.headers['X-Frame-Options']                     = "DENY"
    resp.headers['X-Permitted-Cross-Domain-Policies']   = 'none'
    resp.headers['X-Xss-Protection']                    = "1; mode=block"

    resp.headers.pop('X-Powered-By', None)

Now if I try to add values to the session in a post-login like this:

File: /auth/router.py

auth = Microdot()

@auth.post('/login')
@with_session
async def login(req, session):

    ...
    
    # Add datas to session
    session['user_id'] = str(datas['id'])
    session['email']   = email
    session['cart']    = datas['cart']
    session.save()

    return redirect('/', 303)

I get the following error, which is due to the modification of the resp.headers['Set-Cookie'] = "; HttpOnly; Secure; SameSite=Lax"

Traceback (most recent call last):

  File "C:\Users\Administrator\Documents\POC\.venv\Lib\site-packages\microdot\microdot.py", line 1384, in dispatch_request
    res = await invoke_handler(
          ^^^^^^^^^^^^^^^^^^^^^
          
  File "C:\Users\Administrator\Documents\POC\.venv\Lib\site-packages\microdot\microdot.py", line 25, in invoke_handler
    ret = await asyncio.get_running_loop().run_in_executor(
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
          
  File "C:\Program Files\Python311\Lib\concurrent\futures\thread.py", line 58, in run
    result = self.fn(*self.args, **self.kwargs)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
             
  File "C:\Users\Administrator\Documents\POC\.venv\Lib\site-packages\microdot\session.py", line 97, in _update_session
    response.set_cookie('session', encoded_session, http_only=True)
    
  File "C:\Users\Administrator\Documents\POC\.venv\Lib\site-packages\microdot\microdot.py", line 610, in set_cookie
    self.headers['Set-Cookie'].append(http_cookie)
    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    
AttributeError: 'str' object has no attribute 'append'

I remind you that if I comment out line resp.headers['Set-Cookie'] = "; HttpOnly; Secure; SameSite=Lax", it works, but then I cannot secure my headers properly.


My second session problem is an issue of propagation. Here, the line resp.headers['Set-Cookie'] = "; HttpOnly; Secure; SameSite=Lax" is commented out so that sessions can work.

When I am in my authentication and the user posts their data, the session is created correctly since I get the following response:

File: /auth/router.py

auth = Microdot()

@auth.post('/login')
@with_session
async def login(req, session):

    ...
    
    # Add datas to session
    session['user_id'] = str(datas['id'])
    session['email']   = email
    session['cart']    = datas['cart']
    session.save()
    
    print('Login page session (on sub-app auth):', session)

    return redirect('/', 303)

The response is :

Login page session (on sub-app auth): {'user_id': '3c013fdb-111f-4a77-806a-dfbffc0073ad', 'email': 'user-test@test.fr', 'cart': '{"items":[]}'}
POST /auth/login 303

But after the redirect i come to the index page at '/'

File: main.py

def create_app():
    app = Microdot()

    app.mount(auth, url_prefix='/auth')

    return app

app = create_app()

Session(app, secret_key='top-secret')
Response.default_content_type = 'text/html'
Template.initialize(template_dir='templates/', enable_async=True)

@app.get('/')
@with_session
async def home(req, session):

    # Get db connection
    async with Database() as con:
        async with con.transaction():
            datas = await con.fetch('SELECT "title", "link", "infos" FROM "ProductLines" ORDER BY "link";')

    print('Home page session : (on app)', session)

    return await Template('/home.jinja').render_async(
        title='Bienvenue', product_lines=datas, base_url=getenv('BASE_URL'), session=session
    )

I got an empty session response :

Home page session : (on app) {}
GET / 200

I conclude that the use of a session in a sub-app does not propagate correctly to the main application, which is quite inconvenient. I hope my explanations are clear enough.

Best regards.