diff --git a/crates/opte-api/src/ip.rs b/crates/opte-api/src/ip.rs index 04c59140..b966cae8 100644 --- a/crates/opte-api/src/ip.rs +++ b/crates/opte-api/src/ip.rs @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. -// Copyright 2025 Oxide Computer Company +// Copyright 2026 Oxide Computer Company use super::mac::MacAddr; use crate::DomainName; @@ -527,6 +527,28 @@ impl Ipv4Addr { self.inner[0] == 169 && self.inner[1] == 254 } + /// Returns true if this is in the "this host on this network" block + /// (0.0.0.0/8). + /// + /// [RFC 1122 §3.2.1.3] allows a host to use these before it learns its + /// address, so this is broader than [`Ipv4Addr::is_unspecified`]. + /// + /// [RFC 1122 §3.2.1.3]: https://www.rfc-editor.org/rfc/rfc1122#section-3.2.1.3 + pub const fn is_this_network(&self) -> bool { + self.inner[0] == 0 + } + + /// Returns true if this is in the reserved class E block (240.0.0.0/4). + /// + /// The IANA special-purpose registry ([RFC 6890]) marks the block, + /// reserved by [RFC 1112 §4], as "Source: False". + /// + /// [RFC 6890]: https://www.rfc-editor.org/rfc/rfc6890 + /// [RFC 1112 §4]: https://www.rfc-editor.org/rfc/rfc1112#section-4 + pub const fn is_reserved(&self) -> bool { + self.inner[0] >= 240 + } + /// Return the multicast MAC address associated with this multicast IPv4 /// address. If the IPv4 address is not multicast, None will be returned. /// @@ -793,6 +815,35 @@ impl Ipv6Addr { self.inner[0] == 0xfe && (self.inner[1] & 0xc0) == 0x80 } + /// Returns a description of the embedded-IPv4 form this address takes, or + /// `None` if it embeds no IPv4 address. + /// + /// The IPv4-mapped ([RFC 4291 §2.5.5.2]) and IPv4-compatible + /// ([RFC 4291 §2.5.5.1]) forms convert to an IPv4 address and so would + /// carry IPv4 semantics past any IPv4 checks, while the NAT64 well-known + /// prefix ([RFC 6052 §2.1]) does not convert. [RFC 6052 §3.1] + /// network-specific prefixes are drawn from the operator's own address + /// space and cannot be recognized without knowing the configured prefix. + /// + /// [RFC 4291 §2.5.5.1]: https://www.rfc-editor.org/rfc/rfc4291#section-2.5.5.1 + /// [RFC 4291 §2.5.5.2]: https://www.rfc-editor.org/rfc/rfc4291#section-2.5.5.2 + /// [RFC 6052 §2.1]: https://www.rfc-editor.org/rfc/rfc6052#section-2.1 + /// [RFC 6052 §3.1]: https://www.rfc-editor.org/rfc/rfc6052#section-3.1 + pub const fn embedded_ipv4_form(&self) -> Option<&'static str> { + match self.inner { + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, ..] => { + Some("IPv4-mapped (::ffff:0:0/96, RFC 4291 §2.5.5.2)") + } + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ..] => { + Some("IPv4-compatible (::/96, RFC 4291 §2.5.5.1)") + } + [0x00, 0x64, 0xff, 0x9b, 0, 0, 0, 0, 0, 0, 0, 0, ..] => { + Some("NAT64 well-known (64:ff9b::/96, RFC 6052 §2.1)") + } + _ => None, + } + } + /// Return `true` if this is a multicast IPv6 address with the ff04::/16 prefix /// (admin-local scope with flags=0) as used by Omicron for underlay multicast. /// diff --git a/crates/opte-api/src/lib.rs b/crates/opte-api/src/lib.rs index 0ea97614..c3fef6e6 100644 --- a/crates/opte-api/src/lib.rs +++ b/crates/opte-api/src/lib.rs @@ -51,7 +51,7 @@ pub use ulp::*; /// /// We rely on CI and the check-api-version.sh script to verify that /// this number is incremented anytime the oxide-api code changes. -pub const API_VERSION: u64 = 41; +pub const API_VERSION: u64 = 42; /// Major version of the OPTE package. pub const MAJOR_VERSION: u64 = 0; diff --git a/lib/oxide-vpc/src/api.rs b/lib/oxide-vpc/src/api.rs index eb2176f2..778a3340 100644 --- a/lib/oxide-vpc/src/api.rs +++ b/lib/oxide-vpc/src/api.rs @@ -979,6 +979,67 @@ impl SourceFilter { SourceFilter::Include(s) | SourceFilter::Exclude(s) => s, } } + + /// Validate that every `Include` source is fit to serve as an (S,G) + /// source. + /// + /// Both the subscribe and forwarding paths accept operator-supplied + /// source lists, so both validate here rather than each carrying its own + /// rules. + /// + /// `Exclude` sets are left unchecked. Their entries name traffic to drop, + /// and an address that could never be a legitimate source may still be + /// one an operator wants to block. + /// + /// # Errors + /// + /// Returns a message naming the rejected address and the reason. + pub fn validate_sources(&self) -> Result<(), String> { + let SourceFilter::Include(sources) = self else { + return Ok(()); + }; + for src in sources { + if let Some(reason) = invalid_multicast_source(*src) { + return Err(format!("source filter address {src} {reason}")); + } + } + Ok(()) + } +} + +/// Returns the reason `src` is unfit to serve as a multicast (S,G) source, or +/// `None` if it is acceptable. +/// +/// The rule set matches the source validators in dpd, nexus, and mgd. Shared +/// address space (100.64.0.0/10, [RFC 6598]) is permitted on purpose: it can +/// source traffic inside an operator network. +/// +/// [RFC 6598]: https://www.rfc-editor.org/rfc/rfc6598 +fn invalid_multicast_source(src: IpAddr) -> Option { + if src.is_multicast() { + return Some("is multicast".to_string()); + } + + if src.is_unspecified() + || src.is_loopback() + || src.is_broadcast() + || src.is_link_local() + { + return Some("is a special-use address".to_string()); + } + + match src { + IpAddr::Ip4(v4) if v4.is_this_network() => { + Some("is in 0.0.0.0/8 (this host on this network)".to_string()) + } + IpAddr::Ip4(v4) if v4.is_reserved() => { + Some("is in the reserved class E block (240.0.0.0/4)".to_string()) + } + IpAddr::Ip4(_) => None, + IpAddr::Ip6(v6) => v6 + .embedded_ipv4_form() + .map(|form| format!("embeds an IPv4 address, {form}")), + } } /// Subscribe a port to a multicast group. @@ -1499,6 +1560,75 @@ impl opte::api::cmd::CmdOk for DetachSubnetResp {} pub mod tests { use super::*; + /// Build an `Include` filter holding a single source. + fn filter_with(src: IpAddr) -> SourceFilter { + SourceFilter::Include(BTreeSet::from([src])) + } + + #[test] + fn validate_sources_accepts_ordinary_unicast() { + for src in [ + IpAddr::Ip4("192.168.1.1".parse().unwrap()), + // Shared address space (100.64.0.0/10, RFC 6598) can source + // traffic inside an operator network. + IpAddr::Ip4("100.64.0.1".parse().unwrap()), + // The class E boundary is exact. + IpAddr::Ip4("223.255.255.255".parse().unwrap()), + IpAddr::Ip6("2001:db8::1".parse().unwrap()), + // A NAT64 network-specific prefix that starts with 64:ff9b but is + // not the well-known /96. + IpAddr::Ip6("64:ff9b:0:1::c000:201".parse().unwrap()), + ] { + assert!( + filter_with(src).validate_sources().is_ok(), + "{src} should be accepted as a source" + ); + } + } + + #[test] + fn validate_sources_rejects_unfit_addresses() { + for src in [ + IpAddr::Ip4("224.1.1.1".parse().unwrap()), + IpAddr::Ip4("127.0.0.1".parse().unwrap()), + IpAddr::Ip4("255.255.255.255".parse().unwrap()), + IpAddr::Ip4("169.254.1.1".parse().unwrap()), + // 0.0.0.0/8, this host on this network, RFC 1122 §3.2.1.3 + IpAddr::Ip4("0.0.0.0".parse().unwrap()), + IpAddr::Ip4("0.1.2.3".parse().unwrap()), + // 240.0.0.0/4, class E, RFC 1112 §4 + IpAddr::Ip4("240.0.0.1".parse().unwrap()), + IpAddr::Ip4("255.255.255.254".parse().unwrap()), + IpAddr::Ip6("ff0e::1".parse().unwrap()), + IpAddr::Ip6("::1".parse().unwrap()), + IpAddr::Ip6("fe80::1".parse().unwrap()), + // ::ffff:192.0.2.1, RFC 4291 §2.5.5.2 + IpAddr::Ip6("::ffff:c000:201".parse().unwrap()), + // ::192.0.2.1, RFC 4291 §2.5.5.1 + IpAddr::Ip6("::c000:201".parse().unwrap()), + // 64:ff9b::192.0.2.1, RFC 6052 §2.1 + IpAddr::Ip6("64:ff9b::c000:201".parse().unwrap()), + ] { + assert!( + filter_with(src).validate_sources().is_err(), + "{src} should be rejected as a source" + ); + } + } + + /// An `Exclude` set names traffic to drop, so its entries are not held to + /// the (S,G) source rules. An address that could never be a legitimate + /// source may still be one an operator wants to block. + #[test] + fn validate_sources_ignores_exclude_sets() { + let filter = SourceFilter::Exclude(BTreeSet::from([ + IpAddr::Ip4("0.1.2.3".parse().unwrap()), + IpAddr::Ip4("240.0.0.1".parse().unwrap()), + IpAddr::Ip6("fe80::1".parse().unwrap()), + ])); + assert!(filter.validate_sources().is_ok()); + } + #[test] fn ports_from_str_good() { assert_eq!("AnY".parse::(), Ok(Ports::Any)); diff --git a/xde/src/xde.rs b/xde/src/xde.rs index 6281f323..3b51af9a 100644 --- a/xde/src/xde.rs +++ b/xde/src/xde.rs @@ -4113,6 +4113,12 @@ fn set_mcast_forwarding_hdlr( }); } + // The aggregated source filter is operator-supplied, so it is held to + // the same rules as a subscriber's filter. + if let Err(msg) = entry.source_filter.validate_sources() { + return Err(OpteError::System { errno: EINVAL, msg }); + } + // Reject `Reserved`. It serves no replication target, so the Tx-side // selection never picks such a hop and an accepted one would silently // drop the group's traffic with no telemetry. @@ -4293,23 +4299,8 @@ fn mcast_subscribe_hdlr(env: &mut IoctlEnvelope) -> Result { } // Validate source filter: sources must contain valid unicast addresses - for src in req.filter.sources() { - if src.is_multicast() { - return Err(OpteError::BadState(format!( - "source filter address {src} is multicast" - ))); - } + req.filter.validate_sources().map_err(OpteError::BadState)?; - if src.is_unspecified() - || src.is_loopback() - || src.is_broadcast() - || src.is_link_local() - { - return Err(OpteError::BadState(format!( - "source filter address {src} is a special-use address" - ))); - } - } let group_key = match req.group { oxide_vpc::api::IpAddr::Ip6(ip6) => { // If an overlay->underlay mapping exists, use it; otherwise, if the