Files
ferrix/crates/packet/tests/properties.rs
T
Rayhaan JaufeerallyandClaude Fable 5.1 6197a7ad74 Handle errors in UPDATE messages as RFC 7606 says, not by resetting
Any malformed UPDATE used to reset the session, withdrawing every route
learned over it. One bad attribute, typically originated ASes away and
passed on by a peer that did not look at it, could take down a session
carrying a full table.

UpdateMessage::decode now applies RFC 7606 and returns the UPDATE as it is
to be acted on, plus a report of what was wrong:

- Treat-as-withdraw: a malformed or misflagged attribute, a missing
  mandatory one, or an attribute that overruns the attribute list. The
  routes the UPDATE announced (in the body and in MP_REACH_NLRI) are
  withdrawn.
- Attribute discard: malformed ATOMIC_AGGREGATE, AGGREGATOR, AS4_PATH and
  AS4_AGGREGATOR; an attribute sent twice (the first is kept); LOCAL_PREF,
  ORIGINATOR_ID and CLUSTER_LIST from an external peer; AS4_PATH between
  four-octet speakers.
- Session reset remains where the prefixes are in doubt: the lengths that
  frame the message, an unreadable prefix, a malformed or repeated
  MP_REACH_NLRI or MP_UNREACH_NLRI, an unrecognised well-known attribute.
  Each now sends its own subcode instead of always Malformed Attribute
  List.

The session logs each handled error and counts it in
bgp_update_errors_total{handling}. The parser context says whether the
peer is internal (replacing a field nothing read).

Stricter than before, because the checks did not exist:
- An announcement must carry ORIGIN and AS_PATH, NEXT_HOP for routes in the
  body, and LOCAL_PREF from an internal peer.
- An AS_PATH segment must be an AS_SET or AS_SEQUENCE and not empty.
- An attribute value must be used up entirely by its parser; a NEXT_HOP of
  six octets, say, was accepted before.

Bugs found on the way: we sent ATOMIC_AGGREGATE flagged optional and
AGGREGATOR flagged non-transitive. The flags now come from one table used
for both writing and checking.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-01 23:12:33 +02:00

648 lines
23 KiB
Rust

