Skip to content

Commit 37dd38a

Browse files
authored
Port/ICMP parameter ranges should enforce first <= last (#8578)
The console correctly prevents ranges like `3000-1000` for `L4PortRange`s, but we don't catch this in the API itself. This PR enforces that on the frontend, with some allowances for the fact that we might still have such rows in CRDB. I was torn between enforcing this using transparent types to apply the first/last constraint, which worked in terms of being invisible in the JsonSchema, versus what I did here which is just checking the invariant on conversion to a db::VpcFirewallRule. Unfortunately the former was far messier, and ended up splitting the logic such that it was a little harder to intuit what was checked and where. So, we validate this next to the filter array length checks, which felt fairly natural.
1 parent 3533116 commit 37dd38a

File tree

4 files changed

+131
-15
lines changed

4 files changed

+131
-15
lines changed

common/src/api/external/mod.rs

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1891,10 +1891,16 @@ impl From<u8> for IcmpParamRange {
18911891
}
18921892
}
18931893

1894-
impl From<RangeInclusive<u8>> for IcmpParamRange {
1895-
fn from(value: RangeInclusive<u8>) -> Self {
1896-
let (first, last) = value.into_inner();
1897-
Self { first, last }
1894+
impl TryFrom<RangeInclusive<u8>> for IcmpParamRange {
1895+
type Error = IcmpParamRangeError;
1896+
1897+
fn try_from(value: RangeInclusive<u8>) -> Result<Self, Self::Error> {
1898+
if value.is_empty() {
1899+
Err(IcmpParamRangeError::EmptyRange)
1900+
} else {
1901+
let (first, last) = value.into_inner();
1902+
Ok(Self { first, last })
1903+
}
18981904
}
18991905
}
19001906

@@ -2052,6 +2058,7 @@ impl FromStr for L4PortRange {
20522058
.parse::<NonZeroU16>()
20532059
.map_err(|e| L4PortRangeError::Value(right.into(), e))?
20542060
.into();
2061+
20552062
Ok(L4PortRange { first, last })
20562063
}
20572064
}
@@ -2064,6 +2071,8 @@ pub enum L4PortRangeError {
20642071
MissingStart,
20652072
#[error("range has no end value")]
20662073
MissingEnd,
2074+
#[error("range has larger start value than end value")]
2075+
EmptyRange,
20672076
#[error("{0:?} unparsable for type: {1}")]
20682077
Value(String, ParseIntError),
20692078
}
@@ -2132,10 +2141,16 @@ impl From<L4Port> for L4PortRange {
21322141
}
21332142
}
21342143

