Harden import, the kernel task and the RTR client after review

Three independent reviews (hijack resistance, reliability, code health)
of the whole tree. This is what they found that could let a bad route in
or take the daemon down.

Hijacks:
- A route had two paths on a two-octet session, AS_PATH and AS4_PATH,
  read in two places by two rules. A peer could make the origin checked
  on import differ from the one checked at revalidation, and get an
  RPKI-invalid route into the table. AS4_PATH is now merged into AS_PATH
  once, on receipt (RFC 6793 §4.2.3), and nothing else knows of it.
- The next hop is judged on import: it must be usable, agreed on
  (RFC 8950), and by default the peer itself.
- Our own originated prefixes are not taken from other networks.
- A route for a network the machine is on is never put in the kernel.
- Paths with an AS_SET are refused (RFC 9774).
- A configured peer gets one session, not one per BGP Identifier.
- More IPv6 special-purpose prefixes are bogons.

Reliability:
- A batch of requests the kernel refused could overflow the netlink
  socket and end the daemon. The socket has room now, a lost answer or a
  silent kernel means asking what is there, and neither is fatal.
- Working the routes out again within the grace period after a restart
  no longer drops the routes being kept.
- That work can no longer be put off for ever by interface changes.
- Peers whose sessions keep failing are slowed down.
- Unused attribute sets are swept when they pile up, not only each
  minute.
- Log lines a peer can cause are said once in a while.
- An RTR answer is bounded in size and time; data cannot expire between
  two refreshes.
- The shard of a key is taken from a stirred hash: neighbouring bits of
  the hash proved alike for prefixes in a row. The test that showed it,
  failing one run in sixteen, is deterministic now.

The lab has a phase, hijacks, in which FRR tries the first three.
route::apply is a struct with methods instead of nested closures, and
the kernel task's loop is shorter.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Rayhaan JaufeerallyandClaude Fable 5.1 committed 2026-10-02 19:13:40 +02:00
1 parent fd2248feb5
commit 9ca86ede2f
34 files changed
+1696 -470

No files matched your search

