lnwire: reject onion message payloads with multiple final hop fields
What changed, and why it matters
This change tightens how LND handles special 'final hop' data in onion-routed Lightning messages. Previously, a message could bundle several final-hop payload types (such as an invoice request, an invoice, and an invoice error) together and still be accepted. The patch now rejects any onion message that contains more than one such final-hop payload, matching the BOLT 4 specification. This prevents protocol confusion where a single message might be interpreted in conflicting ways.
Review whether any deployed code or dependent services relied on accepting multiple final-hop TLVs in a single onion message, since this change turns previously accepted payloads into decode errors. Consider backporting to release branches that support onion messages.
Security signals we found
Protocol-conformance fix enforcing BOLT 4 final-hop payload count rule
Prevents ambiguous or conflicting final-hop message interpretation
Rejects bundled invoice_request/invoice/invoice_error payloads
Adds explicit error return for multiple final-hop payload fields
Evidence from the diff
The commit modifies lnwire/onion_msg_payload.go so that OnionMessagePayload.Decode returns a new error, ErrMultipleFinalHopPayloads, when len(o.FinalHopTLVs) > 1. BOLT 4 requires the final node to ignore onion messages whose onionmsg_tlv contains more than one payload field (tlv type >= 64). Previously, Decode accumulated every final-hop field, so a payload bundling invoice_request, invoice, and invoice_error was accepted. Tests are updated to expect rejection and the quick-check property test now draws at most one final-hop payload.
Changed components
lnwire/onion_msg_payload.golnwire/onion_msg_payload_test.goInspect captured patch +29 / −22
diff --git a/lnwire/onion_msg_payload.go b/lnwire/onion_msg_payload.go
index 63bd546..157ebdc 100644
--- a/lnwire/onion_msg_payload.go
+++ b/lnwire/onion_msg_payload.go
@@ -45,6 +45,12 @@ var ErrNotFinalPayload = errors.New("final hop payloads type should be >= 64")
var ErrUnknownEvenType = errors.New("onion message payload contains unknown " +
"even tlv type")
+// ErrMultipleFinalHopPayloads is returned when an onion message payload for the
+// final hop contains more than one payload field (tlv type >= 64). BOLT 4
+// requires the message to be ignored in this case.
+var ErrMultipleFinalHopPayloads = errors.New("onion message payload contains " +
+ "more than one final hop payload field")
+
// OnionMessagePayload contains the contents of an onion message payload.
type OnionMessagePayload struct {
// ReplyPath contains a blinded path that can be used to respond to an
@@ -241,6 +247,14 @@ func (o *OnionMessagePayload) Decode(r io.Reader) (map[tlv.Type][]byte, error) {
)
}
+ // BOLT 4: the final node must ignore an onion message whose
+ // onionmsg_tlv contains more than one payload field (tlv type >= 64).
+ // Every entry in FinalHopTLVs is in the final hop range by
+ // construction, so its length is the number of payload fields present.
+ if len(o.FinalHopTLVs) > 1 {
+ return tlvMap, ErrMultipleFinalHopPayloads
+ }
+
// Iteration through maps occurs in random order - sort final hop
// TLVs in ascending order to make this decoding function
// deterministic.
diff --git a/lnwire/onion_msg_payload_test.go b/lnwire/onion_msg_payload_test.go
index 36affb5..f5345ea 100644
--- a/lnwire/onion_msg_payload_test.go
+++ b/lnwire/onion_msg_payload_test.go
@@ -203,9 +203,13 @@ func TestOnionMessagePayloadRoundTrip(t *testing.T) {
)
})
- t.Run("multiple final hop TLVs", func(t *testing.T) {
+ t.Run("multiple final hop payloads rejected", func(t *testing.T) {
t.Parallel()
+ // BOLT 4 requires the final node to ignore an onion message
+ // that carries more than one final hop payload field, so decode
+ // must reject a payload bundling invoice_request, invoice, and
+ // invoice_error together.
original := &OnionMessagePayload{
FinalHopTLVs: []*FinalHopTLV{
{
@@ -223,24 +227,12 @@ func TestOnionMessagePayloadRoundTrip(t *testing.T) {
},
}
- decoded := encodeAndDecode(t, original)
-
- require.Nil(t, decoded.ReplyPath)
- require.Len(t, decoded.FinalHopTLVs, 3)
+ encoded, err := original.Encode()
+ require.NoError(t, err)
- // Decoded TLVs should be sorted by type.
- require.Equal(
- t, InvoiceRequestNamespaceType,
- decoded.FinalHopTLVs[0].TLVType,
- )
- require.Equal(
- t, InvoiceNamespaceType,
- decoded.FinalHopTLVs[1].TLVType,
- )
- require.Equal(
- t, InvoiceErrorNamespaceType,
- decoded.FinalHopTLVs[2].TLVType,
- )
+ decoded := NewOnionMessagePayload()
+ _, err = decoded.Decode(bytes.NewReader(encoded))
+ require.ErrorIs(t, err, ErrMultipleFinalHopPayloads)
})
t.Run("all fields populated", func(t *testing.T) {
@@ -479,15 +471,16 @@ func TestOnionMessagePayloadRoundTripQuickCheck(t *testing.T) {
).Draw(t, "encryptedData")
}
- // Optionally include final hop TLVs. We use the three known
- // even types (64, 66, 68) since unknown even types would cause
- // decode to fail.
+ // Optionally include a final hop payload. We use the three
+ // known even types (64, 66, 68) since unknown even types would
+ // cause decode to fail. At most one payload field is drawn
+ // because BOLT 4 requires decode to reject more than one.
knownTypes := []tlv.Type{
InvoiceRequestNamespaceType,
InvoiceNamespaceType,
InvoiceErrorNamespaceType,
}
- numFinalTLVs := rapid.IntRange(0, len(knownTypes)).Draw(
+ numFinalTLVs := rapid.IntRange(0, 1).Draw(
t, "numFinalTLVs",
)
for i := range numFinalTLVs {
Why this scored 60/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.