CVE-2024-35220: Insufficient Information (NVD-CWE-noinfo)
value: @fastify/session is a session plugin for fastify. Requires the @fastify/cookie plugin. When restoring the cookie from the session store, the `expires` field is overriden if the `maxAge` field was set.
This means a cookie is never correctly detected as expired and thus expired sessions are not destroyed. This vulnerability has been patched 10.8.0.
CWE description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
Published: 2024-05-21T21:15Z
Repository: https://github.com/fastify/session
Language: JavaScript
Files changed: lib/cookie.js, lib/fastifySession.js, test/expiration.test.js
Commit date: 2024-05-21 18:24:15 +0200
Commit message: Merge pull request from GHSA-pj27-2xvp-4qxg

* fix: Use original expiration date when evaluating restored session.

* fix: Also serialize new field.
Diff: diff --git a/lib/cookie.js b/lib/cookie.js
index 3de8c73..ebfa1c5 100644
--- a/lib/cookie.js
+++ b/lib/cookie.js
@@ -9,10 +9,14 @@ module.exports = class Cookie {
     this.domain = cookie.domain || null
     this.httpOnly = cookie.httpOnly !== undefined ? cookie.httpOnly : true
     this.partitioned = cookie.partitioned
     this._expires = null
 
+    if(cookie.expires) {
+      this.originalExpires = new Date(cookie.expires)
+    }
+        
     if (originalMaxAge) {
       this.maxAge = originalMaxAge
     } else if (cookie.expires) {
       this.expires = new Date(cookie.expires)
       this.originalMaxAge = null
@@ -54,10 +58,11 @@ module.exports = class Cookie {
 
   toJSON () {
     return {
       expires: this._expires,
       originalMaxAge: this.originalMaxAge,
+      originalExpires: this.originalExpires,
       sameSite: this.sameSite,
       secure: this.secure,
       path: this.path,
       httpOnly: this.httpOnly,
       domain: this.domain,
diff --git a/lib/fastifySession.js b/lib/fastifySession.js
index 9e43429..bc8fe20 100644
--- a/lib/fastifySession.js
+++ b/lib/fastifySession.js
@@ -90,11 +90,13 @@ function fastifySession (fastify, options, next) {
         cookieSigner,
         session,
         decryptedSessionId
       )
 
-      if (restoredSession.cookie.expires && restoredSession.cookie.expires.getTime() <= Date.now()) {
+      const expiration = restoredSession.cookie.originalExpires || restoredSession.cookie.expires
+
+      if (expiration && expiration.getTime() <= Date.now()) {
         restoredSession.destroy(err => {
           if (err) {
             done(err)
             return
           }
diff --git a/test/expiration.test.js b/test/expiration.test.js
new file mode 100644
index 0000000..e7eb321
--- /dev/null
+++ b/test/expiration.test.js
@@ -0,0 +1,63 @@
+"use strict";
+
+const test = require("tap").test;
+const { buildFastify, DEFAULT_SECRET } = require("./util");
+const { setTimeout } = require("node:timers/promises");
+
+test("sessions should be deleted if expired", async (t) => {
+  t.plan(5);
+
+  const sessions = {};
+  const options = {
+    secret: DEFAULT_SECRET,
+    store: {
+      get(id, cb) {
+        t.pass("session was restored");
+        cb(null, sessions[id]);
+      },
+      set(id, session, cb) {
+        sessions[id] = session;
+        cb();
+      },
+      destroy(id, cb) {
+        t.pass("expired session is destroyed");
+        cb();
+      },
+    },
+    cookie: { maxAge: 1000, secure: false },
+  };
+
+  const fastify = await buildFastify((request, reply) => {
+    reply.send(200);
+  }, options);
+  t.teardown(() => {
+    fastify.close();
+  });
+
+  let response;
+  response = await fastify.inject({
+    url: "/",
+  });
+
+  const initialSession = response.headers["set-cookie"]
+    .split(" ")[0]
+    .replace(";", "");
+  t.ok(initialSession.startsWith("sessionId="));
+
+  // Wait for the cookie to expire
+  await setTimeout(2000);
+
+  response = await fastify.inject({
+    url: "/",
+    headers: {
+      Cookie: initialSession,
+    },
+  });
+
+  const endingSession = response.headers["set-cookie"]
+    .split(" ")[0]
+    .replace(";", "");
+  t.ok(endingSession.startsWith("sessionId="));
+
+  t.not(initialSession, endingSession);
+});

Vulnerable code:       if (restoredSession.cookie.expires && restoredSession.cookie.expires.getTime() <= Date.now()) {
Fixed code:     if(cookie.expires) {
      this.originalExpires = new Date(cookie.expires)
    }
        
      originalExpires: this.originalExpires,
      const expiration = restoredSession.cookie.originalExpires || restoredSession.cookie.expires

      if (expiration && expiration.getTime() <= Date.now()) {
"use strict";

const test = require("tap").test;
const { buildFastify, DEFAULT_SECRET } = require("./util");
const { setTimeout } = require("node:timers/promises");

test("sessions should be deleted if expired", async (t) => {
  t.plan(5);

  const sessions = {};
  const options = {
    secret: DEFAULT_SECRET,
    store: {
      get(id, cb) {
        t.pass("session was restored");
        cb(null, sessions[id]);
      },
      set(id, session, cb) {
        sessions[id] = session;
        cb();
      },
      destroy(id, cb) {
        t.pass("expired session is destroyed");
        cb();
      },
    },
    cookie: { maxAge: 1000, secure: false },
  };

  const fastify = await buildFastify((request, reply) => {
    reply.send(200);
  }, options);
  t.teardown(() => {
    fastify.close();
  });

  let response;
  response = await fastify.inject({
    url: "/",
  });

  const initialSession = response.headers["set-cookie"]
    .split(" ")[0]
    .replace(";", "");
  t.ok(initialSession.startsWith("sessionId="));

  // Wait for the cookie to expire
  await setTimeout(2000);

  response = await fastify.inject({
    url: "/",
    headers: {
      Cookie: initialSession,
    },
  });

  const endingSession = response.headers["set-cookie"]
    .split(" ")[0]
    .replace(";", "");
  t.ok(endingSession.startsWith("sessionId="));

  t.not(initialSession, endingSession);
});