clnrest: bound XML nesting depth before parsing
What changed, and why it matters
This update fixes a crash bug in Core Lightning's REST plugin (clnrest). An attacker could send a specially crafted XML request with hundreds of nested tags. The XML parser used by the plugin processes tags by recursive function calls, so extremely deep nesting would exhaust the thread's call stack and crash the plugin worker. The fix pre-scans raw request bytes to count nesting depth without recursion and rejects anything deeper than 256 levels with a 'bad request' error, preventing the crash.
Apply the patch and ensure clnrest is upgraded to a release containing this fix. Consider also reviewing other XML/JSON parser recursion limits in the codebase and monitoring for similar stack-exhaustion issues.
Security signals we found
Stack overflow via recursive XML parsing (denial of service)
Pre-parse iterative depth bound to prevent parser recursion
New BadRequest error response for rejected XML
Evidence from the diff
roxmltree’s tokenizer is recursive-descent (parse_element -> parse_content), so Document::parse recurses once per element nesting level. A deeply nested but otherwise valid XML request can overflow the thread stack before a DOM is built, crashing clnrest’s worker thread and killing the plugin. The patch adds an iterative, pre-parse depth check on raw request bytes in plugins/rest-plugin/src/handlers.rs, bounded at MAX_XML_NESTING = 256. It handles comments, CDATA, processing instructions, DOCTYPE, closing tags, and quoted attributes, and returns HTTP 400 for over-deep input. A new AppError::BadRequest variant is added in structs.rs to support the response.
Changed components
plugins/rest-plugin/src/handlers.rsplugins/rest-plugin/src/structs.rsclnrest plugin worker threadroxmltree XML parserInspect captured patch +131 / −0
### plugins/rest-plugin/src/handlers.rs
@@ -26,6 +26,124 @@ use crate::{
/// Maximum size (in bytes) of an accepted request body.
/// Oversized bodies are rejected with 413.
pub const MAX_BODY_SIZE: usize = 2 * 1024 * 1024; // 2 MiB
+/// We refuse to convert XML nested deeper than this. roxmltree's tokenizer is
+/// recursive-descent (`parse_element` -> `parse_content`), so `Document::parse`
+/// itself recurses once per element nesting level and overflows the thread
+/// stack on deep input before we ever get a DOM to inspect. The depth must
+/// therefore be bounded on the raw bytes, before parsing. This mirrors the
+/// JSON depth bound in common/json_parse_simple.c; real RPC params nest only a
+/// handful of levels, far below this.
+const MAX_XML_NESTING: usize = 256;
+
+/// Find the first occurrence of `needle` in `haystack`, or None.
+fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
+ haystack.windows(needle.len()).position(|w| w == needle)
+}
+
+/// Iteratively bound the element nesting depth of `s` without building a DOM
+/// or recursing, so it cannot itself overflow. Runs *before* roxmltree's
+/// recursive parse; only needs to be exact on well-formed XML (malformed input
+/// is rejected downstream) and a miscount can only over-reject, never
+/// under-protect.
+fn xml_nesting_ok(s: &str) -> bool {
+ let b = s.as_bytes();
+ let mut depth = 0usize;
+ let mut i = 0usize;
+ while i < b.len() {
+ if b[i] != b'<' {
+ i += 1;
+ continue;
+ }
+ // Comments: <!-- ... -->.
+ if b[i..].starts_with(b"<!--") {
+ match find_bytes(&b[i + 4..], b"-->") {
+ Some(off) => i += 4 + off + 3,
+ None => return true, // malformed; parse rejects it
+ }
+ continue;
+ }
+ // CDATA: <![CDATA[ ... ]]>.
+ if b[i..].starts_with(b"<![CDATA[") {
+ match find_bytes(&b[i + 9..], b"]]>") {
+ Some(off) => i += 9 + off + 3,
+ None => return true,
+ }
+ continue;
+ }
+ // Processing instructions and declarations: <? ... ?>.
+ if b[i..].starts_with(b"<?") {
+ match find_bytes(&b[i + 2..], b"?>") {
+ Some(off) => i += 2 + off + 2,
+ None => return true,
+ }
+ continue;
+ }
+ // DOCTYPE: <!DOCTYPE ...> (may contain a bracketed internal subset).
+ if b[i..].starts_with(b"<!") {
+ let mut j = i + 2;
+ let mut subset = 0usize;
+ while j < b.len() {
+ match b[j] {
+ b'[' => subset += 1,
+ b']' if subset > 0 => subset -= 1,
+ b'>' if subset == 0 => {
+ i = j + 1;
+ break;
+ }
+ _ => (),
+ }
+ j += 1;
+ }
+ if j >= b.len() {
+ return true;
+ }
+ continue;
+ }
+ // Closing tag: </name>.
+ if b[i..].starts_with(b"</") {
+ match find_bytes(&b[i + 2..], b">") {
+ Some(off) => {
+ if depth == 0 {
+ return true; // malformed; parse rejects it
+ }
+ depth -= 1;
+ i += 2 + off + 1;
+ }
+ None => return true,
+ }
+ continue;
+ }
+ // Opening tag <name ...> or self-closing <name .../>. Scan to the end
+ // of the tag, honouring quoted attribute values.
+ let mut j = i + 1;
+ let mut in_quote: Option<u8> = None;
+ while j < b.len() {
+ let c = b[j];
+ if let Some(q) = in_quote {
+ if c == q {
+ in_quote = None;
+ }
+ } else if c == b'"' || c == b'\'' {
+ in_quote = Some(c);
+ } else if c == b'>' {
+ break;
+ }
+ j += 1;
+ }
+ if j >= b.len() {
+ return true;
+ }
+ // Self-closing tags don't increase the nesting depth.
+ if b[j - 1] != b'/' {
+ depth += 1;
+ if depth > MAX_XML_NESTING {
+ return false;
+ }
+ }
+ i = j + 1;
+ }
+ true
+}
/* Handler for list-methods */
#[utoipa::path(
@@ -262,6 +380,16 @@ fn request_body_to_rpc_params(
),
})
})?;
+ // Bound element nesting on the raw bytes *before* parsing: roxmltree's
+ // tokenizer recurses per nesting level, so parsing deep input
+ // overflows the stack before we get a DOM to inspect.
+ if !xml_nesting_ok(req_str) {
+ return Err(AppError::BadRequest(RpcError {
+ code: None,
+ data: None,
+ message: format!("XML request is nested deeper than {MAX_XML_NESTING} levels"),
+ }));
+ }
let json_with_root = roxmltree_to_serde::xml_str_to_json(
req_str,
&roxmltree_to_serde::Config::new_with_defaults(),
### plugins/rest-plugin/src/structs.rs
@@ -25,6 +25,7 @@ use utoipa::{
#[derive(Debug)]
pub enum AppError {
+ BadRequest(RpcError),
Unauthorized(RpcError),
Forbidden(RpcError),
NotFound(RpcError),
@@ -37,6 +38,7 @@ pub enum AppError {
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, error_message) = match self {
+ AppError::BadRequest(err) => (StatusCode::BAD_REQUEST, err),
AppError::Unauthorized(err) => (StatusCode::UNAUTHORIZED, err),
AppError::Forbidden(err) => (StatusCode::FORBIDDEN, err),
AppError::NotFound(err) => (StatusCode::NOT_FOUND, err),
@@ -54,6 +56,7 @@ impl IntoResponse for AppError {
impl std::fmt::Display for AppError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
+ AppError::BadRequest(err) => write!(f, "Bad Request: {err}"),
AppError::Unauthorized(err) => write!(f, "Unauthorized: {err}"),
AppError::Forbidden(err) => write!(f, "Forbidden: {err}"),
AppError::NotFound(err) => write!(f, "Not Found: {err}"),Why this scored 72/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.