Repository: encode/starlette

Issue:
Session middleware is highly insecure
The session middleware does not have any timestamp component to it, which means that if a session happens to be somehow leaked, it can be reused any time in the future.

Can we have a timestamp component added to the session, so that the session middleware can reject such sessions after a reasonable period of time (e.g. 24 hours)?


Discussion:
This is actually a relatively simple fix, as `itsdangerous` also provides a `TimestampSigner` class. This works the exact same way as `Signer`, while additionally recording the time of signing. When unsigning you can provide a `max_age` argument (in seconds), which will raise `itsdangerous.exc.SignatureExpired` if the signature has expired.

With this in mind, my questions would be:
1. Is 24 hours the value we want to use for `max_age` (seems reasonable enough to me), or if not what value would be appropriate?
2. In the case that the signature has expired, what do we want to do? Do we want to simply clear the scope's session (ie. `scope["session"] = {}`) like we are if the session cookie is not present in the request and continue execution, or raise an exception?

I can make a pull request once I have answers for the above questions.
Django uses 2 weeks by default. If we make it configurable then that sounds okay. We should treat the session as empty if it’s expiried - it’s not an exception case.

Resolving patch:
diff --git a/starlette/middleware/sessions.py b/starlette/middleware/sessions.py
--- a/starlette/middleware/sessions.py
+++ b/starlette/middleware/sessions.py
@@ -3,6 +3,7 @@
 from base64 import b64decode, b64encode
 
 import itsdangerous
+from itsdangerous.exc import BadTimeSignature, SignatureExpired
 
 from starlette.datastructures import MutableHeaders
 from starlette.requests import Request
@@ -11,19 +12,27 @@
 
 class SessionMiddleware:
     def __init__(
-        self, app: ASGIApp, secret_key: str, session_cookie: str = "session"
+        self,
+        app: ASGIApp,
+        secret_key: str,
+        session_cookie: str = "session",
+        max_age: int = 14 * 24 * 60 * 60,  # 14 days, in seconds
     ) -> None:
         self.app = app
-        self.signer = itsdangerous.Signer(secret_key)
+        self.signer = itsdangerous.TimestampSigner(secret_key)
         self.session_cookie = session_cookie
+        self.max_age = max_age
 
     def __call__(self, scope: Scope) -> ASGIInstance:
         if scope["type"] in ("http", "websocket"):
             request = Request(scope)
             if self.session_cookie in request.cookies:
                 data = request.cookies[self.session_cookie].encode("utf-8")
-                data = self.signer.unsign(data)
-                scope["session"] = json.loads(b64decode(data))
+                try:
+                    data = self.signer.unsign(data, max_age=self.max_age)
+                    scope["session"] = json.loads(b64decode(data))
+                except (BadTimeSignature, SignatureExpired):
+                    scope["session"] = {}
             else:
                 scope["session"] = {}
             return functools.partial(self.asgi, scope=scope)


Test patch:
diff --git a/tests/middleware/test_session.py b/tests/middleware/test_session.py
--- a/tests/middleware/test_session.py
+++ b/tests/middleware/test_session.py
@@ -3,29 +3,33 @@
 from starlette.responses import JSONResponse
 from starlette.testclient import TestClient
 
-app = Starlette()
-app.add_middleware(SessionMiddleware, secret_key="example")
 
-
-@app.route("/view_session")
 def view_session(request):
     return JSONResponse({"session": request.session})
 
 
-@app.route("/update_session", methods=["POST"])
 async def update_session(request):
     data = await request.json()
     request.session.update(data)
     return JSONResponse({"session": request.session})
 
 
-@app.route("/clear_session", methods=["POST"])
 async def clear_session(request):
     request.session.clear()
     return JSONResponse({"session": request.session})
 
 
+def create_app():
+    app = Starlette()
+    app.add_route("/view_session", view_session)
+    app.add_route("/update_session", update_session, methods=["POST"])
+    app.add_route("/clear_session", clear_session, methods=["POST"])
+    return app
+
+
 def test_session():
+    app = create_app()
+    app.add_middleware(SessionMiddleware, secret_key="example")
     client = TestClient(app)
 
     response = client.get("/view_session")
@@ -42,3 +46,15 @@ def test_session():
 
     response = client.get("/view_session")
     assert response.json() == {"session": {}}
+
+
+def test_session_expires():
+    app = create_app()
+    app.add_middleware(SessionMiddleware, secret_key="example", max_age=-1)
+    client = TestClient(app)
+
+    response = client.post("/update_session", json={"some": "data"})
+    assert response.json() == {"session": {"some": "data"}}
+
+    response = client.get("/view_session")
+    assert response.json() == {"session": {}}