diff --git a/hydration_context/src/ssr.rs b/hydration_context/src/ssr.rs index 9986c695c..9a9594532 100644 --- a/hydration_context/src/ssr.rs +++ b/hydration_context/src/ssr.rs @@ -182,12 +182,18 @@ impl SharedContext for SsrSharedContext { initial_chunk.push_str("__SERIALIZED_ERRORS=["); for error in mem::take(&mut *self.errors.write().or_poisoned()) { + // Debug-format first to get a valid, quoted JS string literal + // (escaping `"`, `\`, control chars), then rewrite every remaining + // `<` to a single-backslash `<` JS unicode escape. Escaping + // *after* `{:?}` keeps it one backslash, so the HTML tokenizer + // never sees `` while the browser's JS string parser + // still decodes `<` straight back to `<` for the consumer. + let msg = + format!("{:?}", error.2.to_string()).replace('<', "\\u003c"); _ = write!( initial_chunk, - "[{}, {}, {:?}],", - error.0 .0, - error.1, - error.2.to_string() + "[{}, {}, {}],", + error.0 .0, error.1, msg ); } initial_chunk.push_str("];"); @@ -293,12 +299,14 @@ impl Stream for AsyncDataStream { let sealed = self.sealed_error_boundaries.read().or_poisoned(); for error in mem::take(&mut *self.errors.write().or_poisoned()) { if !sealed.contains(&error.0) { + // see the initial-chunk path: Debug-format, then single- + // backslash-escape `<` so the JS parser decodes it back to `<` + let msg = format!("{:?}", error.2.to_string()) + .replace('<', "\\u003c"); _ = write!( resolved, - "__SERIALIZED_ERRORS.push([{}, {}, {:?}]);", - error.0 .0, - error.1, - error.2.to_string() + "__SERIALIZED_ERRORS.push([{}, {}, {}]);", + error.0 .0, error.1, msg ); } } @@ -325,3 +333,101 @@ impl ResolvedData { write!(buf, "{}: {:?}", id.0, ser).unwrap(); } } + +#[cfg(test)] +mod tests { + use super::*; + use futures::{executor::block_on, StreamExt}; + use std::fmt; + + #[derive(Debug)] + struct CustomError(&'static str); + + impl fmt::Display for CustomError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.0) + } + } + + impl std::error::Error for CustomError {} + + /// An error message containing `` must not be able to escape + /// the surrounding "), + "initial chunk must not contain a literal `` substring, \ + got: {initial}" + ); + assert!( + !initial.contains('<'), + "initial chunk must not contain a literal `<` character anywhere \ + inside the serialized errors, got: {initial}" + ); + assert!( + initial.contains("\\u003c") && !initial.contains("\\\\u003c"), + "expected a single-backslash `\\u003c` escape in place of `<` (a \ + double backslash would be decoded to a literal `\\u003c` and \ + shown raw to the user), got: {initial}" + ); + } + + /// The same escape must be applied to errors emitted later via the + /// async stream (AsyncDataStream::poll_next). + #[test] + fn error_in_async_stream_escapes_script_close_tag() { + let ctx = SsrSharedContext::new(); + + // park one async resource so AsyncDataStream emits a follow-up chunk + ctx.write_async( + SerializedDataId(1), + Box::pin(async { String::from("\"ok\"") }), + ); + + let mut stream = ctx.pending_data().expect("pending_data on ssr"); + // skip the initial setup chunk; we want the next one + let _initial = block_on(stream.next()).expect("initial chunk"); + + // register an error after pending_data() has been called so it is + // serialized through the streaming path rather than the initial chunk + ctx.register_error( + SerializedDataId(2), + ErrorId::from(7_usize), + Error::from(CustomError("late")), + ); + + let mut saw_error = false; + while let Some(chunk) = block_on(stream.next()) { + if chunk.contains("__SERIALIZED_ERRORS.push") { + saw_error = true; + assert!( + !chunk.contains(""), + "streamed error chunk must not contain ``: \ + {chunk}" + ); + assert!( + chunk.contains("\\u003c") && !chunk.contains("\\\\u003c"), + "streamed error chunk should carry a single-backslash \ + escaped `<`: {chunk}" + ); + } + } + assert!( + saw_error, + "expected at least one streamed __SERIALIZED_ERRORS.push chunk" + ); + } +} diff --git a/leptos/tests/ssr.rs b/leptos/tests/ssr.rs index 2b8e62108..373c3221b 100644 --- a/leptos/tests/ssr.rs +++ b/leptos/tests/ssr.rs @@ -205,3 +205,39 @@ fn ssr_option() { assert_eq!(rendered.to_html(), ""); } + +#[cfg(feature = "ssr")] +#[test] +fn ssr_textarea_escapes_static_content() { + use leptos::prelude::*; + + // Nested (non-top-level) static textarea exercises the macro's inert + // HTML path; its content must be HTML-escaped. + let rendered: View> = view! { +
+ }; + + assert_eq!( + rendered.to_html(), + "
" + ); +} + +#[cfg(feature = "ssr")] +#[test] +fn ssr_textarea_escapes_dynamic_content() { + use leptos::prelude::*; + + // A dynamic child makes the textarea non-inert, exercising the runtime + // render path; its content must also be HTML-escaped. + let untrusted = "".to_string(); + let rendered: View> = view! { + + }; + + assert_eq!( + rendered.to_html(), + "" + ); +} diff --git a/leptos_macro/src/view/mod.rs b/leptos_macro/src/view/mod.rs index 17bc1f8cd..d8e0f9adb 100644 --- a/leptos_macro/src/view/mod.rs +++ b/leptos_macro/src/view/mod.rs @@ -337,9 +337,7 @@ fn inert_element_to_tokens( Node::Element(node) => { let self_closing = is_self_closing(node); let el_name = node.name().to_string(); - let escape = el_name != "script" - && el_name != "style" - && el_name != "textarea"; + let escape = el_name != "script" && el_name != "style"; // opening tag html.push('<'); @@ -451,9 +449,7 @@ fn inert_svg_element_to_tokens( .map(str::to_string) .unwrap_or(el_name); - let escape = el_name != "script" - && el_name != "style" - && el_name != "textarea"; + let escape = el_name != "script" && el_name != "style"; // opening tag html.push('<'); @@ -740,9 +736,7 @@ fn node_to_tokens( Node::Element(el_node) => { if !top_level && is_inert { let el_name = el_node.name().to_string(); - let escape = el_name != "script" - && el_name != "style" - && el_name != "textarea"; + let escape = el_name != "script" && el_name != "style"; let el_name = el_node.name().to_string(); if is_svg_element(&el_name) && el_name != "svg" { diff --git a/tachys/src/html/element/elements.rs b/tachys/src/html/element/elements.rs index 6bf1250fb..ce0c29df3 100644 --- a/tachys/src/html/element/elements.rs +++ b/tachys/src/html/element/elements.rs @@ -403,7 +403,7 @@ html_elements! { /// The `