Validate HTTPS scheme in LSPS5 URL Readable deserialization
What changed, and why it matters
This commit fixes a validation gap in the Lightning Dev Kit's LSPS5 (liquidity service) URL handling. When receiving a URL over the wire protocol, the code previously accepted any URL scheme, including insecure ones like http:// or ftp://. Now it enforces HTTPS-only URLs during deserialization, matching the validation already done for JSON input. It also adds a length check for webhook URLs.
Review whether any persisted or in-flight wire-serialized LSPS5 URLs with non-HTTPS schemes exist in deployed nodes, and consider whether additional transport-level enforcement is needed. The patch should be backported if supported release branches contain the vulnerable code.
Security signals we found
Bypass of existing HTTPS-only URL validation in wire deserialization path
Potential downgrade or redirection to insecure transport for LSPS5/webhook URLs
Inconsistent validation between serde/JSON and `Readable` deserialization paths
Addition of length limit enforcement for webhook URLs during deserialization
Evidence from the diff
The Readable implementations for LSPSUrl and LSPS5WebhookUrl in lightning-liquidity/src/lsps5/ were deserializing raw UntrustedString values without scheme validation. LSPSUrl::Readable now routes through LSPSUrl::parse(), which rejects non-HTTPS schemes. LSPS5WebhookUrl::Readable now validates the inner LSPSUrl and enforces MAX_WEBHOOK_URL_LENGTH. A related change makes url_length() return byte length rather than character count, which is equivalent because parse() only accepts ASCII.
Changed components
lightning-liquidity/src/lsps5/msgs.rslightning-liquidity/src/lsps5/url_utils.rsLSPSUrl::ReadableLSPS5WebhookUrl::ReadableLSPSUrl::url_lengthInspect captured patch +64 / −4
diff --git a/lightning-liquidity/src/lsps5/msgs.rs b/lightning-liquidity/src/lsps5/msgs.rs
index 363a325..6e9c5df 100644
--- a/lightning-liquidity/src/lsps5/msgs.rs
+++ b/lightning-liquidity/src/lsps5/msgs.rs
@@ -457,7 +457,11 @@ impl Writeable for LSPS5WebhookUrl {
impl Readable for LSPS5WebhookUrl {
fn read<R: lightning::io::Read>(reader: &mut R) -> Result<Self, DecodeError> {
- Ok(Self(Readable::read(reader)?))
+ let url: LSPSUrl = Readable::read(reader)?;
+ if url.url().len() > MAX_WEBHOOK_URL_LENGTH {
+ return Err(DecodeError::InvalidValue);
+ }
+ Ok(Self(url))
}
}
@@ -902,6 +906,58 @@ mod tests {
}
}
+ #[test]
+ fn test_lsps_url_readable_rejects_http() {
+ use lightning::util::ser::Writeable;
+
+ let raw =
+ lightning_types::string::UntrustedString("http://example.com/webhook".to_string());
+ let encoded = raw.encode();
+ let result = LSPSUrl::read(&mut lightning::io::Cursor::new(&encoded));
+ assert!(result.is_err(), "LSPSUrl::Readable should reject http:// URLs");
+ }
+
+ #[test]
+ fn test_lsps_url_readable_accepts_https() {
+ use lightning::util::ser::Writeable;
+
+ let https_url = LSPSUrl::parse("https://example.com/webhook".to_string()).unwrap();
+ let encoded = https_url.encode();
+ let decoded = LSPSUrl::read(&mut lightning::io::Cursor::new(&encoded)).unwrap();
+ assert_eq!(decoded.url(), "https://example.com/webhook");
+ }
+
+ #[test]
+ fn test_webhook_url_readable_rejects_http() {
+ use lightning::util::ser::Writeable;
+
+ let raw =
+ lightning_types::string::UntrustedString("http://example.com/webhook".to_string());
+ let encoded = raw.encode();
+ let result = LSPS5WebhookUrl::read(&mut lightning::io::Cursor::new(&encoded));
+ assert!(result.is_err(), "Readable should reject http:// webhook URLs");
+ }
+
+ #[test]
+ fn test_webhook_url_readable_rejects_too_long() {
+ use lightning::util::ser::Writeable;
+
+ let long_url = LSPSUrl::parse(format!("https://example.com/{}", "a".repeat(2000))).unwrap();
+ let encoded = long_url.encode();
+ let result = LSPS5WebhookUrl::read(&mut lightning::io::Cursor::new(&encoded));
+ assert!(result.is_err(), "Readable should reject URLs exceeding MAX_WEBHOOK_URL_LENGTH");
+ }
+
+ #[test]
+ fn test_webhook_url_readable_accepts_valid_https() {
+ use lightning::util::ser::Writeable;
+
+ let valid_url = LSPS5WebhookUrl::new("https://example.com/webhook".to_string()).unwrap();
+ let encoded = valid_url.encode();
+ let decoded = LSPS5WebhookUrl::read(&mut lightning::io::Cursor::new(&encoded)).unwrap();
+ assert_eq!(decoded.as_str(), "https://example.com/webhook");
+ }
+
#[test]
fn test_webhook_notification_parameter_binding() {
let notification = WebhookNotification::expiry_soon(144);
diff --git a/lightning-liquidity/src/lsps5/url_utils.rs b/lightning-liquidity/src/lsps5/url_utils.rs
index 2d49c10..2a660b4 100644
--- a/lightning-liquidity/src/lsps5/url_utils.rs
+++ b/lightning-liquidity/src/lsps5/url_utils.rs
@@ -69,9 +69,12 @@ impl LSPSUrl {
Ok(LSPSUrl(UntrustedString(url_str)))
}
- /// Returns URL length.
+ /// Returns URL length in bytes.
+ ///
+ /// Since [`LSPSUrl::parse`] only accepts ASCII characters, this is equivalent
+ /// to the character count.
pub fn url_length(&self) -> usize {
- self.0 .0.chars().count()
+ self.0 .0.len()
}
/// Returns the full URL string.
@@ -99,6 +102,7 @@ impl Writeable for LSPSUrl {
impl Readable for LSPSUrl {
fn read<R: lightning::io::Read>(reader: &mut R) -> Result<Self, DecodeError> {
- Ok(Self(Readable::read(reader)?))
+ let s: UntrustedString = Readable::read(reader)?;
+ Self::parse(s.0).map_err(|_| DecodeError::InvalidValue)
}
}
Why this scored 64/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.