lnwire: reject onion message payloads with unknown even types
What changed, and why it matters
This commit fixes a standards-compliance bug in how LND decodes onion-routed messages in the Lightning Network. The relevant protocol rule (BOLT 4) says that if a message contains an unknown even-numbered data field, the receiver must reject the whole message because even fields are 'must understand.' LND was previously accepting such messages, which could let a peer make LND process or forward malformed/ambiguous onion messages. The patch now rejects any unknown even field during decoding, no matter what numeric range it falls in.
Treat this as a security-hardening fix and include it in the next maintenance release. Review whether any production paths currently catch or swallow decode errors from OnionMessagePayload.Decode, since callers now need to handle ErrUnknownEvenType by ignoring the message per BOLT 4. Consider an advisory if prior behavior could be abused to inject or relay invalid onion messages.
Security signals we found
Protocol compliance fix for BOLT 4 'must understand' even TLV types
Previously accepted malformed/ambiguous onion message payloads
Potential for protocol confusion or forwarding of invalid onion messages
New explicit error path introduced during decode
Test coverage added for both in-range and below-range unknown even types
Evidence from the diff
The change is in lnwire/onion_msg_payload.go. OnionMessagePayload.Decode previously iterated the parsed TLV map and silently skipped entries below finalHopPayloadStart (type 64), then stored unknown odd types above 64 as FinalHopTLVs. Because the underlying TLV decoder records unknown types of either parity, an unknown even type such as 70 would be accepted as a final-hop payload, violating BOLT 4’s ‘must understand’ rule for even TLV types. The patch reorders the loop: it first checks whether an unrecognized TLV type is even and, if so, returns ErrUnknownEvenType. Only unknown odd types below 64 are skipped, and unknown odd types at or above 64 are still exposed to application layers. Tests are added for an unknown even type in the final-hop range (70) and below the range (6).
Changed components
lnwire/onion_msg_payload.golnwire/onion_msg_payload_test.goOnionMessagePayload.DecodeFinalHopTLV handlingInspect captured patch +68 / −6
diff --git a/lnwire/onion_msg_payload.go b/lnwire/onion_msg_payload.go
index 7b384d9..63bd546 100644
--- a/lnwire/onion_msg_payload.go
+++ b/lnwire/onion_msg_payload.go
@@ -39,6 +39,12 @@ const (
// correct range.
var ErrNotFinalPayload = errors.New("final hop payloads type should be >= 64")
+// ErrUnknownEvenType is returned when an onion message payload contains an
+// unknown even TLV type. BOLT 4 requires the whole message to be ignored in
+// this case, because even types are "must understand".
+var ErrUnknownEvenType = errors.New("onion message payload contains unknown " +
+ "even tlv type")
+
// 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
@@ -174,11 +180,6 @@ func (o *OnionMessagePayload) Decode(r io.Reader) (map[tlv.Type][]byte, error) {
// recognized. We'll just directly read these out and allow higher
// application layers to deal with them.
for tlvType, tlvBytes := range tlvMap {
- // Skip any tlvs that are not in our range.
- if tlvType < finalHopPayloadStart {
- continue
- }
-
// Skip any tlvs that have been recognized in our decoding.
// DecodeWithParsedTypesP2P stores a nil entry for known types
// that it decoded into a dedicated field above, and the raw
@@ -189,7 +190,25 @@ func (o *OnionMessagePayload) Decode(r io.Reader) (map[tlv.Type][]byte, error) {
continue
}
- // Add the payload to our message's final hop payloads.
+ // BOLT 4: if the onionmsg_tlv contains unknown even types, the
+ // whole message must be ignored, since even types are
+ // "must understand". This applies regardless of the type range,
+ // so we check it before skipping types outside the final hop
+ // range.
+ if tlvType%2 == 0 {
+ return tlvMap, fmt.Errorf("%w: %v", ErrUnknownEvenType,
+ tlvType)
+ }
+
+ // Skip any unknown odd tlvs outside the final hop payload
+ // range: they are not addressed to the final hop's application
+ // layer, and odd types are safe to ignore.
+ if tlvType < finalHopPayloadStart {
+ continue
+ }
+
+ // Add the unknown odd final hop payload to our message so that
+ // higher application layers can deal with it.
payload := &FinalHopTLV{
TLVType: tlvType,
Value: tlvBytes,
diff --git a/lnwire/onion_msg_payload_test.go b/lnwire/onion_msg_payload_test.go
index 4cc464d..36affb5 100644
--- a/lnwire/onion_msg_payload_test.go
+++ b/lnwire/onion_msg_payload_test.go
@@ -316,6 +316,49 @@ func TestOnionMessagePayloadRoundTrip(t *testing.T) {
require.Equal(t, tlv.Type(65), decoded.FinalHopTLVs[0].TLVType)
require.Empty(t, decoded.FinalHopTLVs[0].Value)
})
+
+ t.Run("unknown even final hop type rejected", func(t *testing.T) {
+ t.Parallel()
+
+ // Type 70 is in the final hop range but is an unknown even
+ // type, so BOLT 4 requires the message to be ignored.
+ original := &OnionMessagePayload{
+ FinalHopTLVs: []*FinalHopTLV{
+ {
+ TLVType: 70,
+ Value: []byte("must-understand"),
+ },
+ },
+ }
+
+ encoded, err := original.Encode()
+ require.NoError(t, err)
+
+ decoded := NewOnionMessagePayload()
+ _, err = decoded.Decode(bytes.NewReader(encoded))
+ require.ErrorIs(t, err, ErrUnknownEvenType)
+ })
+
+ t.Run("unknown even type below range rejected", func(t *testing.T) {
+ t.Parallel()
+
+ // An unknown even type outside the final hop range must also be
+ // rejected: the must-understand rule applies regardless of the
+ // tlv range. We build the stream directly because the encoder's
+ // FinalHopTLV.Validate would reject a sub-64 type.
+ val := []byte("data")
+ record := tlv.MakePrimitiveRecord(tlv.Type(6), &val)
+
+ stream, err := tlv.NewStream(record)
+ require.NoError(t, err)
+
+ var b bytes.Buffer
+ require.NoError(t, stream.Encode(&b))
+
+ decoded := NewOnionMessagePayload()
+ _, err = decoded.Decode(bytes.NewReader(b.Bytes()))
+ require.ErrorIs(t, err, ErrUnknownEvenType)
+ })
}
// TestFinalHopTLVValidate tests that FinalHopTLV.Validate correctly rejects
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.