Skip to content

Commit 95798cd

Browse files
Id collision mitigation (#181)
This patch introduces a new method, `create`, to the `SessionStore` trait to distinguish between creating a new session and updating an existing one. This distinction is crucial for mitigating the potential for session ID collisions. Although the probability of session ID collisions is statistically low, given that IDs are composed of securely-random `i128` values, such collisions pose a significant security risk. A store that does not differentiate between session creation and updates could inadvertently allow an existing session to be accessed, leading to potential session takeovers. To prevent this, stores must ensure the uniqueness of session IDs during creation. The new `create` method is designed to allow session store implementers to handle any conflicts and resolve them. This change is a breaking interface update. As a transitional measure, we have provided a default implementation of create that wraps the existing save method. However, this default is not immune to the original issue. Therefore, it is imperative that stores override the create method with an implementation that adheres to the required uniqueness semantics, thereby effectively mitigating the risk of session ID collisions.
1 parent bc0d0f9 commit 95798cd

File tree

5 files changed

+460
-91
lines changed

5 files changed

+460
-91
lines changed

memory-store/src/lib.rs

Lines changed: 88 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,22 @@ use tower_sessions_core::{
1919
/// MemoryStore::default();
2020
/// ```
2121
#[derive(Clone, Debug, Default)]
22-
pub struct MemoryStore(Arc<Mutex<HashMap<Id, (Record, OffsetDateTime)>>>);
22+
pub struct MemoryStore(Arc<Mutex<HashMap<Id, Record>>>);
2323

2424
#[async_trait]
2525
impl SessionStore for MemoryStore {
26+
async fn create(&self, record: &mut Record) -> session_store::Result<()> {
27+
let mut store_guard = self.0.lock().await;
28+
while store_guard.contains_key(&record.id) {
29+
// Session ID collision mitigation.
30+
record.id = Id::default();
31+
}
32+
store_guard.insert(record.id, record.clone());
33+
Ok(())
34+
}
35+
2636
async fn save(&self, record: &Record) -> session_store::Result<()> {
27-
self.0
28-
.lock()
29-
.await
30-
.insert(record.id, (record.clone(), record.expiry_date));
37+
self.0.lock().await.insert(record.id, record.clone());
3138
Ok(())
3239
}
3340

@@ -37,8 +44,7 @@ impl SessionStore for MemoryStore {
3744
.lock()
3845
.await
3946
.get(session_id)
40-
.filter(|(_, expiry_date)| is_active(*expiry_date))
41-
.map(|(session, _)| session)
47+
.filter(|Record { expiry_date, .. }| is_active(*expiry_date))
4248
.cloned())
4349
}
4450

@@ -51,3 +57,78 @@ impl SessionStore for MemoryStore {
5157
fn is_active(expiry_date: OffsetDateTime) -> bool {
5258
expiry_date > OffsetDateTime::now_utc()
5359
}
60+
61+
#[cfg(test)]
62+
mod tests {
63+
use time::Duration;
64+
65+
use super::*;
66+
67+
#[tokio::test]
68+
async fn test_create() {
69+
let store = MemoryStore::default();
70+
let mut record = Record {
71+
id: Default::default(),
72+
data: Default::default(),
73+
expiry_date: OffsetDateTime::now_utc() + Duration::minutes(30),
74+
};
75+
assert!(store.create(&mut record).await.is_ok());
76+
}
77+
78+
#[tokio::test]
79+
async fn test_save() {
80+
let store = MemoryStore::default();
81+
let record = Record {
82+
id: Default::default(),
83+
data: Default::default(),
84+
expiry_date: OffsetDateTime::now_utc() + Duration::minutes(30),
85+
};
86+
assert!(store.save(&record).await.is_ok());
87+
}
88+
89+
#[tokio::test]
90+
async fn test_load() {
91+
let store = MemoryStore::default();
92+
let mut record = Record {
93+
id: Default::default(),
94+
data: Default::default(),
95+
expiry_date: OffsetDateTime::now_utc() + Duration::minutes(30),
96+
};
97+
store.create(&mut record).await.unwrap();
98+
let loaded_record = store.load(&record.id).await.unwrap();
99+
assert_eq!(Some(record), loaded_record);
100+
}
101+
102+
#[tokio::test]
103+
async fn test_delete() {
104+
let store = MemoryStore::default();
105+
let mut record = Record {
106+
id: Default::default(),
107+
data: Default::default(),
108+
expiry_date: OffsetDateTime::now_utc() + Duration::minutes(30),
109+
};
110+
store.create(&mut record).await.unwrap();
111+
assert!(store.delete(&record.id).await.is_ok());
112+
assert_eq!(None, store.load(&record.id).await.unwrap());
113+
}
114+
115+
#[tokio::test]
116+
async fn test_create_id_collision() {
117+
let store = MemoryStore::default();
118+
let expiry_date = OffsetDateTime::now_utc() + Duration::minutes(30);
119+
let mut record1 = Record {
120+
id: Default::default(),
121+
data: Default::default(),
122+
expiry_date,
123+
};
124+
let mut record2 = Record {
125+
id: Default::default(),
126+
data: Default::default(),
127+
expiry_date,
128+
};
129+
store.create(&mut record1).await.unwrap();
130+
record2.id = record1.id; // Set the same ID for record2
131+
store.create(&mut record2).await.unwrap();
132+
assert_ne!(record1.id, record2.id); // IDs should be different
133+
}
134+
}

src/lib.rs

Lines changed: 14 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -394,34 +394,26 @@
394394
//!
395395
//! Cookies hold a pointer to the session, rather than the session's data, and
396396
//! because of this, the `tower` middleware is focused on managing the process
397-
//! of hydrating a session from the store and managing its life cycle.
397+
//! of initializing a session which can later be used in code to transparently
398+
//! interact with the store.
398399
//!
399-
//! We load a session by looking for a cookie that matches our configured
400-
//! session cookie name. If no such cookie is found or a cookie is found but the
401-
//! store has no such session or the session is no longer active, we create a
402-
//! new session.
403-
//!
404-
//! It's important to note that creating a session **does not** save the session
405-
//! to the store. In fact, the session store is not used at all unless the
406-
//! session is read from or written to. In other words, the middleware only
407-
//! introduces session store overhead when the session is actually used.
400+
//! A session is initialized by looking for a cookie that matches the configured
401+
//! session cookie name. If no such cookie is found or a cookie is found but is
402+
//! malformed, an empty session is initialized.
408403
//!
409-
//! Modified sessions will invoke the session store's
410-
//! [`save`](SessionStore::save) method as well as send a `Set-Cookie` header.
411-
//! While deleted sessions will either be:
412-
//!
413-
//! 1. Deleted, invoking the [`delete`](SessionStore::delete) method and setting
414-
//! a removal cookie or,
415-
//! 2. Cycled, invoking the `delete` method but setting a new ID on the session;
416-
//! the session will have been marked as modified and so this will also set a
417-
//! `Set-Cookie` header on the response.
404+
//! Modified sessions will invoke the session's [`save`](Session::save) method
405+
//! as well as append to the `Set-Cookie` header of the response.
418406
//!
419-
//! Empty sessions are considered to be deleted and are removed from the session
420-
//! store as well as the user agent.
407+
//! Empty sessions are considered deleted and will set a removal cookie
408+
//! on the response but are not removed from the store directly.
421409
//!
422-
//! Sessions also carry with them a configurable expiry and will be deleted in
410+
//! Sessions also carry with them a configurable expiry and will be removed in
423411
//! accordance with this.
424412
//!
413+
//! Notably, the session life cycle minimizes overhead with the store. All
414+
//! session store methods are deferred until the point [`Session`] is used in
415+
//! code and more specifically one of its methods requiring the store is called.
416+
//!
425417
//! [^json]: Using JSON allows us to translate arbitrary types to virtually
426418
//! any backend and gives us a nice interface with which to interact with the
427419
//! session.

tower-sessions-core/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,4 @@ tracing = { version = "0.1.40", features = ["log"] }
3434
tower-sessions = { workspace = true, features = ["memory-store"] }
3535
tokio-test = "0.4.3"
3636
tokio = { workspace = true, features = ["rt", "macros"] }
37+
mockall = "0.12.1"

tower-sessions-core/src/session.rs

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -649,19 +649,21 @@ impl Session {
649649
pub async fn save(&self) -> Result<()> {
650650
let mut record_guard = self.get_record().await?;
651651
record_guard.expiry_date = self.expiry_date();
652-
{
653-
let mut session_id_guard = self.session_id.lock();
654-
if session_id_guard.is_none() {
655-
// Generate a new ID here since e.g. flush may have been called, which will
656-
// not directly update the record ID.
657-
let id = Id::default();
658-
*session_id_guard = Some(id);
659-
record_guard.id = id;
660-
}
661-
}
662-
663-
self.store.save(&record_guard).await.map_err(Error::Store)?;
664652

653+
// Session ID is `None` if:
654+
//
655+
// 1. No valid cookie was found on the request or,
656+
// 2. No valid session was found in the store.
657+
//
658+
// In either case, we must create a new session via the store interface.
659+
//
660+
// Potential ID collisions must be handled by session store implementers.
661+
if self.session_id.lock().is_none() {
662+
self.store.create(&mut record_guard).await?;
663+
*self.session_id.lock() = Some(record_guard.id);
664+
} else {
665+
self.store.save(&record_guard).await?;
666+
}
665667
Ok(())
666668
}
667669

0 commit comments

Comments
 (0)