onionmessage: drop onion messages cycling back to the sending peer

Block forwarding of an onion message when the resolved next hop is the
same peer that delivered it. Such a forward would immediately bounce the
message back over the very connection it arrived on, which is never
useful and can be abused to amplify traffic against a peer.

The check runs after the routing action is resolved, so both direct
next-node-ID and SCID-resolved paths are covered. A new
`ErrSamePeerCycle` is returned (and logged at warn level) when a cycle
is detected.

(cherry picked from commit 261babcf09)
This commit is contained in:
Gijs van Dam 2026-04-17 12:21:16 +02:00 committed by github-actions[bot]
parent ad841a7fb3
commit 405163961c
3 changed files with 130 additions and 0 deletions

View file

@ -188,6 +188,32 @@ func (a *OnionPeerActor) Receive(ctx context.Context,
return fn.Err[*Response](err)
}
// Block same-peer cycles: do not forward a message back to
// the peer that sent it.
routingAction.WhenLeft(func(fwdAction forwardAction) {
var nextNodeIDBytes [33]byte
copy(
nextNodeIDBytes[:],
fwdAction.nextNodeID.SerializeCompressed(),
)
if nextNodeIDBytes == a.peerPubKey {
log.WarnS(logCtx,
"Dropping cyclic onion message",
ErrSamePeerCycle,
lnutils.LogPubKey(
"next_node_id",
fwdAction.nextNodeID,
),
)
err = ErrSamePeerCycle
}
})
if err != nil {
return fn.Err[*Response](err)
}
// Handle the routing action.
payload := fn.ElimEither(routingAction,
func(fwdAction forwardAction) *lnwire.OnionMessagePayload {

View file

@ -474,6 +474,105 @@ func TestOnionPeerActorRouting(t *testing.T) {
}
}
// TestOnionPeerActorSamePeerCycle verifies that the actor rejects onion
// messages whose next hop is the same peer that sent them. Both the direct
// next-node-ID and the SCID-resolved paths are covered.
func TestOnionPeerActorSamePeerCycle(t *testing.T) {
t.Parallel()
type nextNodeFn func(h *actorHarness,
pub *btcec.PublicKey) fn.Either[*btcec.PublicKey,
lnwire.ShortChannelID]
tests := []struct {
name string
nextNode nextNodeFn
}{
{
name: "via next node ID",
nextNode: func(_ *actorHarness,
pub *btcec.PublicKey) fn.Either[
*btcec.PublicKey, lnwire.ShortChannelID] {
return fn.NewLeft[*btcec.PublicKey,
lnwire.ShortChannelID](pub)
},
},
{
name: "via SCID",
nextNode: func(h *actorHarness,
pub *btcec.PublicKey) fn.Either[
*btcec.PublicKey, lnwire.ShortChannelID] {
scid := lnwire.NewShortChanIDFromInt(999)
h.resolver.addPeer(scid, pub)
return fn.NewRight[*btcec.PublicKey](scid)
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
h := newActorHarness(t)
// Generate a key for the next hop, then set the
// actor's peerPubKey to the same key to simulate
// the message arriving from that peer.
nextNodeKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
nextNodePub := nextNodeKey.PubKey()
h.actor.peerPubKey = pubKeyToArray(nextNodePub)
nextNode := tc.nextNode(h, nextNodePub)
rdA := record.NewNonFinalBlindedRouteDataOnionMessage(
nextNode, nil, nil,
)
rdB := &record.BlindedRouteData{}
plainA := EncodeBlindedRouteData(t, rdA)
plainB := EncodeBlindedRouteData(t, rdB)
hops := []*sphinx.HopInfo{
{
NodePub: h.nodeKey.PubKey(),
PlainText: plainA,
},
{NodePub: nextNodePub, PlainText: plainB},
}
blindedPath := BuildBlindedPath(t, hops)
onionMsg, _ := BuildOnionMessage(t, blindedPath, nil)
req := &Request{msg: *onionMsg}
result := h.actor.Receive(t.Context(), req)
// The actor must return an error.
require.True(t, result.IsErr())
result.WhenErr(func(err error) {
require.ErrorIs(t, err, ErrSamePeerCycle)
})
// No message should have been forwarded.
select {
case <-h.sender.sent:
require.FailNow(t, "message should not have "+
"been forwarded back to the sending "+
"peer")
default:
}
// No update should have been dispatched.
select {
case <-h.dispatcher.updates:
require.FailNow(t, "update should not be "+
"dispatched for a cyclic message")
default:
}
})
}
}
// TestOnionPeerActorReceiveContextCanceled tests that OnionPeerActor.Receive
// returns an error when the context is canceled.
func TestOnionPeerActorReceiveContextCanceled(t *testing.T) {

View file

@ -14,4 +14,9 @@ var (
// ErrSCIDEmpty is returned when the short channel ID is missing from
// the route data.
ErrSCIDEmpty = errors.New("short channel ID empty")
// ErrSamePeerCycle is returned when a forwarding onion message
// would be sent back to the same peer it was received from.
ErrSamePeerCycle = errors.New("onion message cycle: next " +
"hop is the sending peer")
)