Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Limit number of firewall rules per VPC #5914

Merged
merged 8 commits into from
Jul 25, 2024
50 changes: 43 additions & 7 deletions nexus/db-model/src/vpc_firewall_rule.rs
Original file line number Diff line number Diff line change
Expand Up @@ -211,20 +211,55 @@ pub struct VpcFirewallRule {
pub priority: VpcFirewallRulePriority,
}

/// Cap on the number of rules in a VPC
///
/// The choice of value is somewhat arbitrary, but the goal is to have a
/// large number that customers are unlikely to actually hit, but which still
/// meaningfully limits the ability to overload the DB with a single request.
const MAX_FW_RULES_PER_VPC: usize = 1024;

/// Cap on targets and on each type of filter
const MAX_FW_RULE_PARTS: usize = 128;
david-crespo marked this conversation as resolved.
Show resolved Hide resolved

fn ensure_max_len<T>(
items: &Vec<T>,
label: &str,
max: usize,
) -> Result<(), external::Error> {
if items.len() > max {
let msg = format!("max length {}", max);
return Err(external::Error::invalid_value(label, msg));
}
Ok(())
}

impl VpcFirewallRule {
pub fn new(
rule_id: Uuid,
vpc_id: Uuid,
rule: &external::VpcFirewallRuleUpdate,
) -> Self {
) -> Result<Self, external::Error> {
let identity = VpcFirewallRuleIdentity::new(
rule_id,
external::IdentityMetadataCreateParams {
name: rule.name.clone(),
description: rule.description.clone(),
},
);
Self {

ensure_max_len(&rule.targets, "targets", MAX_FW_RULE_PARTS)?;

if let Some(hosts) = rule.filters.hosts.as_ref() {
ensure_max_len(&hosts, "filters.hosts", MAX_FW_RULE_PARTS)?;
}
if let Some(ports) = rule.filters.ports.as_ref() {
ensure_max_len(&ports, "filters.ports", MAX_FW_RULE_PARTS)?;
}
if let Some(protocols) = rule.filters.protocols.as_ref() {
ensure_max_len(&protocols, "filters.protocols", MAX_FW_RULE_PARTS)?;
}
Comment on lines +250 to +260
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a customer is exceeding 2 or more of these limits, it'd be really nice to get a single error message that tells them every limit they've exceeded. WDYT?


Ok(Self {
identity,
vpc_id,
status: rule.status.into(),
Expand All @@ -248,19 +283,20 @@ impl VpcFirewallRule {
}),
action: rule.action.into(),
priority: rule.priority.into(),
}
})
}

pub fn vec_from_params(
vpc_id: Uuid,
params: external::VpcFirewallRuleUpdateParams,
) -> Result<Vec<VpcFirewallRule>, external::Error> {
ensure_no_duplicates(&params)?;
Ok(params
ensure_max_len(&params.rules, "rules", MAX_FW_RULES_PER_VPC)?;
params
.rules
.iter()
.map(|rule| VpcFirewallRule::new(Uuid::new_v4(), vpc_id, rule))
.collect())
.into_iter()
.map(|rule| VpcFirewallRule::new(Uuid::new_v4(), vpc_id, &rule))
.collect()
}
}

