lsp_plugin: check that featurebit is set and that
What changed, and why it matters
This commit hardens a Core Lightning plugin for the Lightning Service Provider (LSP) protocol. Before sending a request, the plugin now verifies that the target peer is currently connected and advertises the required LSP feature bit. It also makes the plugin advertise that feature bit itself. The change is defensive: without it, a user could accidentally send an LSP request to a peer that does not support LSPs, likely causing the request to fail or behave unexpectedly.
Treat as a hardening improvement. Review whether the feature-bit index 729 is correctly aligned with the LSPS specification and whether the hex encoding order matches Core Lightning's expected feature-bit serialization. No urgent security response is indicated by the diff alone.
Security signals we found
Adds input/peer validation before sending LSP protocol requests
Adds feature-bit advertisement and verification for LSP capability
Prevents requests to disconnected or non-LSP peers
Evidence from the diff
The patch adds an ensure_lsp_connected helper in client.rs that queries listpeers for the requested lsp_id, checks peer.connected, and validates that the peer’s feature bitmap has bit 729 (LSP_FEATURE_BIT) set. It also registers the same feature bit in service.rs via cln_plugin::Builder::featurebits for both node and init feature sets, and adds utility functions is_feature_bit_set and feature_bit_to_hex in util.rs with unit tests. The change is a guardrail, not a fix for a known exploitable vulnerability.
Changed components
plugins/lsps-plugin/src/client.rsplugins/lsps-plugin/src/lib.rsplugins/lsps-plugin/src/service.rsplugins/lsps-plugin/src/util.rsInspect captured patch +176 / −2
diff --git a/plugins/lsps-plugin/src/client.rs b/plugins/lsps-plugin/src/client.rs
index b100e5f4..52f1da61 100644
--- a/plugins/lsps-plugin/src/client.rs
+++ b/plugins/lsps-plugin/src/client.rs
@@ -1,13 +1,19 @@
-use anyhow::Context;
+use anyhow::{anyhow, Context};
use cln_lsps::jsonrpc::client::JsonRpcClient;
use cln_lsps::lsps0::{
self,
transport::{Bolt8Transport, CustomMessageHookManager, WithCustomMessageHookManager},
};
+use cln_lsps::util;
+use cln_lsps::LSP_FEATURE_BIT;
use cln_plugin::options;
+use cln_rpc::model::requests::ListpeersRequest;
+use cln_rpc::primitives::PublicKey;
+use cln_rpc::ClnRpc;
use log::debug;
use serde::Deserialize;
use std::path::Path;
+use std::str::FromStr as _;
/// An option to enable this service.
const OPTION_ENABLED: options::FlagConfigOption = options::ConfigOption::new_flag(
@@ -66,9 +72,14 @@ async fn on_lsps_listprotocols(
}
let dir = p.configuration().lightning_dir;
let rpc_path = Path::new(&dir).join(&p.configuration().rpc_file);
+ let mut cln_client = cln_rpc::ClnRpc::new(rpc_path.clone()).await?;
let req: Request = serde_json::from_value(v).context("Failed to parse request JSON")?;
+ // Fail early: Check that we are connected to the peer and that it has the
+ // LSP feature bit set.
+ ensure_lsp_connected(&mut cln_client, &req.lsp_id).await?;
+
// Create the transport first and handle potential errors
let transport = Bolt8Transport::new(
&req.lsp_id,
@@ -90,3 +101,43 @@ async fn on_lsps_listprotocols(
debug!("Received lsps0.list_protocols response: {:?}", res);
Ok(serde_json::to_value(res)?)
}
+
+/// Checks that the node is connected to the peer and that it has the LSP
+/// feature bit set.
+async fn ensure_lsp_connected(cln_client: &mut ClnRpc, lsp_id: &str) -> Result<(), anyhow::Error> {
+ let res = cln_client
+ .call_typed(&ListpeersRequest {
+ id: Some(PublicKey::from_str(lsp_id)?),
+ level: None,
+ })
+ .await?;
+
+ // unwrap in next line is safe as we checked that an item exists before.
+ if res.peers.is_empty() || !res.peers.first().unwrap().connected {
+ debug!("Node isn't connected to lsp {lsp_id}");
+ return Err(anyhow!("not connected to lsp"));
+ }
+
+ res.peers
+ .first()
+ .filter(|peer| {
+ // Check that feature bit is set
+ peer.features.as_deref().map_or(false, |f_str| {
+ if let Some(feature_bits) = hex::decode(f_str).ok() {
+ let mut fb = feature_bits.clone();
+ fb.reverse();
+ util::is_feature_bit_set(&fb, LSP_FEATURE_BIT)
+ } else {
+ false
+ }
+ })
+ })
+ .ok_or_else(|| {
+ anyhow!(
+ "peer is not an lsp, feature bit {} is missing",
+ LSP_FEATURE_BIT,
+ )
+ })?;
+
+ Ok(())
+}
diff --git a/plugins/lsps-plugin/src/lib.rs b/plugins/lsps-plugin/src/lib.rs
index 2eb605d9..aa93d0ac 100644
--- a/plugins/lsps-plugin/src/lib.rs
+++ b/plugins/lsps-plugin/src/lib.rs
@@ -1,3 +1,5 @@
pub mod jsonrpc;
pub mod lsps0;
pub mod util;
+
+pub const LSP_FEATURE_BIT: usize = 729;
diff --git a/plugins/lsps-plugin/src/service.rs b/plugins/lsps-plugin/src/service.rs
index a350a3d9..6e3a578f 100644
--- a/plugins/lsps-plugin/src/service.rs
+++ b/plugins/lsps-plugin/src/service.rs
@@ -3,10 +3,10 @@ use async_trait::async_trait;
use cln_lsps::jsonrpc::server::{JsonRpcResponseWriter, RequestHandler};
use cln_lsps::jsonrpc::{server::JsonRpcServer, JsonRpcRequest};
use cln_lsps::jsonrpc::{JsonRpcResponse, RequestObject, RpcError, TransportError};
-use cln_lsps::lsps0;
use cln_lsps::lsps0::model::{Lsps0listProtocolsRequest, Lsps0listProtocolsResponse};
use cln_lsps::lsps0::transport::{self, CustomMsg};
use cln_lsps::util::wrap_payload_with_peer_id;
+use cln_lsps::{lsps0, util, LSP_FEATURE_BIT};
use cln_plugin::options::ConfigOption;
use cln_plugin::{options, Plugin};
use cln_rpc::notifications::CustomMsgNotification;
@@ -31,6 +31,14 @@ struct State {
async fn main() -> Result<(), anyhow::Error> {
if let Some(plugin) = cln_plugin::Builder::new(tokio::io::stdin(), tokio::io::stdout())
.option(OPTION_ENABLED)
+ .featurebits(
+ cln_plugin::FeatureBitsKind::Node,
+ util::feature_bit_to_hex(LSP_FEATURE_BIT),
+ )
+ .featurebits(
+ cln_plugin::FeatureBitsKind::Init,
+ util::feature_bit_to_hex(LSP_FEATURE_BIT),
+ )
.hook("custommsg", on_custommsg)
.configure()
.await?
diff --git a/plugins/lsps-plugin/src/util.rs b/plugins/lsps-plugin/src/util.rs
index 89f308ae..fe61bb37 100644
--- a/plugins/lsps-plugin/src/util.rs
+++ b/plugins/lsps-plugin/src/util.rs
@@ -5,6 +5,42 @@ use core::fmt;
use serde_json::Value;
use std::str::FromStr;
+/// Checks if the feature bit is set in the provided bitmap.
+/// Returns true if the `feature_bit` is set in the `bitmap`. Returns false if
+/// the `feature_bit` is unset or our ouf bounds.
+///
+/// # Arguments
+///
+/// * `bitmap`: A slice of bytes representing the feature bitmap.
+/// * `feature_bit`: The 0-based index of the bit to check across the bitmap.
+///
+pub fn is_feature_bit_set(bitmap: &[u8], feature_bit: usize) -> bool {
+ let byte_index = feature_bit >> 3; // Equivalent to feature_bit / 8
+ let bit_index = feature_bit & 7; // Equivalent to feature_bit % 8
+
+ if let Some(&target_byte) = bitmap.get(byte_index) {
+ let mask = 1 << bit_index;
+ (target_byte & mask) != 0
+ } else {
+ false
+ }
+}
+
+/// Returns a single feature_bit in hex representation, least-significant bit
+/// first.
+///
+/// # Arguments
+///
+/// * `feature_bit`: The 0-based index of the bit to check across the bitmap.
+///
+pub fn feature_bit_to_hex(feature_bit: usize) -> String {
+ let byte_index = feature_bit >> 3; // Equivalent to feature_bit / 8
+ let mask = 1 << (feature_bit & 7); // Equivalent to feature_bit % 8
+ let mut map = vec![0u8; byte_index + 1];
+ map[0] |= mask; // least-significant bit first ordering.
+ hex::encode(&map)
+}
+
/// Errors that can occur when unwrapping payload data
#[derive(Debug, Clone, PartialEq)]
pub enum UnwrapError {
@@ -131,4 +167,81 @@ mod tests {
Some(UnwrapError::InvalidPublicKey(_))
));
}
+
+ #[test]
+ fn test_basic_bit_checks() {
+ // Example bitmap:
+ // Byte 0: 0b10100101 (165) -> Bits 0, 2, 5, 7 set
+ // Byte 1: 0b01101010 (106) -> Bits 1, 3, 5, 6 set (indices 9, 11, 13, 14)
+ let bitmap: &[u8] = &[0b10100101, 0b01101010];
+
+ // Check bits in byte 0 (indices 0-7)
+ assert_eq!(is_feature_bit_set(bitmap, 0), true); // Bit 0
+ assert_eq!(is_feature_bit_set(bitmap, 1), false); // Bit 1
+ assert_eq!(is_feature_bit_set(bitmap, 2), true); // Bit 2
+ assert_eq!(is_feature_bit_set(bitmap, 3), false); // Bit 3
+ assert_eq!(is_feature_bit_set(bitmap, 4), false); // Bit 4
+ assert_eq!(is_feature_bit_set(bitmap, 5), true); // Bit 5
+ assert_eq!(is_feature_bit_set(bitmap, 6), false); // Bit 6
+ assert_eq!(is_feature_bit_set(bitmap, 7), true); // Bit 7
+
+ // Check bits in byte 1 (indices 8-15)
+ assert_eq!(is_feature_bit_set(bitmap, 8), false); // Bit 8 (Byte 1, bit 0)
+ assert_eq!(is_feature_bit_set(bitmap, 9), true); // Bit 9 (Byte 1, bit 1)
+ assert_eq!(is_feature_bit_set(bitmap, 10), false); // Bit 10 (Byte 1, bit 2)
+ assert_eq!(is_feature_bit_set(bitmap, 11), true); // Bit 11 (Byte 1, bit 3)
+ assert_eq!(is_feature_bit_set(bitmap, 12), false); // Bit 12 (Byte 1, bit 4)
+ assert_eq!(is_feature_bit_set(bitmap, 13), true); // Bit 13 (Byte 1, bit 5)
+ assert_eq!(is_feature_bit_set(bitmap, 14), true); // Bit 14 (Byte 1, bit 6)
+ assert_eq!(is_feature_bit_set(bitmap, 15), false); // Bit 15 (Byte 1, bit 7)
+ }
+
+ #[test]
+ fn test_out_of_bounds() {
+ let bitmap: &[u8] = &[0b11111111, 0b00000000]; // 16 bits total
+
+ assert_eq!(is_feature_bit_set(bitmap, 15), false); // Last valid bit (is 0)
+ assert_eq!(is_feature_bit_set(bitmap, 16), false); // Out of bounds
+ assert_eq!(is_feature_bit_set(bitmap, 100), false); // Way out of bounds
+ }
+
+ #[test]
+ fn test_empty_bitmap() {
+ let bitmap: &[u8] = &[];
+ assert_eq!(is_feature_bit_set(bitmap, 0), false);
+ assert_eq!(is_feature_bit_set(bitmap, 8), false);
+ }
+
+ #[test]
+ fn test_feature_to_hex_bit_0_be() {
+ // Bit 0 is in Byte 0 (LE index). num_bytes=1. BE index = 1-1-0=0.
+ // Expected map: [0x01]
+ let feature_hex = feature_bit_to_hex(0);
+ assert_eq!(feature_hex, "01");
+ assert!(is_feature_bit_set(&hex::decode(feature_hex).unwrap(), 0));
+ }
+
+ #[test]
+ fn test_feature_to_hex_bit_8_be() {
+ // Bit 8 is in Byte 1 (LE index). num_bytes=2. BE index = 2-1-1=0.
+ // Mask is 0x01 for bit 0 within its byte.
+ // Expected map: [0x01, 0x00] (Byte for 8-15 first, then 0-7)
+ let feature_hex = feature_bit_to_hex(8);
+ let mut decoded = hex::decode(&feature_hex).unwrap();
+ decoded.reverse();
+ assert_eq!(feature_hex, "0100");
+ assert!(is_feature_bit_set(&decoded, 8));
+ }
+
+ #[test]
+ fn test_feature_to_hex_bit_27_be() {
+ // Bit 27 is in Byte 3 (LE index). num_bytes=4. BE index = 4-1-3=0.
+ // Mask is 0x08 for bit 3 within its byte.
+ // Expected map: [0x08, 0x00, 0x00, 0x00] (Byte for 24-31 first)
+ let feature_hex = feature_bit_to_hex(27);
+ let mut decoded = hex::decode(&feature_hex).unwrap();
+ decoded.reverse();
+ assert_eq!(feature_hex, "08000000");
+ assert!(is_feature_bit_set(&decoded, 27));
+ }
}
Why this scored 46/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.