Skip to content
This repository was archived by the owner on Apr 13, 2026. It is now read-only.

Commit c614ddb

Browse files
committed
fix(security): prevent XML injection and remove dead code
Apply escape_xml_string() to all user-derived template substitutions in XmlTemplate::apply_configuration() to prevent XML injection (CWE-91) from crafted CSV input. Change apply_configuration signature from &mut self to &self since it only reads base_content. Extract network_base() helper in VlanConfig to eliminate 7x duplicated strip_suffix pattern across gateway_ip(), dhcp_range_start(), dhcp_range_end(), as_ipv4_network(), and static_reservations(). Remove no-op functions: setup_environment() in main.rs and configure_terminal_with_global() in generate.rs. Remove unused rand::Rng imports in nat.rs and vpn.rs (already covered by prelude). Signed-off-by: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
1 parent 4b08f31 commit c614ddb

11 files changed

Lines changed: 56 additions & 96 deletions

File tree

benches/xml_generation.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ fn bench_xml_generation(c: &mut Criterion) {
2020
let config = &configs[0];
2121

2222
b.iter(|| {
23-
let mut template = XmlTemplate::new(base_xml.to_string()).unwrap();
23+
let template = XmlTemplate::new(base_xml.to_string()).unwrap();
2424
let result = template
2525
.apply_configuration(black_box(config), 1, 6)
2626
.unwrap();
@@ -37,7 +37,7 @@ fn bench_xml_generation(c: &mut Criterion) {
3737
b.iter(|| {
3838
let mut results = Vec::new();
3939
for config in &configs {
40-
let mut template = XmlTemplate::new(base_xml.to_string()).unwrap();
40+
let template = XmlTemplate::new(base_xml.to_string()).unwrap();
4141
let result = template
4242
.apply_configuration(black_box(config), 1, 6)
4343
.unwrap();

mise.lock

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

mise.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,4 @@ lychee = "0.22.0"
3232
node = "25.4.0"
3333
zig = "0.15.2"
3434
python = "3.14.2"
35+
rust = "stable"

src/cli/commands/generate.rs

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,6 @@ pub fn execute_with_global(mut args: GenerateArgs, global: &GlobalArgs) -> Resul
3131
}
3232
}
3333

34-
// Handle terminal compatibility
35-
configure_terminal_with_global(&args, global);
36-
3734
execute_internal(args, global)
3835
}
3936

@@ -84,14 +81,6 @@ fn execute_internal(args: GenerateArgs, global: &GlobalArgs) -> Result<()> {
8481
}
8582
}
8683

87-
/// Configure terminal output based on environment and arguments with global context
88-
fn configure_terminal_with_global(args: &GenerateArgs, global: &GlobalArgs) {
89-
// Handle TERM=dumb compatibility - colors are automatically disabled
90-
// by checking env::var("NO_COLOR").is_ok() and env::var("TERM") == "dumb"
91-
// in the progress bar and console styling code
92-
let _ = (args, global); // Suppress unused parameter warnings
93-
}
94-
9584
/// Handle interactive mode prompts for missing required arguments
9685
fn handle_interactive_mode(mut args: GenerateArgs) -> Result<GenerateArgs> {
9786
let term = Term::stdout();
@@ -529,7 +518,7 @@ fn execute_xml_generation(args: &GenerateArgs, global: &GlobalArgs) -> Result<()
529518
// Load base XML template
530519
let base_xml = fs::read_to_string(base_config)
531520
.with_context(|| format!("Failed to read base config file: {:?}", base_config))?;
532-
let mut template = XmlTemplate::new(base_xml)
521+
let template = XmlTemplate::new(base_xml)
533522
.with_context(|| "Failed to create XML template from base configuration")?;
534523

535524
// Set up progress for XML generation

src/cli/commands/xml.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ pub fn execute(args: XmlArgs) -> Result<()> {
6363

6464
// Load base XML template
6565
let base_xml = fs::read_to_string(&args.base_config)?;
66-
let mut template = XmlTemplate::new(base_xml)?;
66+
let template = XmlTemplate::new(base_xml)?;
6767

6868
// Set up progress for XML generation
6969
let pb = ProgressBar::new(configs.len() as u64);

src/generator/nat.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
//! mappings including port forwarding, source NAT, and destination NAT rules.
55
66
use crate::model::ConfigError;
7-
use rand::Rng;
87
use rand::prelude::*;
98
use serde::{Deserialize, Serialize};
109
use std::collections::HashSet;

src/generator/vlan.rs

Lines changed: 34 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -177,18 +177,17 @@ impl VlanConfig {
177177

178178
/// Get the network as an Ipv4Network if possible
179179
pub fn as_ipv4_network(&self) -> VlanResult<Ipv4Network> {
180-
if let Some(base) = self.ip_network.strip_suffix(".x") {
181-
let network_str = format!("{base}.0/24");
182-
rfc1918::validate_rfc1918_network_string(&network_str)
183-
} else if let Some(base) = self.ip_network.strip_suffix(".0/24") {
184-
let network_str = format!("{base}.0/24");
185-
rfc1918::validate_rfc1918_network_string(&network_str)
186-
} else {
187-
Err(VlanError::network_parsing(format!(
188-
"Cannot parse network format: {}",
189-
self.ip_network
190-
)))
191-
}
180+
let base = self
181+
.ip_network
182+
.strip_suffix(".x")
183+
.or_else(|| self.ip_network.strip_suffix(".0/24"))
184+
.ok_or_else(|| {
185+
VlanError::network_parsing(format!(
186+
"Cannot parse network format: {}",
187+
self.ip_network
188+
))
189+
})?;
190+
rfc1918::validate_rfc1918_network_string(&format!("{base}.0/24"))
192191
}
193192

194193
/// Validate that this configuration is RFC 1918 compliant
@@ -238,46 +237,34 @@ impl VlanConfig {
238237
"255.255.255.0"
239238
}
240239

240+
/// Extract the three-octet base prefix from the IP network string.
241+
///
242+
/// Handles both "10.1.2.x" and "10.1.2.0/24" formats, returning "10.1.2".
243+
fn network_base(&self) -> Result<&str> {
244+
self.ip_network
245+
.strip_suffix(".x")
246+
.or_else(|| self.ip_network.strip_suffix(".0/24"))
247+
.ok_or_else(|| {
248+
ConfigError::validation(format!(
249+
"Cannot parse base from IP network: {}",
250+
self.ip_network
251+
))
252+
})
253+
}
254+
241255
/// Get the gateway IP address (network + 1)
242256
pub fn gateway_ip(&self) -> Result<String> {
243-
if let Some(base) = self.ip_network.strip_suffix(".x") {
244-
Ok(format!("{base}.1"))
245-
} else if let Some(base) = self.ip_network.strip_suffix(".0/24") {
246-
Ok(format!("{base}.1"))
247-
} else {
248-
Err(ConfigError::validation(format!(
249-
"Cannot derive gateway from IP network: {}",
250-
self.ip_network
251-
)))
252-
}
257+
Ok(format!("{}.1", self.network_base()?))
253258
}
254259

255260
/// Get the DHCP range start IP
256261
pub fn dhcp_range_start(&self) -> Result<String> {
257-
if let Some(base) = self.ip_network.strip_suffix(".x") {
258-
Ok(format!("{base}.100"))
259-
} else if let Some(base) = self.ip_network.strip_suffix(".0/24") {
260-
Ok(format!("{base}.100"))
261-
} else {
262-
Err(ConfigError::validation(format!(
263-
"Cannot derive DHCP range from IP network: {}",
264-
self.ip_network
265-
)))
266-
}
262+
Ok(format!("{}.100", self.network_base()?))
267263
}
268264

269265
/// Get the DHCP range end IP
270266
pub fn dhcp_range_end(&self) -> Result<String> {
271-
if let Some(base) = self.ip_network.strip_suffix(".x") {
272-
Ok(format!("{base}.200"))
273-
} else if let Some(base) = self.ip_network.strip_suffix(".0/24") {
274-
Ok(format!("{base}.200"))
275-
} else {
276-
Err(ConfigError::validation(format!(
277-
"Cannot derive DHCP range from IP network: {}",
278-
self.ip_network
279-
)))
280-
}
267+
Ok(format!("{}.200", self.network_base()?))
281268
}
282269

283270
/// Get the DHCP lease time based on department type (in seconds)
@@ -345,16 +332,7 @@ impl VlanConfig {
345332
let mut reservations = Vec::new();
346333

347334
// Get base network for IP assignments
348-
let base = if let Some(base) = self.ip_network.strip_suffix(".x") {
349-
base
350-
} else if let Some(base) = self.ip_network.strip_suffix(".0/24") {
351-
base
352-
} else {
353-
return Err(ConfigError::validation(format!(
354-
"Cannot derive static reservations from IP network: {}",
355-
self.ip_network
356-
)));
357-
};
335+
let base = self.network_base()?;
358336

359337
// Generate department-specific static reservations
360338
let department = self
@@ -1041,26 +1019,26 @@ mod tests {
10411019
assert!(config.dhcp_range_start().is_err());
10421020
assert!(config.dhcp_range_end().is_err());
10431021

1044-
// Test specific error messages
1022+
// All methods delegate to network_base(), which returns a unified error
10451023
let gateway_error = config.gateway_ip().unwrap_err();
10461024
assert!(
10471025
gateway_error
10481026
.to_string()
1049-
.contains("Cannot derive gateway from IP network: invalid.network")
1027+
.contains("Cannot parse base from IP network: invalid.network")
10501028
);
10511029

10521030
let dhcp_start_error = config.dhcp_range_start().unwrap_err();
10531031
assert!(
10541032
dhcp_start_error
10551033
.to_string()
1056-
.contains("Cannot derive DHCP range from IP network: invalid.network")
1034+
.contains("Cannot parse base from IP network: invalid.network")
10571035
);
10581036

10591037
let dhcp_end_error = config.dhcp_range_end().unwrap_err();
10601038
assert!(
10611039
dhcp_end_error
10621040
.to_string()
1063-
.contains("Cannot derive DHCP range from IP network: invalid.network")
1041+
.contains("Cannot parse base from IP network: invalid.network")
10641042
);
10651043
}
10661044

src/generator/vpn.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
//! including OpenVPN, WireGuard, and IPSec tunnels for testing purposes.
55
66
use crate::model::ConfigError;
7-
use rand::Rng;
87
use rand::prelude::*;
98
use serde::{Deserialize, Serialize};
109
use std::collections::HashSet;

src/main.rs

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,6 @@ use opnsense_config_faker::cli::{Cli, Commands};
1111
fn main() -> Result<()> {
1212
let cli = Cli::parse();
1313

14-
// Set up environment with context
15-
setup_environment(&cli).context("Failed to setup CLI environment")?;
16-
1714
// Execute command with rich context
1815
match cli.command {
1916
Commands::Generate(args) => {
@@ -40,11 +37,3 @@ fn main() -> Result<()> {
4037

4138
Ok(())
4239
}
43-
44-
/// Set up the CLI environment with proper configuration
45-
fn setup_environment(cli: &Cli) -> Result<()> {
46-
// Colors are automatically disabled by checking env::var("NO_COLOR").is_ok()
47-
// and env::var("TERM") == "dumb" in the progress bar and console styling code
48-
let _ = cli; // Suppress unused parameter warnings
49-
Ok(())
50-
}

src/xml/template.rs

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ impl XmlTemplate {
2626

2727
/// Apply a VLAN configuration to generate an XML configuration
2828
pub fn apply_configuration(
29-
&mut self,
29+
&self,
3030
config: &VlanConfig,
3131
firewall_nr: u16,
3232
opt_counter: u16,
@@ -37,26 +37,27 @@ impl XmlTemplate {
3737

3838
let mut result = self.base_content.clone();
3939

40-
// Replace placeholder values (simplified version)
40+
// Replace placeholder values — all user-derived values are XML-escaped
41+
// to prevent XML injection (CWE-91) from crafted CSV input
4142
result = result.replace("{{VLAN_ID}}", &config.vlan_id.to_string());
42-
result = result.replace("{{IP_NETWORK}}", &config.ip_network);
43-
result = result.replace("{{DESCRIPTION}}", &config.description);
43+
result = result.replace("{{IP_NETWORK}}", &escape_xml_string(&config.ip_network));
44+
result = result.replace("{{DESCRIPTION}}", &escape_xml_string(&config.description));
4445
result = result.replace("{{WAN_ASSIGNMENT}}", &config.wan_assignment.to_string());
4546
result = result.replace("{{FIREWALL_NR}}", &firewall_nr.to_string());
4647
result = result.replace("{{OPT_COUNTER}}", &opt_counter.to_string());
4748

4849
// Add gateway IP if possible
4950
if let Ok(gateway) = config.gateway_ip() {
50-
result = result.replace("{{GATEWAY_IP}}", &gateway);
51+
result = result.replace("{{GATEWAY_IP}}", &escape_xml_string(&gateway));
5152
}
5253

5354
// Add DHCP range if possible
5455
if let Ok(dhcp_start) = config.dhcp_range_start() {
55-
result = result.replace("{{DHCP_START}}", &dhcp_start);
56+
result = result.replace("{{DHCP_START}}", &escape_xml_string(&dhcp_start));
5657
}
5758

5859
if let Ok(dhcp_end) = config.dhcp_range_end() {
59-
result = result.replace("{{DHCP_END}}", &dhcp_end);
60+
result = result.replace("{{DHCP_END}}", &escape_xml_string(&dhcp_end));
6061
}
6162

6263
Ok(result)
@@ -112,7 +113,7 @@ mod tests {
112113
<gateway>{{GATEWAY_IP}}</gateway>
113114
</opnsense>"#;
114115

115-
let mut template = XmlTemplate::new(xml_content.to_string()).unwrap();
116+
let template = XmlTemplate::new(xml_content.to_string()).unwrap();
116117
let config =
117118
VlanConfig::new(100, "10.1.2.x".to_string(), "Test VLAN 100".to_string(), 1).unwrap();
118119

0 commit comments

Comments
 (0)