[main] Skip auth filter for cellnet protocol-level bye messages#4863
Conversation
Forward-port of NVIDIA#4569 (which fixed NVBug 6154369 on the 2.8 branch) to main. The cellnet protocol-level bye auth-bypass fix landed on 2.8 only; on main, validate_auth_headers() still exempts only Register/Challenge but not Bye, so any cell shutdown (e.g. `nvflare poc stop`) logs a spurious `unauthenticated msg (channel='cellnet.channel' topic='bye') ... missing client name` ERROR, and the receiver's _peer_goodbye never runs (agent cleanup waits on the heartbeat timeout instead of executing immediately). Clean cherry-pick of NVIDIA#4569: - defs.py: add CellChannel.CELLNET + CellChannelTopic.Bye constants - core_cell.py: use the canonical constants - authenticator.py: bypass auth for the direct-neighbor bye, guarded by `to_cell is not None and to_cell == destination` to prevent forged-bye attacks Cherry-picked from commit b4fb37c on the 2.8 branch.
Greptile SummaryThis PR forward-ports the cellnet
Confidence Score: 5/5Safe to merge; the change is a focused, well-tested auth-filter fix with no data-path side effects. The bypass guard is anchored to a receiver-controlled value (local_cell_fqcn) rather than attacker-controlled headers, all three registration call sites are updated, the fail-closed default (None) is correctly handled, and four unit tests directly cover the security boundary including the old TO_CELL forgery vector. No files require special attention; the implementation is straightforward and the test coverage directly targets the security-relevant branches. Important Files Changed
Sequence Diagram%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant Peer as Peer Cell (Cell.stop())
participant Filter as incoming auth filter (validate_auth_headers)
participant CoreCell as CoreCell
participant PeerGoodbye as _peer_goodbye
Peer->>Filter: "bye msg [DESTINATION=this cell, CHANNEL=cellnet.channel]"
Filter->>Filter: "topic==Bye AND channel==CELLNET?"
Filter->>Filter: local_cell_fqcn is not None?
Filter->>Filter: "DESTINATION == local_cell_fqcn?"
Filter-->>CoreCell: return None (bypass auth)
CoreCell->>PeerGoodbye: dispatch (agent cleanup fires immediately)
Note over Peer,PeerGoodbye: Forged / forwarded bye path
Peer->>Filter: "bye msg [DESTINATION=other-cell, CHANNEL=cellnet.channel]"
Filter->>Filter: "DESTINATION != local_cell_fqcn, fall through"
Filter-->>CoreCell: return UNAUTHENTICATED reply
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant Peer as Peer Cell (Cell.stop())
participant Filter as incoming auth filter (validate_auth_headers)
participant CoreCell as CoreCell
participant PeerGoodbye as _peer_goodbye
Peer->>Filter: "bye msg [DESTINATION=this cell, CHANNEL=cellnet.channel]"
Filter->>Filter: "topic==Bye AND channel==CELLNET?"
Filter->>Filter: local_cell_fqcn is not None?
Filter->>Filter: "DESTINATION == local_cell_fqcn?"
Filter-->>CoreCell: return None (bypass auth)
CoreCell->>PeerGoodbye: dispatch (agent cleanup fires immediately)
Note over Peer,PeerGoodbye: Forged / forwarded bye path
Peer->>Filter: "bye msg [DESTINATION=other-cell, CHANNEL=cellnet.channel]"
Filter->>Filter: "DESTINATION != local_cell_fqcn, fall through"
Filter-->>CoreCell: return UNAUTHENTICATED reply
Reviews (4): Last reviewed commit: "Merge branch 'main' into fix/cellnet-bye..." | Re-trigger Greptile |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #4863 +/- ##
==========================================
+ Coverage 60.73% 60.75% +0.01%
==========================================
Files 977 977
Lines 93135 93139 +4
==========================================
+ Hits 56568 56589 +21
+ Misses 36567 36550 -17
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
YuanTingHsieh
left a comment
There was a problem hiding this comment.
The forward-port itself is faithful and the mechanical parts are clean: the authenticator.py block is byte-identical to the 2.8 original b4fb37c4, the constant values are preserved ("cellnet.channel"/"bye") so registered callbacks still match, there are no leftover references to the removed _CHANNEL/_TOPIC_BYE constants, imports are present, and in-scope unit tests pass.
One thing worth addressing while we're touching this guard — flagging rather than hard-blocking, since it's pre-existing in the shipped 2.8 fix, not introduced here (see the inline comment). In short: the bye auth-bypass guard checks to_cell == destination, which compares two sender-controlled headers to each other and never verifies the message actually terminates at the receiving cell, so it doesn't achieve the "direct-neighbor only" property its own comment claims. This is the right moment to tighten it rather than port the gap verbatim — and 2.8 would want the same change.
Addresses @YuanTingHsieh's review on NVIDIA#4863: the bye auth-bypass guard compared two sender-controlled headers (TO_CELL == DESTINATION) and never verified the message actually terminates at the receiving cell, so it did not enforce the "direct-neighbor only" property its comment claimed. A peer that can inject into the incoming pipeline without a valid FL token could craft `topic=bye, TO_CELL=X, DESTINATION=X` for another cell X; the guard passed it, and since DESTINATION != this cell's FQCN the message was then FORWARDED to X, whose _peer_goodbye evicts its upstream agent — an unauthenticated peer-eviction / routing disruption. Fix: bypass auth only when DESTINATION == this cell's own FQCN. The in-filter runs before CoreCell's forward decision (`if destination != my_fqcn: forward`), so a bye addressed to this cell is handled locally and never forwarded, while a bye naming another cell falls through to normal auth. Legitimate byes are always direct-neighbor (Cell.stop() broadcasts to directly-connected agents, so DESTINATION is the receiving cell), so this never breaks a real bye. validate_auth_headers() gains a local_cell_fqcn parameter, supplied at all three call sites (server via self.cell, relay via my_fqcn, admin HCI client via self.cell). When it is None the bypass fails closed. Added unit tests: a bye terminating here is bypassed; a forwarded/forged bye (incl. one with TO_CELL == DESTINATION pointing at another cell) is rejected; no local FQCN fails closed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two existing test doubles didn't provide the cell FQCN that validate_auth_headers now reads: - fed_server.py: use getattr(self, "cell", None) so the auth filter is robust when the server cell isn't set yet (authn_test builds a FederatedServer via __new__ without __init__, so it has no .cell attribute). - admin_api_test.py: give the _FakeCell a get_fqcn() (the real Cell has one). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
The fix itself looks good — I verified the guard end-to-end: the incoming filter runs before CoreCell's forward decision (so crafted byes addressed to other cells are rejected mid-transit), One thing to fix before merge: the PR description no longer matches the code. It says the bypass is "guarded by Related follow-up (not this PR): since main's guard is now strictly stronger, the 2.8 branch's shipped |
|
Thanks @YuanTingHsieh! Updated the PR description to match the shipped code: it now documents the actual guard ( Agreed on the follow-up — I'll back-port the same |
Forward-port of #4569 (which fixed NVBug 6154369 on the 2.8 branch) to main, plus a security tightening of the bye auth-bypass guard (per review).
On main,
validate_auth_headers()still exempts only Register/Challenge, not Bye, so any cell shutdown (e.g.nvflare poc stop) logs a spuriousunauthenticated msg (channel='cellnet.channel' topic='bye') ... missing client nameERROR, and the receiver's_peer_goodbyenever runs (agent cleanup waits on the heartbeat timeout instead of firing immediately).Changes
defs.py: addCellChannel.CELLNET+CellChannelTopic.Byecanonical constants (from [2.8] Skip auth filter for cellnet protocol-level bye messages #4569).core_cell.py: use the canonical constants instead of local string literals (from [2.8] Skip auth filter for cellnet protocol-level bye messages #4569).authenticator.py: bypass auth for a cellnetbyeonly when the message actually terminates at this cell, i.e.DESTINATION == local_cell_fqcn.validate_auth_headers()gains alocal_cell_fqcnparameter, supplied at all three call sites (server viaself.cell, relay viamy_fqcn, admin HCI client viaself.cell); it fails closed when the FQCN is unknown.Why the guard differs from the 2.8 original
The shipped 2.8 fix (
b4fb37c4) guarded the bypass withto_cell == destination. That compares two sender-controlled headers to each other and never checks that the message terminates here — and because_forwardrewritesTO_CELLat each hop, the equality also holds at the terminal hop of a forwarded bye, so a crafted bye addressed to another cell could still slip through and evict that cell's upstream agent. This PR replaces that guard withDESTINATION == local_cell_fqcn: the incoming filter runs before CoreCell's forward decision, so a bye whoseDESTINATIONis this cell is handled locally and never forwarded, while one naming another cell falls through to normal auth. Legitimate byes are always direct-neighbor (Cell.stop()broadcasts to directly-connected agents, soDESTINATIONis the receiving cell), so real byes are unaffected — verified end-to-end (the "missing client name" ERROR stays gone).Tests
authenticator_test.py: a bye terminating here is bypassed; a forwarded/forged bye (including one withTO_CELL == DESTINATIONpointing at another cell) is rejected; no local FQCN fails closed.__new__-builtFederatedServer, HCI_FakeCell) that didn't expose the cell FQCN the guard now reads.Follow-up (not this PR): main's guard is now strictly stronger than the 2.8 branch's shipped
to_cell == destination; the same strengthening should be back-ported to 2.8.Types of changes