What changed, and why it matters
This commit adds validation checks to make sure a Lightning node's configured CLTV expiry limits and advertised channel policies stay within the same supported range. Previously, a node operator could configure settings that were internally inconsistent or advertise forwarding delays that the node was not actually willing to accept, which could lead to payment routing failures or unexpected channel behavior. The change prevents misconfiguration rather than fixing an active exploit.
Treat as a hardening/configuration-safety improvement. Review related policy and forwarding code to confirm no other paths allow bypassing these bounds, and consider whether the new validation should also apply to existing persisted policies during startup or migration.
Security signals we found
Input validation added for configuration and RPC policy parameters
Prevents advertised forwarding CLTV delta from exceeding node acceptance limit
Prevents local max-cltv-expiry from being set below default time lock delta or above protocol maximum
Refactors existing bounds check into shared validators with additional upper-bound consistency check
Evidence from the diff
The patch introduces three validation helpers in config.go: validateMaxOutgoingCltvExpiry, validateCltvDeltaBounds, and validateChannelPolicyTimeLockDelta. It enforces that cfg.MaxOutgoingCltvExpiry is between the default Bitcoin TimeLockDelta and MaxTimeLockDelta, and that RPC UpdateChannelPolicy TimeLockDelta values are within minTimeLockDelta/MaxTimeLockDelta and do not exceed MaxOutgoingCltvExpiry. rpcserver.go replaces its previous two-sided bounds check with the new unified validator that also compares against the node’s max outgoing CLTV expiry. Tests are added for both validators.
Changed components
config.go configuration validationrpcserver.go UpdateChannelPolicy RPC handlerchannel policy / forwarding parameter validationInspect captured patch +123 / −8
diff --git a/config.go b/config.go
index 3b73dbc..5b69e94 100644
--- a/config.go
+++ b/config.go
@@ -264,6 +264,58 @@ const (
defaultNoDisconnectOnPongFailure = false
)
+// validateMaxOutgoingCltvExpiry validates the configured maximum outgoing CLTV
+// expiry against the node's default time lock delta.
+func validateMaxOutgoingCltvExpiry(maxCltvExpiry, timeLockDelta uint32) error {
+ if maxCltvExpiry < timeLockDelta {
+ return fmt.Errorf(
+ "max-cltv-expiry must be at least %v", timeLockDelta,
+ )
+ }
+
+ if maxCltvExpiry > MaxTimeLockDelta {
+ return fmt.Errorf(
+ "max-cltv-expiry must be at most %v", MaxTimeLockDelta,
+ )
+ }
+
+ return nil
+}
+
+// validateCltvDeltaBounds validates a CLTV delta against LND's absolute
+// supported bounds.
+func validateCltvDeltaBounds(delta uint32) error {
+ if delta < minTimeLockDelta {
+ return fmt.Errorf("time lock delta of %v is too small, "+
+ "minimum supported is %v", delta, minTimeLockDelta)
+ }
+
+ if delta > MaxTimeLockDelta {
+ return fmt.Errorf("time lock delta of %v is too big, "+
+ "maximum supported is %v", delta, MaxTimeLockDelta)
+ }
+
+ return nil
+}
+
+// validateChannelPolicyTimeLockDelta validates an advertised channel policy
+// time lock delta against the node's supported forwarding bounds.
+func validateChannelPolicyTimeLockDelta(timeLockDelta,
+ maxOutgoingCltvExpiry uint32) error {
+
+ if err := validateCltvDeltaBounds(timeLockDelta); err != nil {
+ return err
+ }
+
+ if timeLockDelta > maxOutgoingCltvExpiry {
+ return fmt.Errorf("time lock delta of %v exceeds "+
+ "max-cltv-expiry of %v", timeLockDelta,
+ maxOutgoingCltvExpiry)
+ }
+
+ return nil
+}
+
var (
// DefaultLndDir is the default directory where lnd tries to find its
// configuration file and store its data. This is a directory in the
@@ -1198,6 +1250,12 @@ func ValidateConfig(cfg Config, interceptor signal.Interceptor, fileParser,
cfg.MaxCommitFeeRateAnchors)
}
+ if err := validateMaxOutgoingCltvExpiry(
+ cfg.MaxOutgoingCltvExpiry, cfg.Bitcoin.TimeLockDelta,
+ ); err != nil {
+ return nil, mkErr("%v", err)
+ }
+
// Validate the Tor config parameters.
socks, err := lncfg.ParseAddressString(
cfg.Tor.SOCKS, strconv.Itoa(defaultTorSOCKSPort),
diff --git a/config_test.go b/config_test.go
index 0233a27..c312e0c 100644
--- a/config_test.go
+++ b/config_test.go
@@ -5,6 +5,7 @@ import (
"testing"
"github.com/lightningnetwork/lnd/chainreg"
+ "github.com/lightningnetwork/lnd/htlcswitch"
"github.com/lightningnetwork/lnd/routing"
"github.com/stretchr/testify/require"
)
@@ -171,3 +172,63 @@ func TestValidateConfigTrickleDelay(t *testing.T) {
})
}
}
+
+// TestValidateMaxOutgoingCltvExpiry asserts that max-cltv-expiry accepts
+// values within its supported bounds and rejects values outside them.
+func TestValidateMaxOutgoingCltvExpiry(t *testing.T) {
+ t.Parallel()
+
+ cfg := DefaultConfig()
+
+ require.NoError(
+ t, validateMaxOutgoingCltvExpiry(
+ htlcswitch.DefaultMaxOutgoingCltvExpiry,
+ cfg.Bitcoin.TimeLockDelta,
+ ),
+ )
+ require.NoError(t, validateMaxOutgoingCltvExpiry(
+ MaxTimeLockDelta, MaxTimeLockDelta,
+ ))
+
+ err := validateMaxOutgoingCltvExpiry(
+ cfg.Bitcoin.TimeLockDelta-1,
+ cfg.Bitcoin.TimeLockDelta,
+ )
+ require.ErrorContains(t, err, "max-cltv-expiry must be at least")
+
+ err = validateMaxOutgoingCltvExpiry(
+ MaxTimeLockDelta+1, cfg.Bitcoin.TimeLockDelta,
+ )
+ require.ErrorContains(t, err, "max-cltv-expiry must be at most")
+}
+
+// TestValidateChannelPolicyTimeLockDelta asserts that advertised channel
+// policy CLTV deltas stay within the node's supported forwarding bounds.
+func TestValidateChannelPolicyTimeLockDelta(t *testing.T) {
+ t.Parallel()
+
+ cfg := DefaultConfig()
+
+ require.NoError(t, validateChannelPolicyTimeLockDelta(
+ cfg.Bitcoin.TimeLockDelta, cfg.MaxOutgoingCltvExpiry,
+ ))
+ require.NoError(t, validateChannelPolicyTimeLockDelta(
+ cfg.MaxOutgoingCltvExpiry, cfg.MaxOutgoingCltvExpiry,
+ ))
+
+ err := validateChannelPolicyTimeLockDelta(
+ minTimeLockDelta-1, cfg.MaxOutgoingCltvExpiry,
+ )
+ require.ErrorContains(t, err, "time lock delta of")
+ require.ErrorContains(t, err, "is too small")
+
+ err = validateChannelPolicyTimeLockDelta(
+ MaxTimeLockDelta+1, MaxTimeLockDelta,
+ )
+ require.ErrorContains(t, err, "is too big")
+
+ err = validateChannelPolicyTimeLockDelta(
+ cfg.MaxOutgoingCltvExpiry+1, cfg.MaxOutgoingCltvExpiry,
+ )
+ require.ErrorContains(t, err, "exceeds max-cltv-expiry")
+}
diff --git a/rpcserver.go b/rpcserver.go
index af5e9c9..9b2202d 100644
--- a/rpcserver.go
+++ b/rpcserver.go
@@ -7500,14 +7500,10 @@ func (r *rpcServer) UpdateChannelPolicy(ctx context.Context,
// We'll also ensure that the user isn't setting a CLTV delta that
// won't give outgoing HTLCs enough time to fully resolve if needed.
- if req.TimeLockDelta < minTimeLockDelta {
- return nil, fmt.Errorf("time lock delta of %v is too small, "+
- "minimum supported is %v", req.TimeLockDelta,
- minTimeLockDelta)
- } else if req.TimeLockDelta > uint32(MaxTimeLockDelta) {
- return nil, fmt.Errorf("time lock delta of %v is too big, "+
- "maximum supported is %v", req.TimeLockDelta,
- MaxTimeLockDelta)
+ if err := validateChannelPolicyTimeLockDelta(
+ req.TimeLockDelta, r.cfg.MaxOutgoingCltvExpiry,
+ ); err != nil {
+ return nil, err
}
// By default, positive inbound fees are rejected.
Why this scored 44/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.