Expand Down
20 changes: 12 additions & 8 deletions nexus/db-queries/src/db/datastore/vpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -221,20 +221,24 @@ impl DataStore {
.map(|rule| (rule.name().clone(), rule))
.collect::<BTreeMap<_, _>>();

fw_rules.entry(DNS_VPC_FW_RULE.name.clone()).or_insert_with(|| {
VpcFirewallRule::new(
// these have to be done this way because the contructor returns a result
if !fw_rules.contains_key(&DNS_VPC_FW_RULE.name) {
let rule = VpcFirewallRule::new(
Uuid::new_v4(),
*SERVICES_VPC_ID,
&DNS_VPC_FW_RULE,
)
});
fw_rules.entry(NEXUS_VPC_FW_RULE.name.clone()).or_insert_with(|| {
VpcFirewallRule::new(
)?;
fw_rules.insert(DNS_VPC_FW_RULE.name.clone(), rule);
}

if !fw_rules.contains_key(&NEXUS_VPC_FW_RULE.name) {
let rule = VpcFirewallRule::new(
Uuid::new_v4(),
*SERVICES_VPC_ID,
&NEXUS_VPC_FW_RULE,
)
});
)?;
fw_rules.insert(NEXUS_VPC_FW_RULE.name.clone(), rule);
}

let rules = fw_rules
.into_values()
Expand Down
260 changes: 259 additions & 1 deletion nexus/tests/integration_tests/vpc_firewall.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use http::method::Method;
use http::StatusCode;
use nexus_test_utils::http_testing::{AuthnMode, NexusRequest};
use nexus_test_utils::resource_helpers::{
create_project, create_vpc, object_get, object_put_error,
create_project, create_vpc, object_get, object_put, object_put_error,
};
use nexus_test_utils_macros::nexus_test;
use nexus_types::external_api::views::Vpc;
Expand Down Expand Up @@ -321,3 +321,261 @@ async fn test_firewall_rules_same_name(cptestctx: &ControlPlaneTestContext) {
assert_eq!(error.error_code, Some("InvalidValue".to_string()));
assert_eq!(error.message, "unsupported value for \"rules\": Rule names must be unique. Duplicates: [\"dupe\"]");
}

#[nexus_test]
async fn test_firewall_rules_max_lengths(cptestctx: &ControlPlaneTestContext) {
let client = &cptestctx.external_client;

let project_name = "my-project";
create_project(&client, &project_name).await;

let base_rule = VpcFirewallRuleUpdate {
name: "my-rule".parse().unwrap(),
description: "".to_string(),
status: VpcFirewallRuleStatus::Enabled,
direction: VpcFirewallRuleDirection::Inbound,
targets: vec![],
filters: VpcFirewallRuleFilter {
hosts: None,
protocols: None,
ports: None,
},
action: VpcFirewallRuleAction::Allow,
priority: VpcFirewallRulePriority(65534),
};

let rule_n = |i: usize| VpcFirewallRuleUpdate {
name: format!("rule{}", i).parse().unwrap(),
..base_rule.clone()
};

let url =
format!("/v1/vpc-firewall-rules?vpc=default&project={}", project_name);

// fine with max count
let max_rules: usize = 1024;
let rules: VpcFirewallRules = object_put(
client,
&url,
&VpcFirewallRuleUpdateParams {
rules: (0..max_rules).map(rule_n).collect::<Vec<_>>(),
},
)
.await;
assert_eq!(rules.rules.len(), max_rules);

// fails with max + 1
let error = object_put_error(
client,
&url,
&VpcFirewallRuleUpdateParams {
rules: (0..max_rules + 1).map(rule_n).collect::<Vec<_>>(),
},
StatusCode::BAD_REQUEST,
)
.await;
assert_eq!(error.error_code, Some("InvalidValue".to_string()));
assert_eq!(
error.message,
format!("unsupported value for \"rules\": max length {max_rules}")
);

///////////////////////
// TARGETS
///////////////////////

let max_parts: usize = 128;

let target = VpcFirewallRuleTarget::Vpc("default".parse().unwrap());

// fine with max
let rules: VpcFirewallRules = object_put(
client,
&url,
&VpcFirewallRuleUpdateParams {
rules: vec![VpcFirewallRuleUpdate {
targets: (0..max_parts).map(|_i| target.clone()).collect(),
..base_rule.clone()
}],
},
)
.await;
assert_eq!(rules.rules[0].targets.len(), max_parts);

// fails with max + 1
let error = object_put_error(
client,
&url,
&VpcFirewallRuleUpdateParams {
rules: vec![VpcFirewallRuleUpdate {
targets: (0..max_parts + 1).map(|_i| target.clone()).collect(),
..base_rule.clone()
}],
},
StatusCode::BAD_REQUEST,
)
.await;
assert_eq!(error.error_code, Some("InvalidValue".to_string()));
assert_eq!(
error.message,
format!("unsupported value for \"targets\": max length {max_parts}")
);

///////////////////////
// HOST FILTERS
///////////////////////

let host = VpcFirewallRuleHostFilter::Vpc("default".parse().unwrap());

// fine with max
let rules: VpcFirewallRules = object_put(
client,
&url,
&VpcFirewallRuleUpdateParams {
rules: vec![VpcFirewallRuleUpdate {
filters: VpcFirewallRuleFilter {
hosts: Some(
(0..max_parts).map(|_i| host.clone()).collect(),
),
protocols: None,
ports: None,
},
..base_rule.clone()
}],
},
)
.await;
assert_eq!(rules.rules[0].filters.hosts.as_ref().unwrap().len(), max_parts);

// fails with max + 1
let error = object_put_error(
client,
&url,
&VpcFirewallRuleUpdateParams {
rules: vec![VpcFirewallRuleUpdate {
filters: VpcFirewallRuleFilter {
hosts: Some(
(0..max_parts + 1).map(|_i| host.clone()).collect(),
),
protocols: None,
ports: None,
},
..base_rule.clone()
}],
},
StatusCode::BAD_REQUEST,
)
.await;
assert_eq!(error.error_code, Some("InvalidValue".to_string()));
assert_eq!(
error.message,
format!(
"unsupported value for \"filters.hosts\": max length {max_parts}"
)
);

///////////////////////
// PORT FILTERS
///////////////////////

let port: L4PortRange = "1234".parse().unwrap();

// fine with max
let rules: VpcFirewallRules = object_put(
client,
&url,
&VpcFirewallRuleUpdateParams {
rules: vec![VpcFirewallRuleUpdate {
filters: VpcFirewallRuleFilter {
ports: Some((0..max_parts).map(|_i| port).collect()),
protocols: None,
hosts: None,
},
..base_rule.clone()
}],
},
)
.await;
assert_eq!(rules.rules[0].filters.ports.as_ref().unwrap().len(), max_parts);

// fails with max + 1
let error = object_put_error(
client,
&url,
&VpcFirewallRuleUpdateParams {
rules: vec![VpcFirewallRuleUpdate {
filters: VpcFirewallRuleFilter {
ports: Some((0..max_parts + 1).map(|_i| port).collect()),
protocols: None,
hosts: None,
},
..base_rule.clone()
}],
},
StatusCode::BAD_REQUEST,
)
.await;
assert_eq!(error.error_code, Some("InvalidValue".to_string()));
assert_eq!(
error.message,
format!(
"unsupported value for \"filters.ports\": max length {max_parts}"
)
);

///////////////////////
// PROTOCOL FILTERS
///////////////////////

let protocol = VpcFirewallRuleProtocol::Tcp;

// fine with max
let rules: VpcFirewallRules = object_put(
client,
&url,
&VpcFirewallRuleUpdateParams {
rules: vec![VpcFirewallRuleUpdate {
filters: VpcFirewallRuleFilter {
protocols: Some(
(0..max_parts).map(|_i| protocol).collect(),
),
ports: None,
hosts: None,
},
..base_rule.clone()
}],
},
)
.await;
assert_eq!(
rules.rules[0].filters.protocols.as_ref().unwrap().len(),
max_parts
);

// fails with max + 1
let error = object_put_error(
client,
&url,
&VpcFirewallRuleUpdateParams {
rules: vec![VpcFirewallRuleUpdate {
filters: VpcFirewallRuleFilter {
protocols: Some(
(0..max_parts + 1).map(|_i| protocol).collect(),
),
ports: None,
hosts: None,
},
..base_rule.clone()
}],
},
StatusCode::BAD_REQUEST,
)
.await;
assert_eq!(error.error_code, Some("InvalidValue".to_string()));
assert_eq!(
error.message,
format!(
"unsupported value for \"filters.protocols\": max length {max_parts}"
)
);
}
Loading