+47
View File
@@ -767,6 +767,53 @@ impl AsPathAttribute {
})
}
/// How long the path is, as RFC 4271 §9.1.2.2 counts: one for each AS
/// of a sequence, one for a whole set.
pub fn hops(&self) -> usize {
self.segments
.iter()
.map(|segment| {
if segment.ordered {
segment.path.len()
} else {
1
}
})
.sum()
}
/// The path as it really is, given what a two-octet session carried:
/// this, with AS_TRANS wherever a number did not fit, and `wide`, the
/// AS4_PATH that has the numbers themselves for the path's tail
/// (RFC 6793 §4.2.3). What `wide` does not cover is kept from the
/// front of this.
///
/// `None` if `wide` is longer than the path, which is not to be used:
/// some speaker on the way that knew nothing of AS4_PATH changed the
/// one and not the other.
pub fn with_as4_path(&self, wide: &AsPathAttribute) -> Option<AsPathAttribute> {
let mut keep = self.hops().checked_sub(wide.hops())?;
let mut segments = Vec::with_capacity(self.segments.len() + wide.segments.len());
for segment in &self.segments {
if keep == 0 {
break;
}
let (hops, taken) = if segment.ordered {
let taken = segment.path.len().min(keep);
(taken, &segment.path[..taken])
} else {
(1, &segment.path[..])
};
keep -= hops;
segments.push(AsPathSegment {
ordered: segment.ordered,
path: taken.to_vec(),
});
}
segments.extend(wide.segments.iter().cloned());
Some(AsPathAttribute { segments })
}
/// Parses an AS_PATH with AS numbers as wide as the session negotiated
/// (RFC 6793).
pub fn from_wire<'a>(ctx: &ParserContext, buf: &'a [u8]) -> Parsed<'a, Self> {
+104
View File
@@ -185,6 +185,35 @@ impl UpdateMessage {
}
}
/// Makes one path of the two a session with two-octet AS numbers brings:
/// AS_PATH becomes the path as it really is, and AS4_PATH is gone, used or
/// not (RFC 6793 §4.2.3).
///
/// For whoever receives an UPDATE to do once, before anything looks at
/// the path: the checks on it, the choice between routes, validation
/// against the RPKI now and again later. Were each to read the two for
/// itself, a peer could have them disagree about where a route comes
/// from.
pub fn merge_as4_path(attributes: &mut Vec<PathAttribute>) {
let Some(at) = attributes
.iter()
.position(|attribute| matches!(attribute, PathAttribute::As4Path(_)))
else {
return;
};
let PathAttribute::As4Path(wide) = attributes.remove(at) else {
return;
};
for attribute in attributes {
if let PathAttribute::AsPath(path) = attribute {
if let Some(merged) = path.with_as4_path(&wide) {
*path = merged;
}
return;
}
}
}
/// What to do with an attribute, once it is clear.
enum Verdict {
/// Use it. `route_property` is false for one that was read only to find
@@ -908,6 +937,18 @@ mod tests {
.iter()
.any(|a| matches!(a, PathAttribute::As4Path(_)))
);
// The two made one: AS 4200000001 where AS_PATH had AS_TRANS, and
// no AS4_PATH.
let mut one_path = decoded.update.path_attributes;
merge_as4_path(&mut one_path);
assert_eq!(
one_path,
[
PathAttribute::Origin(OriginPathAttribute::IGP),
AsPathAttribute::from_asns(vec![4_200_000_001]),
PathAttribute::NextHop(NextHopPathAttribute(Ipv4Addr::new(192, 0, 2, 1))),
]
);
let bad = attr(OPTIONAL_TRANSITIVE, AS4_PATH, &[2, 1, 0xfa, 0x56]);
let decoded = UpdateMessage::decode(&ctx, &body(&[], &attributes(bad), &NLRI)).unwrap();
@@ -918,6 +959,69 @@ mod tests {
assert_eq!(decoded.update.announced_routes, [route()]);
}
/// RFC 6793 §4.2.3. AS4_PATH is the tail of the path; the front is
/// kept from AS_PATH. One longer than the path is not used, and is
/// gone all the same: nothing after the decoder is to see two paths,
/// or a peer could have them disagree about a route's origin.
#[test]
fn as4_path_is_merged_into_the_path() {
use crate::message::AsPathSegment;
let sequence = |path: &[u32]| AsPathSegment {
ordered: true,
path: path.to_vec(),
};
let set = |path: &[u32]| AsPathSegment {
ordered: false,
path: path.to_vec(),
};
let merged = |narrow: Vec<AsPathSegment>, wide: Vec<AsPathSegment>| {
let mut attributes = vec![
PathAttribute::Origin(OriginPathAttribute::IGP),
PathAttribute::As4Path(AsPathAttribute { segments: wide }),
PathAttribute::AsPath(AsPathAttribute { segments: narrow }),
];
merge_as4_path(&mut attributes);
assert_eq!(attributes.len(), 2, "AS4_PATH is gone");
match attributes.pop() {
Some(PathAttribute::AsPath(path)) => path.segments,
other => panic!("{other:?}"),
}
};
assert_eq!(
merged(
vec![sequence(&[3333, 23456, 23456])],
vec![sequence(&[210_036, 4_200_000_001])]
),
[sequence(&[3333]), sequence(&[210_036, 4_200_000_001])]
);
// As long as the path: all of it.
assert_eq!(
merged(vec![sequence(&[23456])], vec![sequence(&[210_036])]),
[sequence(&[210_036])]
);
// Longer than the path: not used. The hijack this would allow is
// a path [3333] that says it ends at someone else's AS.
assert_eq!(
merged(vec![sequence(&[3333])], vec![sequence(&[64500, 1299])]),
[sequence(&[3333])]
);
// A set counts as one, whole.
assert_eq!(
merged(
vec![sequence(&[3333]), set(&[1, 2]), sequence(&[23456])],
vec![sequence(&[210_036])]
),
[sequence(&[3333]), set(&[1, 2]), sequence(&[210_036])]
);
assert_eq!(
merged(
vec![sequence(&[3333, 23456])],
vec![set(&[210_036, 210_037])]
),
[sequence(&[3333]), set(&[210_036, 210_037])]
);
}
/// 2001:db8:1::/48 via 2001:db8::1.
fn mp_reach_v6() -> Vec<u8> {
let mut value = vec![0, 2, 1, 16];