Skip to content

Commit b200eea

Browse files
committed
feat: security hardening and packet stats in TUI
Bound resource limits, restrict file permissions and sandbox access, add packets/sec and backpressure visibility to stats panel.
1 parent df178e1 commit b200eea

11 files changed

Lines changed: 224 additions & 24 deletions

File tree

‎src/app.rs‎

Lines changed: 79 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use crossbeam::channel::{self, Receiver, Sender};
44
use dashmap::DashMap;
55
use log::{debug, error, info, warn};
66
use serde_json::json;
7-
use std::fs::OpenOptions;
7+
use std::fs::{File, OpenOptions};
88
use std::io::Write;
99
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
1010
use std::sync::{Arc, RwLock};
@@ -101,6 +101,30 @@ impl ProcessDetectionStatus {
101101
static QUIC_CONNECTION_MAPPING: LazyLock<Mutex<HashMap<String, String>>> =
102102
LazyLock::new(|| Mutex::new(HashMap::new()));
103103

104+
/// Maximum QUIC connection ID mappings to prevent unbounded growth.
105+
const MAX_QUIC_MAPPINGS: usize = 10_000;
106+
107+
/// Maximum queued packets before backpressure drops packets.
108+
/// At ~1500 bytes per packet, 10,000 packets ≈ 15 MB of buffer.
109+
const MAX_PACKET_QUEUE: usize = 10_000;
110+
111+
/// Maximum tracked connections to prevent memory exhaustion from port scans
112+
/// or connection floods.
113+
const MAX_CONNECTIONS: usize = 50_000;
114+
115+
/// Open or create a file for appending with restrictive permissions (0o600 on Unix).
116+
///
117+
/// Ensures log files containing connection metadata are not world-readable.
118+
fn open_log_file(path: &str) -> std::io::Result<File> {
119+
let file = OpenOptions::new().create(true).append(true).open(path)?;
120+
#[cfg(unix)]
121+
{
122+
use std::os::unix::fs::PermissionsExt;
123+
let _ = file.set_permissions(std::fs::Permissions::from_mode(0o600));
124+
}
125+
Ok(file)
126+
}
127+
104128
/// Helper function to log connection events as JSON
105129
fn log_connection_event(
106130
json_log_path: &str,
@@ -213,11 +237,8 @@ fn log_connection_event(
213237
}
214238
}
215239

216-
// Write to file
217-
if let Ok(mut file) = OpenOptions::new()
218-
.create(true)
219-
.append(true)
220-
.open(json_log_path)
240+
// Write to file (restrictive permissions: 0o600 on Unix)
241+
if let Ok(mut file) = open_log_file(json_log_path)
221242
&& let Ok(json_str) = serde_json::to_string(&event)
222243
{
223244
let _ = writeln!(file, "{}", json_str);
@@ -265,10 +286,7 @@ fn log_pcap_connection(pcap_path: &str, conn: &Connection) {
265286
}
266287
}
267288

268-
if let Ok(mut file) = OpenOptions::new()
269-
.create(true)
270-
.append(true)
271-
.open(&json_path)
289+
if let Ok(mut file) = open_log_file(&json_path)
272290
&& let Ok(json_str) = serde_json::to_string(&event)
273291
{
274292
let _ = writeln!(file, "{}", json_str);
@@ -549,7 +567,7 @@ impl App {
549567
connections: Arc<DashMap<String, Connection>>,
550568
) -> Result<()> {
551569
// Create packet channel
552-
let (packet_tx, packet_rx) = channel::unbounded();
570+
let (packet_tx, packet_rx) = channel::bounded(MAX_PACKET_QUEUE);
553571

554572
// Start capture thread
555573
self.start_capture_thread(packet_tx)?;
@@ -592,6 +610,18 @@ impl App {
592610
*current_interface.write().unwrap() = Some(device_name.clone());
593611
*linktype_storage.write().unwrap() = Some(linktype);
594612

613+
// Drop CAP_NET_RAW now that the socket is open (Linux only)
614+
#[cfg(all(target_os = "linux", feature = "landlock"))]
615+
{
616+
if let Err(e) =
617+
crate::network::platform::sandbox::capabilities::drop_cap_net_raw()
618+
{
619+
warn!("Failed to drop CAP_NET_RAW in capture thread: {}", e);
620+
} else {
621+
debug!("Dropped CAP_NET_RAW in capture thread");
622+
}
623+
}
624+
595625
// Check if PKTAP is active (linktype 149 or 258)
596626
#[cfg(target_os = "macos")]
597627
{
@@ -678,9 +708,17 @@ impl App {
678708
});
679709
}
680710

681-
if packet_tx.send(packet).is_err() {
682-
warn!("Packet channel closed");
683-
break;
711+
match packet_tx.try_send(packet) {
712+
Ok(()) => {}
713+
Err(crossbeam::channel::TrySendError::Full(_)) => {
714+
stats
715+
.packets_dropped
716+
.fetch_add(1, Ordering::Relaxed);
717+
}
718+
Err(crossbeam::channel::TrySendError::Disconnected(_)) => {
719+
warn!("Packet channel closed");
720+
break;
721+
}
684722
}
685723
}
686724
Ok(None) => {
@@ -767,6 +805,17 @@ impl App {
767805
thread::spawn(move || {
768806
info!("Packet processor {} started", id);
769807

808+
// Drop CAP_NET_RAW immediately as this thread doesn't need it (Linux only)
809+
#[cfg(all(target_os = "linux", feature = "landlock"))]
810+
{
811+
if let Err(e) = crate::network::platform::sandbox::capabilities::drop_cap_net_raw()
812+
{
813+
warn!("Failed to drop CAP_NET_RAW in processor thread {}: {}", id, e);
814+
} else {
815+
debug!("Dropped CAP_NET_RAW in processor thread {}", id);
816+
}
817+
}
818+
770819
// Wait for linktype to be available
771820
let parser = loop {
772821
if let Some(linktype) = *linktype_storage.read().unwrap() {
@@ -1403,7 +1452,7 @@ impl App {
14031452
);
14041453
}
14051454

1406-
thread::sleep(Duration::from_secs(10));
1455+
thread::sleep(Duration::from_secs(5));
14071456
}
14081457
});
14091458

@@ -1696,6 +1745,11 @@ fn update_connection(
16961745
key, conn_id_hex
16971746
);
16981747
} else {
1748+
// Prevent unbounded growth of QUIC connection ID mappings
1749+
if mapping.len() >= MAX_QUIC_MAPPINGS {
1750+
debug!("QUIC mapping limit reached, clearing old entries");
1751+
mapping.clear();
1752+
}
16991753
// New QUIC connection ID, create mapping
17001754
mapping.insert(conn_id_hex.clone(), key.clone());
17011755
debug!(
@@ -1705,6 +1759,16 @@ fn update_connection(
17051759
}
17061760
}
17071761

1762+
// Prevent unbounded growth from port scans or connection floods.
1763+
// Only limit new connections; existing ones always get updated.
1764+
if !connections.contains_key(&key) && connections.len() >= MAX_CONNECTIONS {
1765+
debug!(
1766+
"Connection limit reached ({}), dropping new connection: {}",
1767+
MAX_CONNECTIONS, key
1768+
);
1769+
return;
1770+
}
1771+
17081772
connections
17091773
.entry(key.clone())
17101774
.and_modify(|conn| {

‎src/main.rs‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,14 @@ fn main() -> Result<()> {
134134
// This must be done BEFORE Landlock is applied so the file exists when adding rules
135135
if let Some(ref pcap_path) = config.pcap_export_file {
136136
let jsonl_path = format!("{}.connections.jsonl", pcap_path);
137-
if let Err(e) = std::fs::File::create(&jsonl_path) {
137+
if let Err(e) = std::fs::File::create(&jsonl_path).and_then(|f| {
138+
#[cfg(unix)]
139+
{
140+
use std::os::unix::fs::PermissionsExt;
141+
f.set_permissions(std::fs::Permissions::from_mode(0o600))?;
142+
}
143+
Ok(f)
144+
}) {
138145
warn!("Failed to pre-create sidecar JSONL file: {}", e);
139146
}
140147
}

‎src/network/dpi/bittorrent.rs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ const EXTENSION_BIT_BYTE: usize = 5; // Byte 5, bit 0x10
1414
/// uTP header size (BEP 29)
1515
const UTP_HEADER_LEN: usize = 20;
1616

17+
/// Maximum DHT method name length. Standard methods are short
18+
/// (e.g., "ping", "find_node", "get_peers", "announce_peer").
19+
const MAX_DHT_METHOD_LEN: usize = 64;
20+
1721
// --- TCP: Peer Handshake ---
1822

1923
/// Check if the payload starts with a BitTorrent peer handshake.
@@ -135,6 +139,9 @@ fn extract_dht_method(payload: &[u8]) -> Option<String> {
135139

136140
let len_str = std::str::from_utf8(&payload[after..len_end]).ok()?;
137141
let str_len: usize = len_str.parse().ok()?;
142+
if str_len > MAX_DHT_METHOD_LEN {
143+
return None;
144+
}
138145
let str_start = len_end + 1;
139146
let str_end = str_start + str_len;
140147
if str_end > payload.len() {

‎src/network/dpi/dns.rs‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
use crate::network::types::{DnsInfo, DnsQueryType};
22

3+
/// Maximum DNS name length per RFC 1035 section 2.3.4
4+
const MAX_DNS_NAME_LEN: usize = 253;
5+
36
pub fn analyze_dns(payload: &[u8]) -> Option<DnsInfo> {
47
if payload.len() < 12 {
58
return None;
@@ -50,6 +53,11 @@ pub fn analyze_dns(payload: &[u8]) -> Option<DnsInfo> {
5053
name.push_str(label);
5154
}
5255

56+
// Enforce RFC 1035 maximum name length
57+
if name.len() > MAX_DNS_NAME_LEN {
58+
break;
59+
}
60+
5361
offset += 1 + label_len;
5462
}
5563

@@ -115,3 +123,61 @@ pub fn analyze_dns(payload: &[u8]) -> Option<DnsInfo> {
115123

116124
Some(info)
117125
}
126+
127+
#[cfg(test)]
128+
mod tests {
129+
use super::*;
130+
131+
#[test]
132+
fn test_empty_payload_safe() {
133+
assert!(analyze_dns(&[]).is_none());
134+
}
135+
136+
#[test]
137+
fn test_short_payload_safe() {
138+
assert!(analyze_dns(&[0; 5]).is_none());
139+
}
140+
141+
#[test]
142+
fn test_dns_name_length_limit() {
143+
// Build a DNS packet with many 63-byte labels (exceeding 253 chars)
144+
let mut payload = vec![0u8; 12]; // DNS header
145+
// Set qdcount = 1
146+
payload[5] = 1;
147+
// Add 10 labels of 63 bytes each (630+ chars total, exceeds 253)
148+
for _ in 0..10 {
149+
payload.push(63); // label length
150+
payload.extend_from_slice(&[b'a'; 63]);
151+
}
152+
payload.push(0); // null terminator
153+
payload.extend_from_slice(&[0, 1, 0, 1]); // QTYPE A, QCLASS IN
154+
155+
let info = analyze_dns(&payload).unwrap();
156+
if let Some(name) = &info.query_name {
157+
// Name should be truncated near the RFC limit, not the full 630+ chars
158+
assert!(name.len() <= MAX_DNS_NAME_LEN + 63 + 1);
159+
}
160+
}
161+
162+
#[test]
163+
fn test_normal_dns_query() {
164+
// Build a simple query for "example.com"
165+
let mut payload = vec![0u8; 12]; // DNS header
166+
payload[5] = 1; // qdcount = 1
167+
// "example" label
168+
payload.push(7);
169+
payload.extend_from_slice(b"example");
170+
// "com" label
171+
payload.push(3);
172+
payload.extend_from_slice(b"com");
173+
// null terminator
174+
payload.push(0);
175+
// QTYPE A (1), QCLASS IN (1)
176+
payload.extend_from_slice(&[0, 1, 0, 1]);
177+
178+
let info = analyze_dns(&payload).unwrap();
179+
assert_eq!(info.query_name, Some("example.com".to_string()));
180+
assert_eq!(info.query_type, Some(DnsQueryType::A));
181+
assert!(!info.is_response);
182+
}
183+
}

‎src/network/dpi/https.rs‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
use crate::network::types::{HttpsInfo, TlsInfo, TlsVersion};
22
use log::debug;
33

4+
/// Maximum TLS extensions to parse. Defense-in-depth against crafted packets
5+
/// with many zero-length extensions designed to waste CPU.
6+
const MAX_TLS_EXTENSIONS: usize = 100;
7+
48
pub fn is_tls_handshake(payload: &[u8]) -> bool {
59
if payload.len() < 5 {
610
return false;
@@ -252,8 +256,13 @@ fn parse_server_hello(data: &[u8], info: &mut TlsInfo) {
252256

253257
fn parse_extensions(data: &[u8], info: &mut TlsInfo, is_client_hello: bool) {
254258
let mut offset = 0;
259+
let mut extensions_parsed = 0;
255260

256261
while offset + 4 <= data.len() {
262+
extensions_parsed += 1;
263+
if extensions_parsed > MAX_TLS_EXTENSIONS {
264+
break;
265+
}
257266
let ext_type = u16::from_be_bytes([data[offset], data[offset + 1]]);
258267
let ext_len = u16::from_be_bytes([data[offset + 2], data[offset + 3]]) as usize;
259268

‎src/network/dpi/mqtt.rs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,13 @@ mod tests {
291291
pkt
292292
}
293293

294+
#[test]
295+
fn test_empty_payload_safe() {
296+
// This simulates a potential DoS attack with an empty packet
297+
// Should return None, not panic
298+
assert!(analyze_mqtt(&[]).is_none());
299+
}
300+
294301
#[test]
295302
fn test_connect_v311() {
296303
let pkt = build_connect(b"MQTT", 4, "my-client");

‎src/network/dpi/stun.rs‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,16 @@ mod tests {
184184
attr
185185
}
186186

187+
#[test]
188+
fn test_empty_payload_safe() {
189+
assert!(analyze_stun(&[]).is_none());
190+
}
191+
192+
#[test]
193+
fn test_short_payload_safe() {
194+
assert!(analyze_stun(&[0x00, 0x01]).is_none());
195+
}
196+
187197
#[test]
188198
fn test_binding_request() {
189199
let packet = build_stun_packet(0, 0x0001, &[]);

‎src/network/platform/linux/sandbox/landlock.rs‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,13 @@ pub fn apply_landlock(config: &SandboxConfig) -> Result<LandlockResult> {
6666
// We need read access to /proc for process identification
6767
let read_access = AccessFs::from_read(abi);
6868

69-
// Build filesystem access rights for writing
70-
let write_access = AccessFs::from_all(abi);
69+
// Build filesystem access rights for writing (principle of least privilege)
70+
// RustNet only needs to create regular files, write/append to them, and traverse dirs.
71+
let write_access = AccessFs::WriteFile
72+
| AccessFs::ReadFile
73+
| AccessFs::ReadDir
74+
| AccessFs::MakeReg
75+
| AccessFs::Truncate;
7176

7277
// Start building the ruleset
7378
let ruleset = Ruleset::default()

‎src/network/platform/linux/sandbox/mod.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
//! - Older kernels: Graceful degradation (sandbox not applied)
2121
2222
#[cfg(feature = "landlock")]
23-
mod capabilities;
23+
pub mod capabilities;
2424
#[cfg(feature = "landlock")]
2525
mod landlock;
2626

‎src/network/types.rs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1041,6 +1041,7 @@ pub struct TrafficSample {
10411041
pub tx_bytes_per_sec: u64,
10421042
pub connection_count: usize,
10431043
// Network health metrics
1044+
pub packets_per_sec: u64,
10441045
pub packet_loss_pct: f32,
10451046
pub avg_rtt_ms: Option<f64>,
10461047
}
@@ -1081,6 +1082,7 @@ impl TrafficHistory {
10811082
rx_bytes_per_sec,
10821083
tx_bytes_per_sec,
10831084
connection_count,
1085+
packets_per_sec: total_packets,
10841086
packet_loss_pct,
10851087
avg_rtt_ms,
10861088
};
@@ -1091,6 +1093,11 @@ impl TrafficHistory {
10911093
self.samples.push_back(sample);
10921094
}
10931095

1096+
/// Get the latest packets/sec value, or 0 if no samples
1097+
pub fn get_latest_packets_per_sec(&self) -> u64 {
1098+
self.samples.back().map(|s| s.packets_per_sec).unwrap_or(0)
1099+
}
1100+
10941101
/// Get RX bytes/sec values for sparkline (newest last), smoothed with moving average
10951102
pub fn get_rx_sparkline_data(&self, count: usize) -> Vec<u64> {
10961103
let raw: Vec<u64> = self

0 commit comments

Comments
 (0)