Skip to content

Commit c0b8834

Browse files
Add custom URL parser for LSPS5.
Allows validating webhook URLs without depending on the external url crate.
1 parent 8ac732c commit c0b8834

File tree

1 file changed

+227
-0
lines changed

1 file changed

+227
-0
lines changed
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
// This file is Copyright its original authors, visible in version control
2+
// history.
3+
//
4+
// This file is licensed under the Apache License, Version 2.0 <LICENSE-APACHE
5+
// or http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
6+
// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your option.
7+
// You may not use this file except in accordance with one or both of these
8+
// licenses.
9+
10+
//! URL utilities for LSPS5 webhook notifications.
11+
12+
use super::msgs::LSPS5ProtocolError;
13+
14+
use lightning_types::string::UntrustedString;
15+
16+
use alloc::string::String;
17+
18+
/// Represents a parsed URL for LSPS5 webhook notifications.
19+
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
20+
pub struct LSPSUrl {
21+
url: UntrustedString,
22+
}
23+
24+
impl LSPSUrl {
25+
/// Parses a URL string into a URL instance.
26+
///
27+
/// # Arguments
28+
/// * `url_str` - The URL string to parse
29+
///
30+
/// # Returns
31+
/// A Result containing either the parsed URL or an error message.
32+
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+
}
36+
37+
let (scheme, remainder) =
38+
url_str.split_once("://").ok_or_else(|| (LSPS5ProtocolError::UrlParse))?;
39+
40+
if !scheme.eq_ignore_ascii_case("https") {
41+
return Err(LSPS5ProtocolError::UnsupportedProtocol);
42+
}
43+
44+
let host_section = remainder
45+
.split(['/', '?', '#'])
46+
.next()
47+
.ok_or_else(|| (LSPS5ProtocolError::UrlParse))?;
48+
49+
let host_without_auth = host_section
50+
.split('@')
51+
.next_back()
52+
.filter(|s| !s.is_empty())
53+
.ok_or_else(|| (LSPS5ProtocolError::UrlParse))?;
54+
55+
if host_without_auth.is_empty()
56+
|| host_without_auth.chars().any(|c| !Self::is_valid_host_char(c))
57+
{
58+
return Err(LSPS5ProtocolError::UrlParse);
59+
}
60+
61+
match host_without_auth.rsplit_once(':') {
62+
Some((hostname, _)) if hostname.is_empty() => return Err(LSPS5ProtocolError::UrlParse),
63+
Some((_, port)) => {
64+
if !port.is_empty() && port.parse::<u16>().is_err() {
65+
return Err(LSPS5ProtocolError::UrlParse);
66+
}
67+
},
68+
None => {},
69+
};
70+
71+
Ok(LSPSUrl { url: UntrustedString(url_str) })
72+
}
73+
74+
/// Returns URL length.
75+
pub fn url_length(&self) -> usize {
76+
self.url.0.chars().count()
77+
}
78+
79+
/// Returns the full URL string.
80+
pub fn url(&self) -> &str {
81+
self.url.0.as_str()
82+
}
83+
84+
fn is_valid_url_char(c: char) -> bool {
85+
c.is_ascii_alphanumeric()
86+
|| matches!(c, ':' | '/' | '.' | '@' | '?' | '#' | '%' | '-' | '_' | '&' | '=')
87+
}
88+
89+
fn is_valid_host_char(c: char) -> bool {
90+
c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':' | '_')
91+
}
92+
}
93+
94+
#[cfg(test)]
95+
mod tests {
96+
use super::*;
97+
use crate::alloc::string::ToString;
98+
use alloc::vec::Vec;
99+
use proptest::prelude::*;
100+
101+
#[test]
102+
fn test_extremely_long_url() {
103+
let url_str = format!("https://{}/path", "a".repeat(1000)).to_string();
104+
let url_chars = url_str.chars().count();
105+
let result = LSPSUrl::parse(url_str);
106+
107+
assert!(result.is_ok());
108+
let url = result.unwrap();
109+
assert_eq!(url.url.0.chars().count(), url_chars);
110+
}
111+
112+
#[test]
113+
fn test_parse_http_url() {
114+
let url_str = "http://example.com/path".to_string();
115+
let url = LSPSUrl::parse(url_str).unwrap_err();
116+
assert_eq!(url, LSPS5ProtocolError::UnsupportedProtocol);
117+
}
118+
119+
#[test]
120+
fn valid_lsps_url() {
121+
let test_vec: Vec<&'static str> = vec![
122+
"https://www.example.org/push?l=1234567890abcopqrstuv&c=best",
123+
"https://www.example.com/path",
124+
"https://example.org",
125+
"https://example.com:8080/path",
126+
"https://api.example.com/v1/resources",
127+
"https://example.com/page#section1",
128+
"https://example.com/search?q=test#results",
129+
"https://user:[email protected]/",
130+
"https://192.168.1.1/admin",
131+
"https://example.com://path",
132+
"https://example.com/path%20with%20spaces",
133+
"https://example_example.com/path?query=with&spaces=true",
134+
];
135+
for url_str in test_vec {
136+
let url = LSPSUrl::parse(url_str.to_string());
137+
assert!(url.is_ok(), "Failed to parse URL: {}", url_str);
138+
}
139+
}
140+
141+
#[test]
142+
fn invalid_lsps_url() {
143+
let test_vec = vec![
144+
"ftp://ftp.example.org/pub/files/document.pdf",
145+
"sftp://user:[email protected]:22/uploads/",
146+
"ssh://[email protected]:2222",
147+
"lightning://03a.example.com/invoice?amount=10000",
148+
"ftp://[email protected]/files/",
149+
"https://例子.测试/path",
150+
"a123+-.://example.com",
151+
"a123+-.://example.com",
152+
"https:\\\\example.com\\path",
153+
"https:///whatever",
154+
"https://example.com/path with spaces",
155+
];
156+
for url_str in test_vec {
157+
let url = LSPSUrl::parse(url_str.to_string());
158+
assert!(url.is_err(), "Expected error for URL: {}", url_str);
159+
}
160+
}
161+
162+
#[test]
163+
fn parsing_errors() {
164+
let test_vec = vec![
165+
"example.com/path",
166+
"https://bad domain.com/",
167+
"https://example.com\0/path",
168+
"https://",
169+
"ht@ps://example.com",
170+
"http!://example.com",
171+
"1https://example.com",
172+
"https://://example.com",
173+
"https://example.com:port/path",
174+
"https://:8080/path",
175+
"https:",
176+
"://",
177+
"https://example.com\0/path",
178+
];
179+
for url_str in test_vec {
180+
let url = LSPSUrl::parse(url_str.to_string());
181+
assert!(url.is_err(), "Expected error for URL: {}", url_str);
182+
}
183+
}
184+
185+
fn host_strategy() -> impl Strategy<Value = String> {
186+
prop_oneof![
187+
proptest::string::string_regex(
188+
"[a-z0-9]+(?:-[a-z0-9]+)*(?:\\.[a-z0-9]+(?:-[a-z0-9]+)*)*"
189+
)
190+
.unwrap(),
191+
(0u8..=255u8, 0u8..=255u8, 0u8..=255u8, 0u8..=255u8)
192+
.prop_map(|(a, b, c, d)| format!("{}.{}.{}.{}", a, b, c, d))
193+
]
194+
}
195+
196+
proptest! {
197+
#[test]
198+
fn proptest_parse_round_trip(
199+
host in host_strategy(),
200+
port in proptest::option::of(0u16..=65535u16),
201+
path in proptest::option::of(proptest::string::string_regex("[a-zA-Z0-9._%&=:@/-]{0,20}").unwrap()),
202+
query in proptest::option::of(proptest::string::string_regex("[a-zA-Z0-9._%&=:@/-]{0,20}").unwrap()),
203+
fragment in proptest::option::of(proptest::string::string_regex("[a-zA-Z0-9._%&=:@/-]{0,20}").unwrap())
204+
) {
205+
let mut url = format!("https://{}", host);
206+
if let Some(p) = port {
207+
url.push_str(&format!(":{}", p));
208+
}
209+
if let Some(pth) = &path {
210+
url.push('/');
211+
url.push_str(pth);
212+
}
213+
if let Some(q) = &query {
214+
url.push('?');
215+
url.push_str(q);
216+
}
217+
if let Some(f) = &fragment {
218+
url.push('#');
219+
url.push_str(f);
220+
}
221+
222+
let parsed = LSPSUrl::parse(url.clone()).expect("should parse");
223+
prop_assert_eq!(parsed.url(), url.as_str());
224+
prop_assert_eq!(parsed.url_length(), url.chars().count());
225+
}
226+
}
227+
}

0 commit comments

Comments
 (0)