Skip to content

Commit 972846f

Browse files
committed
fix(mitm): bound generated leaf certificate cache
The MITM certificate manager caches generated rustls ServerConfig instances by domain so repeated HTTPS interception does not regenerate a leaf certificate for every connection. That cache was an unbounded HashMap, so long-running sessions that touched many hostnames could retain every generated leaf configuration until process exit. Add an explicit leaf-cache capacity and maintain a small LRU order alongside the existing domain map. Cache hits refresh their eviction position, replacements remove stale order entries, and inserts evict the oldest cached domain once the configured capacity is reached. The default limit keeps hot domains reusable while preventing unbounded growth in generated certificate chains, private-key material wrapped in rustls configs, and per-domain server state. Add focused tests for capacity eviction and hit-refresh behavior using a reduced test capacity. The public MITM API, CA storage layout, generated leaf contents, ALPN settings, and certificate validity rules remain unchanged; only cache retention policy changes.
1 parent 40b5386 commit 972846f

1 file changed

Lines changed: 75 additions & 4 deletions

File tree

src/mitm.rs

Lines changed: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use std::collections::HashMap;
1+
use std::collections::{HashMap, VecDeque};
22
use std::path::{Path, PathBuf};
33
use std::sync::Arc;
44

@@ -27,6 +27,7 @@ pub const CERT_NAME: &str = "MasterHttpRelayVPN";
2727
pub const CA_DIR: &str = "ca";
2828
pub const CA_KEY_FILE: &str = "ca/ca.key";
2929
pub const CA_CERT_FILE: &str = "ca/ca.crt";
30+
const DEFAULT_LEAF_CACHE_CAPACITY: usize = 512;
3031

3132
pub struct MitmCertManager {
3233
/// The CA certificate bytes as they appear on disk.
@@ -41,6 +42,8 @@ pub struct MitmCertManager {
4142
/// re-made), but that's fine — we never send this cert to browsers.
4243
ca_cert: Certificate,
4344
cache: HashMap<String, Arc<ServerConfig>>,
45+
cache_order: VecDeque<String>,
46+
cache_capacity: usize,
4447
}
4548

4649
impl MitmCertManager {
@@ -88,6 +91,8 @@ impl MitmCertManager {
8891
ca_key_pair: key_pair,
8992
ca_cert,
9093
cache: HashMap::new(),
94+
cache_order: VecDeque::new(),
95+
cache_capacity: DEFAULT_LEAF_CACHE_CAPACITY,
9196
})
9297
}
9398

@@ -127,6 +132,8 @@ impl MitmCertManager {
127132
ca_key_pair: key_pair,
128133
ca_cert,
129134
cache: HashMap::new(),
135+
cache_order: VecDeque::new(),
136+
cache_capacity: DEFAULT_LEAF_CACHE_CAPACITY,
130137
})
131138
}
132139

@@ -136,8 +143,9 @@ impl MitmCertManager {
136143

137144
/// Return a rustls ServerConfig for the given domain, ALPN ["http/1.1"].
138145
pub fn get_server_config(&mut self, domain: &str) -> Result<Arc<ServerConfig>, MitmError> {
139-
if let Some(cfg) = self.cache.get(domain) {
140-
return Ok(cfg.clone());
146+
if let Some(cfg) = self.cache.get(domain).cloned() {
147+
self.touch_cached_domain(domain);
148+
return Ok(cfg);
141149
}
142150
let (leaf_der, leaf_key_der) = self.issue_leaf(domain)?;
143151

@@ -149,10 +157,35 @@ impl MitmCertManager {
149157
.with_single_cert(chain, key)?;
150158
cfg.alpn_protocols = vec![b"http/1.1".to_vec()];
151159
let arc = Arc::new(cfg);
152-
self.cache.insert(domain.to_string(), arc.clone());
160+
self.insert_cached_config(domain.to_string(), arc.clone());
153161
Ok(arc)
154162
}
155163

164+
fn touch_cached_domain(&mut self, domain: &str) {
165+
self.cache_order.retain(|cached| cached != domain);
166+
self.cache_order.push_back(domain.to_string());
167+
}
168+
169+
fn insert_cached_config(&mut self, domain: String, cfg: Arc<ServerConfig>) {
170+
if self.cache_capacity == 0 {
171+
return;
172+
}
173+
174+
if self.cache.remove(&domain).is_some() {
175+
self.cache_order.retain(|cached| cached != &domain);
176+
}
177+
178+
while self.cache.len() >= self.cache_capacity {
179+
let Some(evicted_domain) = self.cache_order.pop_front() else {
180+
break;
181+
};
182+
self.cache.remove(&evicted_domain);
183+
}
184+
185+
self.cache.insert(domain.clone(), cfg);
186+
self.cache_order.push_back(domain);
187+
}
188+
156189
fn issue_leaf(&self, domain: &str) -> Result<(CertificateDer<'static>, Vec<u8>), MitmError> {
157190
let mut params = CertificateParams::default();
158191
let mut dn = DistinguishedName::new();
@@ -268,6 +301,44 @@ mod tests {
268301
let _ = std::fs::remove_dir_all(&tmp);
269302
}
270303

304+
#[test]
305+
fn leaf_cache_is_capacity_bounded() {
306+
init_crypto();
307+
let tmp = tempdir();
308+
let mut m = MitmCertManager::new_in(&tmp).unwrap();
309+
m.cache_capacity = 2;
310+
311+
let _ = m.get_server_config("a.example.com").unwrap();
312+
let _ = m.get_server_config("b.example.com").unwrap();
313+
let _ = m.get_server_config("c.example.com").unwrap();
314+
315+
assert_eq!(m.cache.len(), 2);
316+
assert!(!m.cache.contains_key("a.example.com"));
317+
assert!(m.cache.contains_key("b.example.com"));
318+
assert!(m.cache.contains_key("c.example.com"));
319+
assert_eq!(m.cache_order.len(), 2);
320+
let _ = std::fs::remove_dir_all(&tmp);
321+
}
322+
323+
#[test]
324+
fn leaf_cache_hit_refreshes_eviction_order() {
325+
init_crypto();
326+
let tmp = tempdir();
327+
let mut m = MitmCertManager::new_in(&tmp).unwrap();
328+
m.cache_capacity = 2;
329+
330+
let _ = m.get_server_config("a.example.com").unwrap();
331+
let _ = m.get_server_config("b.example.com").unwrap();
332+
let _ = m.get_server_config("a.example.com").unwrap();
333+
let _ = m.get_server_config("c.example.com").unwrap();
334+
335+
assert_eq!(m.cache.len(), 2);
336+
assert!(m.cache.contains_key("a.example.com"));
337+
assert!(!m.cache.contains_key("b.example.com"));
338+
assert!(m.cache.contains_key("c.example.com"));
339+
let _ = std::fs::remove_dir_all(&tmp);
340+
}
341+
271342
fn tempdir() -> PathBuf {
272343
let mut p = std::env::temp_dir();
273344
let n: u64 = rand::random();

0 commit comments

Comments
 (0)