Skip to content

Commit a4633f1

Browse files
authored
Merge pull request #4566 from tnull/2026-04-bitreq-url
Use `bitreq::Url` for LSPS5 webhook URLs
2 parents 2313bd5 + 5237c9a commit a4633f1

4 files changed

Lines changed: 49 additions & 54 deletions

File tree

lightning-liquidity/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ chrono = { version = "0.4", default-features = false, features = ["serde", "allo
3333
serde = { version = "1.0", default-features = false, features = ["derive", "alloc"] }
3434
serde_json = { version = "1.0", default-features = false, features = ["alloc"] }
3535
backtrace = { version = "0.3", optional = true }
36+
bitreq = { version = "0.3.2", default-features = false }
3637

3738
[dev-dependencies]
3839
lightning = { version = "0.3.0", path = "../lightning", default-features = false, features = ["_test_utils"] }

lightning-liquidity/src/lsps5/msgs.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -876,7 +876,7 @@ mod tests {
876876
}
877877

878878
#[test]
879-
fn test_url_security_validation() {
879+
fn test_webhook_url_validation() {
880880
let urls_that_should_throw = [
881881
"test-app",
882882
"http://example.com/webhook",
@@ -906,6 +906,16 @@ mod tests {
906906
}
907907
}
908908

909+
#[test]
910+
fn test_webhook_url_accepts_https_userinfo_and_ipv6() {
911+
let userinfo_url =
912+
LSPS5WebhookUrl::new("https://user:pass@example.com/webhook".to_string()).unwrap();
913+
assert_eq!(userinfo_url.as_str(), "https://user:pass@example.com/webhook");
914+
915+
let ipv6_url = LSPS5WebhookUrl::new("https://[::1]/webhook".to_string()).unwrap();
916+
assert_eq!(ipv6_url.as_str(), "https://[::1]/webhook");
917+
}
918+
909919
#[test]
910920
fn test_lsps_url_readable_rejects_http() {
911921
use lightning::util::ser::Writeable;

lightning-liquidity/src/lsps5/url_utils.rs

Lines changed: 24 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,28 @@
1111
1212
use super::msgs::LSPS5ProtocolError;
1313

14+
use bitreq::Url;
1415
use lightning::ln::msgs::DecodeError;
1516
use lightning::util::ser::{Readable, Writeable};
16-
use lightning_types::string::UntrustedString;
1717

1818
use alloc::string::String;
19+
use core::hash::{Hash, Hasher};
1920

2021
/// Represents a parsed URL for LSPS5 webhook notifications.
21-
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
22-
pub struct LSPSUrl(UntrustedString);
22+
#[derive(Debug, Clone, Eq)]
23+
pub struct LSPSUrl(Url);
24+
25+
impl PartialEq for LSPSUrl {
26+
fn eq(&self, other: &Self) -> bool {
27+
self.0.as_str() == other.0.as_str()
28+
}
29+
}
30+
31+
impl Hash for LSPSUrl {
32+
fn hash<H: Hasher>(&self, state: &mut H) {
33+
self.0.as_str().hash(state)
34+
}
35+
}
2336

2437
impl LSPSUrl {
2538
/// Parses a URL string into a URL instance.
@@ -30,79 +43,37 @@ impl LSPSUrl {
3043
/// # Returns
3144
/// A Result containing either the parsed URL or an error message.
3245
pub fn parse(url_str: String) -> Result<Self, LSPS5ProtocolError> {
33-
if url_str.chars().any(|c| !Self::is_valid_url_char(c)) {
34-
return Err(LSPS5ProtocolError::UrlParse);
35-
}
46+
let url = Url::parse(&url_str).map_err(|_| LSPS5ProtocolError::UrlParse)?;
3647

37-
let (scheme, remainder) =
38-
url_str.split_once("://").ok_or_else(|| LSPS5ProtocolError::UrlParse)?;
39-
40-
if !scheme.eq_ignore_ascii_case("https") {
48+
if url.scheme() != "https" {
4149
return Err(LSPS5ProtocolError::UnsupportedProtocol);
4250
}
4351

44-
let host_section =
45-
remainder.split(['/', '?', '#']).next().ok_or_else(|| LSPS5ProtocolError::UrlParse)?;
46-
47-
let host_without_auth = host_section
48-
.split('@')
49-
.next_back()
50-
.filter(|s| !s.is_empty())
51-
.ok_or_else(|| LSPS5ProtocolError::UrlParse)?;
52-
53-
if host_without_auth.is_empty()
54-
|| host_without_auth.chars().any(|c| !Self::is_valid_host_char(c))
55-
{
56-
return Err(LSPS5ProtocolError::UrlParse);
57-
}
58-
59-
match host_without_auth.rsplit_once(':') {
60-
Some((hostname, _)) if hostname.is_empty() => return Err(LSPS5ProtocolError::UrlParse),
61-
Some((_, port)) => {
62-
if !port.is_empty() && port.parse::<u16>().is_err() {
63-
return Err(LSPS5ProtocolError::UrlParse);
64-
}
65-
},
66-
None => {},
67-
};
68-
69-
Ok(LSPSUrl(UntrustedString(url_str)))
52+
Ok(LSPSUrl(url))
7053
}
7154

7255
/// Returns URL length in bytes.
73-
///
74-
/// Since [`LSPSUrl::parse`] only accepts ASCII characters, this is equivalent
75-
/// to the character count.
7656
pub fn url_length(&self) -> usize {
77-
self.0 .0.len()
57+
self.0.as_str().len()
7858
}
7959

8060
/// Returns the full URL string.
8161
pub fn url(&self) -> &str {
82-
self.0 .0.as_str()
83-
}
84-
85-
fn is_valid_url_char(c: char) -> bool {
86-
c.is_ascii_alphanumeric()
87-
|| matches!(c, ':' | '/' | '.' | '@' | '?' | '#' | '%' | '-' | '_' | '&' | '=')
88-
}
89-
90-
fn is_valid_host_char(c: char) -> bool {
91-
c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':' | '_')
62+
self.0.as_str()
9263
}
9364
}
9465

9566
impl Writeable for LSPSUrl {
9667
fn write<W: lightning::util::ser::Writer>(
9768
&self, writer: &mut W,
9869
) -> Result<(), lightning::io::Error> {
99-
self.0.write(writer)
70+
self.0.as_str().write(writer)
10071
}
10172
}
10273

10374
impl Readable for LSPSUrl {
10475
fn read<R: lightning::io::Read>(reader: &mut R) -> Result<Self, DecodeError> {
105-
let s: UntrustedString = Readable::read(reader)?;
106-
Self::parse(s.0).map_err(|_| DecodeError::InvalidValue)
76+
let s: String = Readable::read(reader)?;
77+
Self::parse(s).map_err(|_| DecodeError::InvalidValue)
10778
}
10879
}

lightning/src/util/ser.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1631,6 +1631,13 @@ impl Readable for () {
16311631
}
16321632

16331633
impl Writeable for String {
1634+
#[inline]
1635+
fn write<W: Writer>(&self, w: &mut W) -> Result<(), io::Error> {
1636+
self.as_str().write(w)
1637+
}
1638+
}
1639+
1640+
impl Writeable for &str {
16341641
#[inline]
16351642
fn write<W: Writer>(&self, w: &mut W) -> Result<(), io::Error> {
16361643
CollectionLength(self.len() as u64).write(w)?;
@@ -1797,6 +1804,12 @@ mod tests {
17971804
assert_eq!(Hostname::read(&mut buf.as_slice()).unwrap().as_str(), "test");
17981805
}
17991806

1807+
#[test]
1808+
fn str_serialization_matches_string() {
1809+
let s = "test";
1810+
assert_eq!(s.encode(), s.to_string().encode());
1811+
}
1812+
18001813
#[test]
18011814
/// Taproot will likely fill legacy signature fields with all 0s.
18021815
/// This test ensures that doing so won't break serialization.

0 commit comments

Comments
 (0)