//! Property tests for the wire codec.
//!
//! Two things are checked for every kind of message:
//!
//! - **Round trip.** A message that can be written can be read back, comes
//! back equal, and is exactly as long as `wire_len` said.
//! - **Hostile input.** Whatever bytes arrive, decoding returns a message or
//! an error: it never panics. What it does accept can be shown, written
//! again, and read back as the same message.
//!
//! The second is what a peer can do to us, so it is run on three kinds of
//! input: plain noise, noise behind a valid header (so it reaches the body
//! parsers), and valid messages with a few bytes damaged (so it reaches deep
//! into them).
use std::net::{Ipv4Addr, Ipv6Addr};
use bgp_packet::errors::BgpError;
use bgp_packet::message::{
AggregatorPathAttribute, AsPathAttribute, AsPathSegment, AtomicAggregatePathAttribute,
BGP_HEADER_LEN, BGP_MAX_MSG_LEN, CommunitiesPathAttribute, ExtendedCommunitiesPathAttribute,
ExtendedCommunity, LargeCommunitiesPathAttribute, LargeCommunity, LocalPrefPathAttribute,
Message, MpReachNlriPathAttribute, MpUnreachNlriPathAttribute, MultiExitDiscPathAttribute,
NextHopPathAttribute, NlriNextHop, NotificationMessage, OriginPathAttribute, PathAttribute,
PathAttributeFlags, RouteRefreshMessage, UpdateMessage,
};
use bgp_packet::open::{
AddPathSendRecv, BgpRole, BgpSecHeader, Capability, ExtendedNextHop, GracefulRestartEntry,
LongLivedGracefulRestart, MultiLabelEntry, OpenMessage, OpenOption, OrfSendRecv, OrfType,
OutboundRouteFilterEntry,
};
use bgp_primitives::constants::{AddressFamilyId, Afi, Safi};
use bgp_primitives::ip_prefix::IpPrefix;
use bgp_primitives::parser::{EncodeError, ParserContext};
use bytes::BytesMut;
use proptest::collection::vec;
use proptest::prelude::*;
/// What a session settled that the codec depends on.
#[derive(Debug, Clone, Copy)]
struct Session {
four_octet_asn: bool,
ibgp: bool,
}
impl Session {
fn ctx(self) -> ParserContext {
ParserContext {
four_octet_asn: Some(self.four_octet_asn),
ibgp: self.ibgp,
}
}
}
fn session() -> impl Strategy<Value = Session> {
any::<(bool, bool)>().prop_map(|(four_octet_asn, ibgp)| Session {
four_octet_asn,
ibgp,
})
}
// ---------------------------------------------------------------------------
// Generators
// ---------------------------------------------------------------------------
fn prefix(version: AddressFamilyId) -> impl Strategy<Value = IpPrefix> {
let max_len = match version {
AddressFamilyId::Ipv4 => 32u8,
AddressFamilyId::Ipv6 => 128,
};
(0..=max_len, any::<[u8; 16]>()).prop_map(move |(length, bytes)| {
IpPrefix::new(version, bytes.to_vec(), length).expect("16 bytes are enough for any prefix")
})
}
fn afi() -> impl Strategy<Value = Afi> {
any::<u16>().prop_map(Afi)
}
fn safi() -> impl Strategy<Value = Safi> {
any::<u8>().prop_map(Safi)
}
/// AS numbers that fit the session: any on a four-octet one, two-octet ones
/// otherwise (wider ones are replaced by AS_TRANS on the wire, by design).
fn asn(four_octet_asn: bool) -> BoxedStrategy<u32> {
if four_octet_asn {
any::<u32>().boxed()
} else {
any::<u16>().prop_map(u32::from).boxed()
}
}
fn as_path(asn: BoxedStrategy<u32>) -> impl Strategy<Value = AsPathAttribute> {
// A segment has at least one AS number.
vec((any::<bool>(), vec(asn, 1..6)), 0..4).prop_map(|segments| AsPathAttribute {
segments: segments
.into_iter()
.map(|(ordered, path)| AsPathSegment { ordered, path })
.collect(),
})
}
/// Extended communities are classified by their type octets when read, so
/// they are generated from the eight octets they are on the wire.
fn extended_community() -> impl Strategy<Value = ExtendedCommunity> {
any::<[u8; 8]>().prop_map(|bytes| {
ExtendedCommunity::from_wire(&ParserContext::default(), &bytes)
.expect("any eight octets are an extended community")
.1
})
}
fn mp_reach() -> impl Strategy<Value = MpReachNlriPathAttribute> {
let ip_safi = prop_oneof![Just(Safi::UNICAST), Just(Safi::MULTICAST)];
let v4 = (
ip_safi.clone(),
any::<[u8; 4]>(),
vec(prefix(AddressFamilyId::Ipv4), 0..5),
)
.prop_map(|(safi, next_hop, prefixes)| MpReachNlriPathAttribute {
afi: Afi::IPV4,
safi,
next_hop: NlriNextHop::Ipv4(Ipv4Addr::from(next_hop)),
prefixes,
});
let v6_next_hop = prop_oneof![
any::<[u8; 16]>().prop_map(|a| NlriNextHop::Ipv6(Ipv6Addr::from(a))),
(any::<[u8; 16]>(), any::<[u8; 16]>()).prop_map(|(global, link_local)| {
NlriNextHop::Ipv6WithLl {
global: Ipv6Addr::from(global),
link_local: Ipv6Addr::from(link_local),
}
}),
];
let v6 = (
ip_safi,
v6_next_hop,
vec(prefix(AddressFamilyId::Ipv6), 0..5),
)
.prop_map(|(safi, next_hop, prefixes)| MpReachNlriPathAttribute {
afi: Afi::IPV6,
safi,
next_hop,
prefixes,
});
prop_oneof![v4, v6]
}
fn mp_unreach() -> impl Strategy<Value = MpUnreachNlriPathAttribute> {
let of = |version: AddressFamilyId| {
vec(prefix(version), 0..5).prop_map(move |prefixes| MpUnreachNlriPathAttribute {
afi: version.into(),
safi: Safi::UNICAST,
prefixes,
})
};
prop_oneof![of(AddressFamilyId::Ipv4), of(AddressFamilyId::Ipv6)]
}
/// Type codes whose definition the decoder knows; an unknown attribute has
/// none of these.
const DEFINED_ATTRIBUTES: [u8; 16] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 14, 15, 16, 17, 18, 32];
/// Unknown attributes, each with a type code of its own: a repeated
/// attribute is dropped by the decoder.
fn unknown_attributes() -> impl Strategy<Value = Vec<PathAttribute>> {
proptest::collection::btree_map(
any::<u8>().prop_filter("a type code without a definition", |code| {
!DEFINED_ATTRIBUTES.contains(code)
}),
(any::<u8>(), vec(any::<u8>(), 0..300)),
0..3,
)
.prop_map(|attributes| {
attributes
.into_iter()
.map(|(type_code, (flags, payload))| {
// Optional (bit 7): an unknown well-known attribute is an
// error. Bit 4 selects the two-octet length, which a payload
// this long needs.
let mut flags = flags | 0x80;
if payload.len() > 255 {
flags |= 0x10;
}
PathAttribute::UnknownPathAttribute {
flags: PathAttributeFlags(flags),
type_code,
payload,
}
})
.collect()
})
}
/// The attributes of a well-formed UPDATE on `session`: the mandatory ones,
/// and any of the others at most once.
fn path_attributes(session: Session) -> impl Strategy<Value = Vec<PathAttribute>> {
let mandatory = (
prop_oneof![
Just(OriginPathAttribute::IGP),
Just(OriginPathAttribute::EGP),
Just(OriginPathAttribute::INCOMPLETE)
],
as_path(asn(session.four_octet_asn)),
any::<[u8; 4]>(),
any::<u32>(),
)
.prop_map(move |(origin, as_path, next_hop, local_pref)| {
let mut attributes = vec![
PathAttribute::Origin(origin),
PathAttribute::AsPath(as_path),
PathAttribute::NextHop(NextHopPathAttribute(Ipv4Addr::from(next_hop))),
];
// Mandatory between internal peers, dropped from external ones.
if session.ibgp {
attributes.push(PathAttribute::LocalPref(LocalPrefPathAttribute(local_pref)));
}
attributes
});
let optional = (
proptest::option::of(
any::<u32>().prop_map(|v| PathAttribute::MultiExitDisc(MultiExitDiscPathAttribute(v))),
),
proptest::option::of(Just(PathAttribute::AtomicAggregate(
AtomicAggregatePathAttribute {},
))),
proptest::option::of((asn(session.four_octet_asn), any::<[u8; 4]>()).prop_map(
|(asn, ip)| {
PathAttribute::Aggregator(AggregatorPathAttribute {
asn,
ip: Ipv4Addr::from(ip),
})
},
)),
proptest::option::of(
vec(any::<(u16, u16)>(), 1..8)
.prop_map(|c| PathAttribute::Communities(CommunitiesPathAttribute(c))),
),
proptest::option::of(mp_reach().prop_map(PathAttribute::MpReachNlri)),
proptest::option::of(mp_unreach().prop_map(PathAttribute::MpUnreachNlri)),
proptest::option::of(
vec(extended_community(), 1..6).prop_map(|extended_communities| {
PathAttribute::ExtendedCommunities(ExtendedCommunitiesPathAttribute {
extended_communities,
})
}),
),
// AS4_PATH is four octets wide, and only a two-octet speaker sends
// it.
proptest::option::of(as_path(any::<u32>().boxed()).prop_map(PathAttribute::As4Path))
.prop_map(move |as4_path| as4_path.filter(|_| !session.four_octet_asn)),
proptest::option::of(vec(any::<(u32, u32, u32)>(), 1..6).prop_map(|communities| {
PathAttribute::LargeCommunities(LargeCommunitiesPathAttribute {
communities: communities
.into_iter()
.map(|(global_admin, data_1, data_2)| LargeCommunity {
global_admin,
data_1,
data_2,
})
.collect(),
})
})),
unknown_attributes(),
);
(mandatory, optional).prop_map(|(mut attributes, optional)| {
let (med, atomic, aggregator, communities, reach, unreach, extended, as4, large, unknown) =
optional;
attributes.extend(
[
med,
atomic,
aggregator,
communities,
reach,
unreach,
extended,
as4,
large,
]
.into_iter()
.flatten(),
);
attributes.extend(unknown);
attributes
})
}
fn update(session: Session) -> impl Strategy<Value = UpdateMessage> {
(
vec(prefix(AddressFamilyId::Ipv4), 0..5),
path_attributes(session),
vec(prefix(AddressFamilyId::Ipv4), 0..5),
)
.prop_map(
|(withdrawn_routes, path_attributes, announced_routes)| UpdateMessage {
withdrawn_routes,
path_attributes,
announced_routes,
},
)
}
/// Capability codes that have their own variant.
const KNOWN_CAPABILITIES: [u8; 13] = [1, 2, 3, 5, 6, 7, 8, 9, 64, 65, 69, 70, 71];
fn capability() -> impl Strategy<Value = Capability> {
let orf = (
any::<u8>().prop_map(OrfType::from),
prop_oneof![
Just(OrfSendRecv::Receive),
Just(OrfSendRecv::Send),
Just(OrfSendRecv::Both)
],
)
.prop_map(|(r#type, send_recv)| OutboundRouteFilterEntry { r#type, send_recv });
let add_path = prop_oneof![
Just(AddPathSendRecv::Receive),
Just(AddPathSendRecv::Send),
Just(AddPathSendRecv::Both)
];
prop_oneof![
(afi(), safi()).prop_map(|(afi, safi)| Capability::MultiProtocol { afi, safi }),
Just(Capability::RouteRefresh),
(afi(), safi(), vec(orf, 0..4))
.prop_map(|(afi, safi, orfs)| Capability::OutboundRouteFilter { afi, safi, orfs }),
vec((afi(), safi(), afi()), 1..4).prop_map(|entries| Capability::ExtendedNextHop {
entries: entries
.into_iter()
.map(|(afi, safi, nh_afi)| ExtendedNextHop { afi, safi, nh_afi })
.collect(),
}),
Just(Capability::ExtendedMessage),
(any::<u8>(), afi()).prop_map(|(header, afi)| Capability::BgpSec {
header: BgpSecHeader(header),
afi,
}),
vec((afi(), safi(), any::<u8>()), 1..4).prop_map(|entries| {
Capability::MultiLabelCompat {
entries: entries
.into_iter()
.map(|(afi, safi, count)| MultiLabelEntry { afi, safi, count })
.collect(),
}
}),
any::<u8>().prop_map(|role| Capability::BgpRole {
role: BgpRole::from(role),
}),
(
any::<bool>(),
0u16..4096,
vec((afi(), safi(), any::<bool>()), 0..4)
)
.prop_map(|(restart_state, restart_time, entries)| {
Capability::GracefulRestart {
restart_state,
restart_time,
entries: entries
.into_iter()
.map(|(afi, safi, preserve_forwarding)| GracefulRestartEntry {
afi,
safi,
preserve_forwarding,
})
.collect(),
}
}),
any::<u32>().prop_map(|asn| Capability::FourByteAsn { asn }),
(afi(), safi(), add_path).prop_map(|(afi, safi, send_recv)| Capability::AddPath {
afi,
safi,
send_recv,
}),
Just(Capability::EnhancedRouteRefresh),
vec((afi(), safi(), any::<u8>(), 0u32..(1 << 24)), 0..4).prop_map(|entries| {
Capability::LongLivedGracefulRestart(
entries
.into_iter()
.map(|(afi, safi, flags, stale_time)| LongLivedGracefulRestart {
afi,
safi,
flags,
stale_time,
})
.collect(),
)
}),
(
any::<u8>().prop_filter("a code without a variant", |code| {
!KNOWN_CAPABILITIES.contains(code)
}),
vec(any::<u8>(), 0..20)
)
.prop_map(|(type_code, value)| Capability::UnknownCapability {
type_code,
length: value.len() as u8,
value,
}),
]
}
fn open() -> impl Strategy<Value = OpenMessage> {
(
any::<u16>(),
any::<u16>(),
any::<[u8; 4]>(),
// Enough to go past 255 octets of options now and then, which
// switches to the extended length of RFC 9072.
vec(vec(capability(), 0..10), 0..8),
)
.prop_map(|(asn, hold_time, identifier, options)| OpenMessage {
version: 4,
asn,
hold_time,
identifier: Ipv4Addr::from(identifier),
options: options.into_iter().map(OpenOption::Capabilities).collect(),
})
}
fn notification() -> impl Strategy<Value = NotificationMessage> {
(1u8..=8, 0u8..12, vec(any::<u8>(), 0..40)).prop_filter_map(
"an error code and subcode that are defined",
|(code, subcode, data)| {
BgpError::from_code_subcode(code, subcode)
.ok()
.map(|error| NotificationMessage { error, data })
},
)
}
fn message(session: Session) -> impl Strategy<Value = Message> {
prop_oneof![
open().prop_map(Message::Open),
update(session).prop_map(Message::Update),
notification().prop_map(Message::Notification),
Just(Message::KeepAlive),
(afi(), any::<u8>(), safi()).prop_map(|(afi, subtype, safi)| {
Message::RouteRefresh(RouteRefreshMessage { afi, subtype, safi })
}),
]
}
/// A session, and a well-formed message that fits it.
fn session_and_message() -> impl Strategy<Value = (Session, Message)> {
session().prop_flat_map(|session| (Just(session), message(session)))
}
// ---------------------------------------------------------------------------
// Properties
// ---------------------------------------------------------------------------
/// Decodes `wire` and checks what must hold for anything the decoder
/// accepts: it can be shown and written, and unless the decoder had to
/// repair it, it reads back as itself.
fn check_decoding(ctx: &ParserContext, wire: &[u8]) -> Result<(), TestCaseError> {
let Ok((msg, repairs)) = Message::decode_with_report(ctx, wire) else {
return Ok(());
};
// Display of what a peer sent must not be able to panic: it is logged.
let _ = format!("{msg:?} {repairs:?}");
if let Message::Update(update) = &msg {
for prefix in update
.withdrawn_routes
.iter()
.chain(&update.announced_routes)
{
let _ = prefix.to_string();
}
for attribute in &update.path_attributes {
match attribute {
PathAttribute::MpReachNlri(mp) => {
mp.prefixes.iter().for_each(|p| drop(p.to_string()))
}
PathAttribute::MpUnreachNlri(mp) => {
mp.prefixes.iter().for_each(|p| drop(p.to_string()))
}
_ => {}
}
}
}
let mut rewritten = BytesMut::new();
match msg.to_wire(ctx, &mut rewritten) {
Ok(()) => {}
// Withdrawing what a full-size UPDATE announced can need more room
// than one message has. The result is acted on, never sent.
Err(EncodeError::TooLong { .. }) if !repairs.is_empty() => return Ok(()),
Err(e) => {
return Err(TestCaseError::fail(format!(
"accepted but cannot be written: {e}"
)));
}
}
prop_assert_eq!(usize::from(msg.wire_len(ctx).unwrap()), rewritten.len());
if repairs.is_empty() {
let (again, repairs) = Message::decode_with_report(ctx, &rewritten)
.map_err(|e| TestCaseError::fail(format!("written but cannot be read: {e}")))?;
prop_assert!(repairs.is_empty(), "{:?}", repairs);
prop_assert_eq!(again, msg);
}
Ok(())
}
/// Writes a generated message. `None` when it is too big for one BGP
/// message, which the generators can produce and the encoder must refuse;
/// any other refusal is a bug, since everything generated is meant to fit
/// the session.
fn written(ctx: &ParserContext, msg: &Message) -> Result<Option<BytesMut>, TestCaseError> {
let mut wire = BytesMut::new();
match msg.to_wire(ctx, &mut wire) {
Ok(()) => Ok(Some(wire)),
Err(EncodeError::TooLong { .. }) => Ok(None),
Err(e) => Err(TestCaseError::fail(format!("cannot be written: {e}"))),
}
}
fn framed(msg_type: u8, body: &[u8]) -> Vec<u8> {
let mut wire = vec![0xff; 16];
let length = u16::try_from(usize::from(BGP_HEADER_LEN) + body.len()).unwrap();
wire.extend_from_slice(&length.to_be_bytes());
wire.push(msg_type);
wire.extend_from_slice(body);
wire
}
/// Ways to damage a message.
#[derive(Debug, Clone)]
enum Damage {
Set { at: prop::sample::Index, to: u8 },
Remove { at: prop::sample::Index },
Insert { at: prop::sample::Index, byte: u8 },
Truncate { to: prop::sample::Index },
}
fn damage() -> impl Strategy<Value = Damage> {
prop_oneof![
(any::<prop::sample::Index>(), any::<u8>()).prop_map(|(at, to)| Damage::Set { at, to }),
any::<prop::sample::Index>().prop_map(|at| Damage::Remove { at }),
(any::<prop::sample::Index>(), any::<u8>())
.prop_map(|(at, byte)| Damage::Insert { at, byte }),
any::<prop::sample::Index>().prop_map(|to| Damage::Truncate { to }),
]
}
/// Applies `damage` to the body of `wire` and makes the header's length
/// right again, so that the damage is the body parsers' problem.
fn damaged(wire: &[u8], damage: &[Damage]) -> Vec<u8> {
let header = usize::from(BGP_HEADER_LEN);
let msg_type = wire[header - 1];
let mut body = wire[header..].to_vec();
for d in damage {
match d {
Damage::Set { at, to } if !body.is_empty() => {
let i = at.index(body.len());
body[i] = *to;
}
Damage::Remove { at } if !body.is_empty() => {
body.remove(at.index(body.len()));
}
Damage::Insert { at, byte } => body.insert(at.index(body.len() + 1), *byte),
Damage::Truncate { to } if !body.is_empty() => body.truncate(to.index(body.len())),
_ => {}
}
}
body.truncate(usize::from(BGP_MAX_MSG_LEN - BGP_HEADER_LEN));
framed(msg_type, &body)
}
/// 2000 cases a property, which takes about a second. For a longer hunt
/// set `PROPTEST_CASES`, e.g. `PROPTEST_CASES=1000000 cargo test --release
/// -p bgp-packet --test properties`.
fn config() -> ProptestConfig {
let cases = std::env::var("PROPTEST_CASES")
.ok()
.and_then(|cases| cases.parse().ok())
.unwrap_or(2000);
ProptestConfig {
cases,
// Failures are not saved to a file: one that is found becomes a
// named test next to the code it broke.
failure_persistence: None,
..ProptestConfig::default()
}
}
proptest! {
#![proptest_config(config())]
#[test]
fn a_message_that_can_be_written_reads_back_equal((session, msg) in session_and_message()) {
let ctx = session.ctx();
let Some(wire) = written(&ctx, &msg)? else {
return Ok(());
};
prop_assert_eq!(usize::from(msg.wire_len(&ctx).unwrap()), wire.len());
let (decoded, repairs) = Message::decode_with_report(&ctx, &wire)
.map_err(|e| TestCaseError::fail(format!("{e}")))?;
// What we write is well formed: nothing for the decoder to repair.
prop_assert!(repairs.is_empty(), "{:?}", repairs);
prop_assert_eq!(&decoded, &msg);
let (rest, _) = Message::from_wire(&ctx, &wire).unwrap();
prop_assert!(rest.is_empty());
}
#[test]
fn noise_is_never_more_than_an_error(session in session(), bytes in vec(any::<u8>(), 0..200)) {
check_decoding(&session.ctx(), &bytes)?;
}
#[test]
fn noise_behind_a_valid_header_is_never_more_than_an_error(
session in session(),
msg_type in 0u8..7,
body in vec(any::<u8>(), 0..300),
) {
check_decoding(&session.ctx(), &framed(msg_type, &body))?;
}
#[test]
fn a_damaged_message_is_never_more_than_an_error(
(session, msg) in session_and_message(),
damage in vec(damage(), 1..4),
) {
let ctx = session.ctx();
let Some(wire) = written(&ctx, &msg)? else {
return Ok(());
};
check_decoding(&ctx, &damaged(&wire, &damage))?;
}
/// The parsers below the message level are public too.
#[test]
fn noise_is_never_more_than_an_error_for_the_parts(
session in session(),
bytes in vec(any::<u8>(), 0..120),
) {
let ctx = session.ctx();
let _ = OpenMessage::from_wire(&ctx, &bytes);
let _ = OpenOption::from_wire(&ctx, &bytes);
let _ = Capability::from_wire(&ctx, &bytes);
let _ = UpdateMessage::decode(&ctx, &bytes);
let _ = PathAttribute::from_wire(&ctx, &bytes);
let _ = NotificationMessage::from_wire(&ctx, &bytes);
}
}