Skip to content

Commit a419f5f

Browse files
authored
Fix: Add input validation to prevent memory exhaustion (#147)
* Add input validation to prevent memory exhaustion (Issue #82) * fix: coordinated eviction from both caches and update test to trigger eviction * feat: make validation limits configurable via ValidationLimits struct * remove unnecessary deprecated constant aliases * refactor: move cache_size into ValidationLimits * fix: add zero-value validation to all ValidationLimits builder methods
1 parent a74e81d commit a419f5f

File tree

6 files changed

+1077
-26
lines changed

6 files changed

+1077
-26
lines changed

crates/mdk-core/src/groups.rs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3850,18 +3850,21 @@ mod tests {
38503850
.expect("Failed to merge commit");
38513851
}
38523852

3853-
/// Test group with very long name
3853+
/// Test group with long name within allowed limits
3854+
///
3855+
/// Security fix (Issue #82): Group names are now limited to prevent memory exhaustion.
3856+
/// This test verifies that names within the limit work correctly.
38543857
#[test]
38553858
fn test_group_with_long_name() {
38563859
let creator_mdk = create_test_mdk();
38573860
let (creator, members, admins) = create_test_group_members();
38583861
let group_id = create_test_group(&creator_mdk, &creator, &members, &admins);
38593862

3860-
// Update to very long name (1000 characters)
3861-
let long_name = "a".repeat(1000);
3863+
// Update to a long name within the allowed limit (256 bytes)
3864+
let long_name = "a".repeat(256);
38623865
let update = NostrGroupDataUpdate::new().name(long_name);
38633866
let result = creator_mdk.update_group_data(&group_id, update);
3864-
assert!(result.is_ok(), "Long group name should be valid");
3867+
assert!(result.is_ok(), "Group name at limit should be valid");
38653868
creator_mdk
38663869
.merge_pending_commit(&group_id)
38673870
.expect("Failed to merge commit");

crates/mdk-memory-storage/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@
4040

4141
- Implemented pagination support using `Pagination` struct for group messages ([#111](https://github.com/marmot-protocol/mdk/pull/111))
4242
- Implemented pagination support using `Pagination` struct for pending welcomes ([#110](https://github.com/marmot-protocol/mdk/pull/110))
43+
- **Security (Audit Issue AM)**: Added input validation constants and enforcement to prevent memory exhaustion attacks. New public constants: `DEFAULT_MAX_RELAYS_PER_GROUP`, `DEFAULT_MAX_MESSAGES_PER_GROUP`, `DEFAULT_MAX_GROUP_NAME_LENGTH`, `DEFAULT_MAX_GROUP_DESCRIPTION_LENGTH`, `DEFAULT_MAX_ADMINS_PER_GROUP`, `DEFAULT_MAX_RELAYS_PER_WELCOME`, `DEFAULT_MAX_ADMINS_PER_WELCOME`, `DEFAULT_MAX_RELAY_URL_LENGTH`. Fixes [#82](https://github.com/marmot-protocol/mdk/issues/82) ([#147](https://github.com/marmot-protocol/mdk/pull/147))
44+
- Added `ValidationLimits` struct for configurable validation limits, allowing users to override default memory exhaustion protection limits via `MdkMemoryStorage::with_limits()` ([#147](https://github.com/marmot-protocol/mdk/pull/147))
4345

4446
### Fixed
4547

@@ -52,7 +54,9 @@
5254
- **Security (Audit Issue AA)**: Added pagination to prevent memory exhaustion from unbounded loading of pending welcomes ([#110](https://github.com/marmot-protocol/mdk/pull/110))
5355
- **Security (Audit Issue AN)**: Fixed security issue where `save_message` would accept messages for non-existent groups, allowing cache pollution. Now verifies group existence before inserting messages into the cache. ([#113](https://github.com/marmot-protocol/mdk/pull/113))
5456
- **Security (Audit Issue AO)**: Removed MLS group identifiers from error messages to prevent metadata leakage in logs and telemetry. Error messages now use generic "Group not found" instead of including the sensitive 32-byte MLS group ID. ([#112](https://github.com/marmot-protocol/mdk/pull/112))
57+
- **Security (Audit Issue AM)**: Added input validation to prevent memory exhaustion from unbounded per-key values in LRU caches. Validation is enforced in `save_group`, `replace_group_relays`, `save_message`, and `save_welcome` to cap string lengths, collection sizes, and per-group message counts. Fixes [#82](https://github.com/marmot-protocol/mdk/issues/82) ([#147](https://github.com/marmot-protocol/mdk/pull/147))
5558
- Fix `admins()` to return `InvalidParameters` error when group not found, instead of incorrectly returning `NoAdmins` ([#104](https://github.com/marmot-protocol/mdk/pull/104))
59+
5660
### Removed
5761

5862
### Deprecated

crates/mdk-memory-storage/src/groups.rs

Lines changed: 280 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,33 @@ use crate::MdkMemoryStorage;
1313

1414
impl GroupStorage for MdkMemoryStorage {
1515
fn save_group(&self, group: Group) -> Result<(), GroupError> {
16+
// Validate group name length
17+
if group.name.len() > self.limits.max_group_name_length {
18+
return Err(GroupError::InvalidParameters(format!(
19+
"Group name exceeds maximum length of {} bytes (got {} bytes)",
20+
self.limits.max_group_name_length,
21+
group.name.len()
22+
)));
23+
}
24+
25+
// Validate group description length
26+
if group.description.len() > self.limits.max_group_description_length {
27+
return Err(GroupError::InvalidParameters(format!(
28+
"Group description exceeds maximum length of {} bytes (got {} bytes)",
29+
self.limits.max_group_description_length,
30+
group.description.len()
31+
)));
32+
}
33+
34+
// Validate admin pubkeys count
35+
if group.admin_pubkeys.len() > self.limits.max_admins_per_group {
36+
return Err(GroupError::InvalidParameters(format!(
37+
"Group admin count exceeds maximum of {} (got {})",
38+
self.limits.max_admins_per_group,
39+
group.admin_pubkeys.len()
40+
)));
41+
}
42+
1643
// Acquire both locks to ensure atomic update
1744
let mut groups_cache = self.groups_cache.write();
1845
let mut nostr_id_cache = self.groups_by_nostr_id_cache.write();
@@ -131,6 +158,25 @@ impl GroupStorage for MdkMemoryStorage {
131158
group_id: &GroupId,
132159
relays: BTreeSet<RelayUrl>,
133160
) -> Result<(), GroupError> {
161+
// Validate relay count to prevent memory exhaustion
162+
if relays.len() > self.limits.max_relays_per_group {
163+
return Err(GroupError::InvalidParameters(format!(
164+
"Relay count exceeds maximum of {} (got {})",
165+
self.limits.max_relays_per_group,
166+
relays.len()
167+
)));
168+
}
169+
170+
// Validate individual relay URL lengths
171+
for relay in &relays {
172+
if relay.as_str().len() > self.limits.max_relay_url_length {
173+
return Err(GroupError::InvalidParameters(format!(
174+
"Relay URL exceeds maximum length of {} bytes",
175+
self.limits.max_relay_url_length
176+
)));
177+
}
178+
}
179+
134180
// Check if the group exists first
135181
if self.find_group_by_mls_group_id(group_id)?.is_none() {
136182
return Err(GroupError::InvalidParameters("Group not found".to_string()));
@@ -195,12 +241,167 @@ impl GroupStorage for MdkMemoryStorage {
195241
#[cfg(test)]
196242
mod tests {
197243
use super::*;
244+
use crate::{
245+
DEFAULT_MAX_ADMINS_PER_GROUP, DEFAULT_MAX_GROUP_DESCRIPTION_LENGTH,
246+
DEFAULT_MAX_GROUP_NAME_LENGTH, DEFAULT_MAX_RELAY_URL_LENGTH, DEFAULT_MAX_RELAYS_PER_GROUP,
247+
};
198248
use mdk_storage_traits::groups::types::GroupState;
199249
use mdk_storage_traits::messages::MessageStorage;
200250
use mdk_storage_traits::messages::types::{Message, MessageState};
201-
use nostr::{EventId, Kind, PublicKey, Tags, Timestamp, UnsignedEvent};
251+
use nostr::{EventId, Keys, Kind, Tags, Timestamp, UnsignedEvent};
202252
use openmls_memory_storage::MemoryStorage;
203253

254+
fn create_test_group(mls_group_id: GroupId, nostr_group_id: [u8; 32]) -> Group {
255+
Group {
256+
mls_group_id,
257+
nostr_group_id,
258+
name: "Test Group".to_string(),
259+
description: "A test group".to_string(),
260+
admin_pubkeys: BTreeSet::new(),
261+
last_message_id: None,
262+
last_message_at: None,
263+
epoch: 0,
264+
state: GroupState::Active,
265+
image_hash: None,
266+
image_key: None,
267+
image_nonce: None,
268+
}
269+
}
270+
271+
#[test]
272+
fn test_save_group_name_length_validation() {
273+
let storage = MdkMemoryStorage::new(MemoryStorage::default());
274+
let mls_group_id = GroupId::from_slice(&[1, 2, 3, 4]);
275+
276+
// Test with name at exactly the limit (should succeed)
277+
let mut group = create_test_group(mls_group_id.clone(), [1u8; 32]);
278+
group.name = "a".repeat(DEFAULT_MAX_GROUP_NAME_LENGTH);
279+
assert!(storage.save_group(group).is_ok());
280+
281+
// Test with name exceeding the limit (should fail)
282+
let mut group = create_test_group(GroupId::from_slice(&[2, 3, 4, 5]), [2u8; 32]);
283+
group.name = "a".repeat(DEFAULT_MAX_GROUP_NAME_LENGTH + 1);
284+
let result = storage.save_group(group);
285+
assert!(result.is_err());
286+
assert!(
287+
result
288+
.unwrap_err()
289+
.to_string()
290+
.contains("Group name exceeds maximum length")
291+
);
292+
}
293+
294+
#[test]
295+
fn test_save_group_description_length_validation() {
296+
let storage = MdkMemoryStorage::new(MemoryStorage::default());
297+
let mls_group_id = GroupId::from_slice(&[1, 2, 3, 4]);
298+
299+
// Test with description at exactly the limit (should succeed)
300+
let mut group = create_test_group(mls_group_id.clone(), [1u8; 32]);
301+
group.description = "a".repeat(DEFAULT_MAX_GROUP_DESCRIPTION_LENGTH);
302+
assert!(storage.save_group(group).is_ok());
303+
304+
// Test with description exceeding the limit (should fail)
305+
let mut group = create_test_group(GroupId::from_slice(&[2, 3, 4, 5]), [2u8; 32]);
306+
group.description = "a".repeat(DEFAULT_MAX_GROUP_DESCRIPTION_LENGTH + 1);
307+
let result = storage.save_group(group);
308+
assert!(result.is_err());
309+
assert!(
310+
result
311+
.unwrap_err()
312+
.to_string()
313+
.contains("Group description exceeds maximum length")
314+
);
315+
}
316+
317+
#[test]
318+
fn test_save_group_admin_count_validation() {
319+
let storage = MdkMemoryStorage::new(MemoryStorage::default());
320+
let mls_group_id = GroupId::from_slice(&[1, 2, 3, 4]);
321+
322+
// Test with admin count at exactly the limit (should succeed)
323+
let mut group = create_test_group(mls_group_id.clone(), [1u8; 32]);
324+
for _ in 0..DEFAULT_MAX_ADMINS_PER_GROUP {
325+
group.admin_pubkeys.insert(Keys::generate().public_key());
326+
}
327+
assert!(storage.save_group(group).is_ok());
328+
329+
// Test with admin count exceeding the limit (should fail)
330+
let mut group = create_test_group(GroupId::from_slice(&[2, 3, 4, 5]), [2u8; 32]);
331+
for _ in 0..=DEFAULT_MAX_ADMINS_PER_GROUP {
332+
group.admin_pubkeys.insert(Keys::generate().public_key());
333+
}
334+
let result = storage.save_group(group);
335+
assert!(result.is_err());
336+
assert!(
337+
result
338+
.unwrap_err()
339+
.to_string()
340+
.contains("Group admin count exceeds maximum")
341+
);
342+
}
343+
344+
#[test]
345+
fn test_replace_group_relays_count_validation() {
346+
let storage = MdkMemoryStorage::new(MemoryStorage::default());
347+
let mls_group_id = GroupId::from_slice(&[1, 2, 3, 4]);
348+
349+
// Create a group first
350+
let group = create_test_group(mls_group_id.clone(), [1u8; 32]);
351+
storage.save_group(group).unwrap();
352+
353+
// Test with relay count at exactly the limit (should succeed)
354+
let mut relays = BTreeSet::new();
355+
for i in 0..DEFAULT_MAX_RELAYS_PER_GROUP {
356+
relays.insert(RelayUrl::parse(&format!("wss://relay{}.example.com", i)).unwrap());
357+
}
358+
assert!(storage.replace_group_relays(&mls_group_id, relays).is_ok());
359+
360+
// Test with relay count exceeding the limit (should fail)
361+
let mut relays = BTreeSet::new();
362+
for i in 0..=DEFAULT_MAX_RELAYS_PER_GROUP {
363+
relays.insert(RelayUrl::parse(&format!("wss://relay{}.example.com", i)).unwrap());
364+
}
365+
let result = storage.replace_group_relays(&mls_group_id, relays);
366+
assert!(result.is_err());
367+
assert!(
368+
result
369+
.unwrap_err()
370+
.to_string()
371+
.contains("Relay count exceeds maximum")
372+
);
373+
}
374+
375+
#[test]
376+
fn test_replace_group_relays_url_length_validation() {
377+
let storage = MdkMemoryStorage::new(MemoryStorage::default());
378+
let mls_group_id = GroupId::from_slice(&[1, 2, 3, 4]);
379+
380+
// Create a group first
381+
let group = create_test_group(mls_group_id.clone(), [1u8; 32]);
382+
storage.save_group(group).unwrap();
383+
384+
// Test with URL at exactly the limit (should succeed)
385+
// URL format: wss:// (6) + domain + .com (4) = need domain of DEFAULT_MAX_RELAY_URL_LENGTH - 10
386+
let domain = "a".repeat(DEFAULT_MAX_RELAY_URL_LENGTH - 10);
387+
let url = format!("wss://{}.com", domain);
388+
let relays = BTreeSet::from([RelayUrl::parse(&url).unwrap()]);
389+
assert!(storage.replace_group_relays(&mls_group_id, relays).is_ok());
390+
391+
// Test with URL exceeding the limit (should fail)
392+
let domain = "a".repeat(DEFAULT_MAX_RELAY_URL_LENGTH); // This will exceed the limit
393+
let url = format!("wss://{}.com", domain);
394+
let relays = BTreeSet::from([RelayUrl::parse(&url).unwrap()]);
395+
let result = storage.replace_group_relays(&mls_group_id, relays);
396+
assert!(result.is_err());
397+
assert!(
398+
result
399+
.unwrap_err()
400+
.to_string()
401+
.contains("Relay URL exceeds maximum length")
402+
);
403+
}
404+
204405
#[test]
205406
fn test_messages_pagination_memory() {
206407
let storage = MdkMemoryStorage::new(MemoryStorage::default());
@@ -227,7 +428,7 @@ mod tests {
227428
storage.save_group(group).unwrap();
228429

229430
// Create 25 test messages
230-
let pubkey = PublicKey::from_slice(&[1u8; 32]).unwrap();
431+
let pubkey = Keys::generate().public_key();
231432
for i in 0..25 {
232433
let event_id = EventId::from_slice(&[i as u8; 32]).unwrap();
233434
let wrapper_event_id = EventId::from_slice(&[100 + i as u8; 32]).unwrap();
@@ -360,6 +561,83 @@ mod tests {
360561
assert_eq!(result.unwrap().len(), 0); // No results at that offset
361562
}
362563

564+
/// Test that custom validation limits work correctly for groups
565+
#[test]
566+
fn test_custom_group_limits() {
567+
use crate::ValidationLimits;
568+
569+
// Create storage with custom smaller limits
570+
let limits = ValidationLimits::default()
571+
.with_max_group_name_length(10)
572+
.with_max_group_description_length(20)
573+
.with_max_admins_per_group(2)
574+
.with_max_relays_per_group(3);
575+
576+
let storage = MdkMemoryStorage::with_limits(MemoryStorage::default(), limits);
577+
578+
// Verify limits are accessible
579+
assert_eq!(storage.limits().max_group_name_length, 10);
580+
assert_eq!(storage.limits().max_group_description_length, 20);
581+
assert_eq!(storage.limits().max_admins_per_group, 2);
582+
assert_eq!(storage.limits().max_relays_per_group, 3);
583+
584+
let mls_group_id = GroupId::from_slice(&[1, 2, 3, 4]);
585+
586+
// Test name length with custom limit (10 chars should succeed)
587+
let mut group = create_test_group(mls_group_id.clone(), [1u8; 32]);
588+
group.name = "a".repeat(10);
589+
assert!(storage.save_group(group).is_ok());
590+
591+
// Test name length exceeding custom limit (11 chars should fail)
592+
let mut group = create_test_group(GroupId::from_slice(&[2, 3, 4, 5]), [2u8; 32]);
593+
group.name = "a".repeat(11);
594+
let result = storage.save_group(group);
595+
assert!(result.is_err());
596+
assert!(result.unwrap_err().to_string().contains("10 bytes"));
597+
598+
// Test admin count with custom limit (2 admins should succeed)
599+
let mut group = create_test_group(GroupId::from_slice(&[3, 4, 5, 6]), [3u8; 32]);
600+
group.admin_pubkeys.insert(Keys::generate().public_key());
601+
group.admin_pubkeys.insert(Keys::generate().public_key());
602+
assert!(storage.save_group(group).is_ok());
603+
604+
// Test admin count exceeding custom limit (3 admins should fail)
605+
let mut group = create_test_group(GroupId::from_slice(&[4, 5, 6, 7]), [4u8; 32]);
606+
for _ in 0..3 {
607+
group.admin_pubkeys.insert(Keys::generate().public_key());
608+
}
609+
let result = storage.save_group(group);
610+
assert!(result.is_err());
611+
assert!(result.unwrap_err().to_string().contains("maximum of 2"));
612+
613+
// Test relay count with custom limit
614+
let group = create_test_group(GroupId::from_slice(&[5, 6, 7, 8]), [5u8; 32]);
615+
storage.save_group(group).unwrap();
616+
617+
// 3 relays should succeed
618+
let relays = BTreeSet::from([
619+
RelayUrl::parse("wss://r1.com").unwrap(),
620+
RelayUrl::parse("wss://r2.com").unwrap(),
621+
RelayUrl::parse("wss://r3.com").unwrap(),
622+
]);
623+
assert!(
624+
storage
625+
.replace_group_relays(&GroupId::from_slice(&[5, 6, 7, 8]), relays)
626+
.is_ok()
627+
);
628+
629+
// 4 relays should fail
630+
let relays = BTreeSet::from([
631+
RelayUrl::parse("wss://r1.com").unwrap(),
632+
RelayUrl::parse("wss://r2.com").unwrap(),
633+
RelayUrl::parse("wss://r3.com").unwrap(),
634+
RelayUrl::parse("wss://r4.com").unwrap(),
635+
]);
636+
let result = storage.replace_group_relays(&GroupId::from_slice(&[5, 6, 7, 8]), relays);
637+
assert!(result.is_err());
638+
assert!(result.unwrap_err().to_string().contains("maximum of 3"));
639+
}
640+
363641
#[test]
364642
fn test_nostr_group_id_collision_rejected() {
365643
let storage = MdkMemoryStorage::new(MemoryStorage::default());

0 commit comments

Comments
 (0)