2135-
impl From<RangeInclusive<L4Port>> for L4PortRange {
2136-
fn from(value: RangeInclusive<L4Port>) -> Self {
2137-
let (first, last) = value.into_inner();
2138-
Self { first, last }
2144+
impl TryFrom<RangeInclusive<L4Port>> for L4PortRange {
2145+
type Error = L4PortRangeError;
2146+
2147+
fn try_from(value: RangeInclusive<L4Port>) -> Result<Self, Self::Error> {
2148+
if value.is_empty() {
2149+
Err(L4PortRangeError::EmptyRange)
2150+
} else {
2151+
let (first, last) = value.into_inner();
2152+
Ok(Self { first, last })
2153+
}
21392154
}
21402155
}
21412156

@@ -2169,6 +2184,7 @@ impl FromStr for IcmpParamRange {
21692184
let last = right
21702185
.parse::<u8>()
21712186
.map_err(|e| IcmpParamRangeError::Value(right.into(), e))?;
2187+
21722188
Ok(IcmpParamRange { first, last })
21732189
}
21742190
}
@@ -2181,6 +2197,8 @@ pub enum IcmpParamRangeError {
21812197
MissingStart,
21822198
#[error("range has no end value")]
21832199
MissingEnd,
2200+
#[error("range has larger start value than end value")]
2201+
EmptyRange,
21842202
#[error("{0:?} unparsable for type: {1}")]
21852203
Value(String, ParseIntError),
21862204
}
@@ -4125,7 +4143,7 @@ mod test {
41254143
assert_eq!(
41264144
VpcFirewallRuleProtocol::Icmp(Some(VpcFirewallIcmpFilter {
41274145
icmp_type: 60,
4128-
code: Some((0..=10).into())
4146+
code: Some((0..=10).try_into().unwrap())
41294147
})),
41304148
"icmp:60,0-10".parse().unwrap()
41314149
);

nexus/db-fixed-data/src/vpc_firewall_rule.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ pub static NEXUS_ICMP_FW_RULE: LazyLock<VpcFirewallRuleUpdate> =
106106
// Type 3 -- Destination Unreachable
107107
icmp_type: 3,
108108
// Codes 3,4 -- Port Unreachable, Fragmentation needed
109-
code: Some((3..=4).into()),
109+
code: Some((3..=4).try_into().expect("3 <= 4")),
110110
})),
111111
VpcFirewallRuleProtocol::Icmp(Some(VpcFirewallIcmpFilter {
112112
// Type 5 -- Redirect

nexus/db-model/src/vpc_firewall_rule.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,39 @@ fn ensure_max_len<T>(
211211
Ok(())
212212
}
213213

214+
fn validate_port_ranges(
215+
items: &[external::L4PortRange],
216+
) -> Result<(), external::Error> {
217+
for range in items {
218+
if range.last < range.first {
219+
return Err(external::L4PortRangeError::EmptyRange.into());
220+
}
221+
}
222+
Ok(())
223+
}
224+
225+
fn validate_protocols(
226+
items: &[external::VpcFirewallRuleProtocol],
227+
) -> Result<(), external::Error> {
228+
for proto in items {
229+
if let external::VpcFirewallRuleProtocol::Icmp(Some(
230+
external::VpcFirewallIcmpFilter {
231+
code: Some(external::IcmpParamRange { first, last }),
232+
..
233+
},
234+
)) = proto
235+
{
236+
if last < first {
237+
return Err(external::Error::invalid_value(
238+
"code",
239+
external::IcmpParamRangeError::EmptyRange.to_string(),
240+
));
241+
}
242+
}
243+
}
244+
Ok(())
245+
}
246+
214247
impl VpcFirewallRule {
215248
pub fn new(
216249
rule_id: Uuid,
@@ -232,9 +265,11 @@ impl VpcFirewallRule {
232265
}
233266
if let Some(ports) = rule.filters.ports.as_ref() {
234267
ensure_max_len(&ports, "filters.ports", MAX_FW_RULE_PARTS)?;
268+
validate_port_ranges(ports.as_slice())?;
235269
}
236270
if let Some(protocols) = rule.filters.protocols.as_ref() {
237271
ensure_max_len(&protocols, "filters.protocols", MAX_FW_RULE_PARTS)?;
272+
validate_protocols(protocols.as_slice())?;
238273
}
239274

240275
Ok(Self {

nexus/tests/integration_tests/vpc_firewall.rs

Lines changed: 68 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,12 @@ use nexus_test_utils::resource_helpers::{
1717
use nexus_test_utils_macros::nexus_test;
1818
use nexus_types::external_api::views::Vpc;
1919
use omicron_common::api::external::{
20-
IdentityMetadata, L4Port, L4PortRange, ServiceIcmpConfig, VpcFirewallRule,
21-
VpcFirewallRuleAction, VpcFirewallRuleDirection, VpcFirewallRuleFilter,
22-
VpcFirewallRuleHostFilter, VpcFirewallRulePriority,
23-
VpcFirewallRuleProtocol, VpcFirewallRuleStatus, VpcFirewallRuleTarget,
24-
VpcFirewallRuleUpdate, VpcFirewallRuleUpdateParams, VpcFirewallRules,
20+
IcmpParamRange, IdentityMetadata, L4Port, L4PortRange, ServiceIcmpConfig,
21+
VpcFirewallIcmpFilter, VpcFirewallRule, VpcFirewallRuleAction,
22+
VpcFirewallRuleDirection, VpcFirewallRuleFilter, VpcFirewallRuleHostFilter,
23+
VpcFirewallRulePriority, VpcFirewallRuleProtocol, VpcFirewallRuleStatus,
24+
VpcFirewallRuleTarget, VpcFirewallRuleUpdate, VpcFirewallRuleUpdateParams,
25+
VpcFirewallRules,
2526
};
2627
use omicron_nexus::Nexus;
2728
use std::convert::TryFrom;
@@ -591,6 +592,68 @@ async fn test_firewall_rules_max_lengths(cptestctx: &ControlPlaneTestContext) {
591592
);
592593
}
593594

595+
#[nexus_test]
596+
async fn test_firewall_rules_illegal_range(
597+
cptestctx: &ControlPlaneTestContext,
598+
) {
599+
let client = &cptestctx.external_client;
600+
601+
let project_name = "my-project";
602+
create_project(&client, &project_name).await;
603+
604+
let mut rule = VpcFirewallRuleUpdate {
605+
name: "illegal-range".parse().unwrap(),
606+
description: "".to_string(),
607+
status: VpcFirewallRuleStatus::Enabled,
608+
direction: VpcFirewallRuleDirection::Inbound,
609+
targets: vec![],
610+
filters: VpcFirewallRuleFilter {
611+
hosts: None,
612+
protocols: Some(vec![VpcFirewallRuleProtocol::Icmp(Some(
613+
VpcFirewallIcmpFilter {
614+
icmp_type: 0,
615+
code: Some(IcmpParamRange { first: 21, last: 20 }),
616+
},
617+
))]),
618+
ports: None,
619+
},
620+
action: VpcFirewallRuleAction::Allow,
621+
priority: VpcFirewallRulePriority(65534),
622+
};
623+
624+
let error = object_put_error(
625+
client,
626+
&format!("/v1/vpc-firewall-rules?vpc=default&project={}", project_name),
627+
&VpcFirewallRuleUpdateParams { rules: vec![rule.clone()] },
628+
StatusCode::BAD_REQUEST,
629+
)
630+
.await;
631+
assert_eq!(error.error_code, Some("InvalidValue".to_string()));
632+
assert_eq!(
633+
error.message,
634+
"unsupported value for \"code\": range has larger start value than end value"
635+
);
636+
637+
rule.filters.protocols = None;
638+
rule.filters.ports = Some(vec![L4PortRange {
639+
first: L4Port::try_from(1000).unwrap(),
640+
last: L4Port::try_from(900).unwrap(),
641+
}]);
642+
643+
let error = object_put_error(
644+
client,
645+
&format!("/v1/vpc-firewall-rules?vpc=default&project={}", project_name),
646+
&VpcFirewallRuleUpdateParams { rules: vec![rule] },
647+
StatusCode::BAD_REQUEST,
648+
)
649+
.await;
650+
assert_eq!(error.error_code, Some("InvalidValue".to_string()));
651+
assert_eq!(
652+
error.message,
653+
"unsupported value for \"l4_port_range\": range has larger start value than end value"
654+
);
655+
}
656+
594657
async fn icmp_rule_is_enabled(
595658
enabled: bool,
596659
datastore: &DataStore,

0 commit comments

Comments
 (0)