From 1763a959856d9b2ac401da733d88c56f00e1f95a Mon Sep 17 00:00:00 2001 From: reverse Date: Wed, 19 Aug 2026 00:32:09 +0000 Subject: [PATCH] =?UTF-8?q?s2s:=20validate=20a=20message=20source=20actual?= =?UTF-8?q?ly=20lives=20behind=20the=20link=20it=20arrived=20on=20(source?= =?UTF-8?q?=5Fbehind)=20before=20applying=20remote=20JOIN/IJOIN/KICK/TOPIC?= =?UTF-8?q?/MODE/KILL/PRIVMSG=20=E2=80=94=20else=20a=20peer=20could=20forg?= =?UTF-8?q?e=20ops/kicks/bans/topics/service-badged=20messages=20for=20use?= =?UTF-8?q?rs=20behind=20another=20link;=20NICK/QUIT/PART=20already=20guar?= =?UTF-8?q?ded=20this,=20now=20the=20channel-state=20handlers=20do=20too?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/link.rs | 40 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/src/link.rs b/src/link.rs index 860473c..6d80eec 100644 --- a/src/link.rs +++ b/src/link.rs @@ -1155,6 +1155,23 @@ impl Server { self.remote_users.get(uuid).map(|ru| ru.via) == Some(via) } + /// Whether a message source `src` — a remote user uuid **or** a server sid — + /// genuinely sits behind the link `via` it arrived on. In a spanning tree a + /// line from `src` must always reach us via the next hop toward `src`; a peer + /// naming a source that lives behind a *different* link is forging it. Used to + /// gate the channel-state handlers (JOIN/KICK/TOPIC/MODE/message) the same way + /// `sourced_via` already gates NICK/QUIT/PART — except this also accepts a + /// server source, since services burst FMODE/FTOPIC/NOTICE from their SID. + fn source_behind(&self, src: &str, via: Uid) -> bool { + if let Some(ru) = self.remote_users.get(src) { + return ru.via == via; + } + if let Some(sv) = self.servers.get(src) { + return sv.via == via; + } + false + } + /// Resolve a nick collision between local user `luid` and an incoming remote /// user by timestamp: same user@ip → the OLDER changes; else the NEWER changes; /// equal TS → both. The loser is force-renamed to its UUID — locally right here @@ -1273,6 +1290,9 @@ impl Server { let Some(src) = msg.source.clone() else { return; }; + if !self.source_behind(&src, via) { + return; // reject a KILL whose source doesn't live behind this link + } let (Some(target), Some(reason)) = (msg.params.first().cloned(), msg.params.get(1).cloned()) else { @@ -1405,6 +1425,9 @@ impl Server { let Some(src) = msg.source.clone() else { return; }; + if !self.source_behind(&src, via) { + return; // don't relay a message forged from behind another link + } if msg.params.len() < 2 { return; } @@ -1588,7 +1611,8 @@ impl Server { let Some(chan) = msg.params.first().cloned() else { return; }; - if !self.remote_users.contains_key(&uuid) { + // the joiner must actually live behind the link this JOIN arrived on + if !self.sourced_via(&uuid, via) { return; } let key = chan.to_ascii_lowercase(); @@ -1617,7 +1641,7 @@ impl Server { let Some(chan) = msg.params.first().cloned() else { return; }; - if !chan.starts_with('#') || !self.remote_users.contains_key(&uuid) { + if !chan.starts_with('#') || !self.sourced_via(&uuid, via) { return; } let key = chan.to_ascii_lowercase(); @@ -1936,6 +1960,9 @@ impl Server { let Some(src) = msg.source.clone() else { return; }; + if !self.source_behind(&src, via) { + return; // a peer can't set a topic sourced from behind another link + } if msg.params.len() < 2 { return; } @@ -1972,6 +1999,9 @@ impl Server { let Some(src) = msg.source.clone() else { return; }; + if !self.source_behind(&src, via) { + return; // reject a KICK whose kicker doesn't live behind this link + } if msg.params.len() < 2 { return; } @@ -2104,10 +2134,14 @@ impl Server { // Channel modes arrive as `: FMODE <#chan> [params]` // (timestamped) or `: MODE <#chan> [params]`; user modes as // `: MODE `. Applied without re-checking privilege — the - // originating server already authorised the change. + // originating server already authorised the change, *provided* the source + // genuinely sits behind this link (else a peer could forge ops/bans). let Some(src) = msg.source.clone() else { return; }; + if !self.source_behind(&src, via) { + return; + } if msg.params.len() < 2 { return; }