Merge pull request #4812 from leptos-rs/escaping-fixes

Escaping fixes backported from 0.9
This commit is contained in:
Greg Johnston
2026-06-25 06:39:48 -04:00
committed by GitHub
5 changed files with 243 additions and 22 deletions

View File

@@ -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 `</script>` 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 `</script>` must not be able to escape
/// the surrounding <script> tag in the streamed initial chunk.
#[test]
fn error_in_initial_chunk_escapes_script_close_tag() {
let ctx = SsrSharedContext::new();
ctx.register_error(
SerializedDataId(0),
ErrorId::from(0_usize),
Error::from(CustomError(
"boom</script><script>alert('pwned')</script><script>",
)),
);
let mut stream = ctx.pending_data().expect("pending_data on ssr");
let initial = block_on(stream.next()).expect("at least one chunk");
assert!(
!initial.contains("</script>"),
"initial chunk must not contain a literal `</script>` 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</script><script>x</script>")),
);
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("</script>"),
"streamed error chunk must not contain `</script>`: \
{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"
);
}
}

View File

@@ -205,3 +205,39 @@ fn ssr_option() {
assert_eq!(rendered.to_html(), "<option></option>");
}
#[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<HtmlElement<_, _, _>> = view! {
<div><textarea>"a < b & c"</textarea></div>
};
assert_eq!(
rendered.to_html(),
"<div><textarea>a &lt; b &amp; c</textarea></div>"
);
}
#[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 = "</textarea><script>alert('xss')</script>".to_string();
let rendered: View<HtmlElement<_, _, _>> = view! {
<textarea>{untrusted}</textarea>
};
assert_eq!(
rendered.to_html(),
"<textarea>&lt;/textarea&gt;&lt;script&gt;alert('xss')&lt;/script&gt;\
</textarea>"
);
}

View File

@@ -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" {

View File

@@ -403,7 +403,7 @@ html_elements! {
/// The `<template>` HTML element is a mechanism for holding HTML that is not to be rendered immediately when a page is loaded but may be instantiated subsequently during runtime using JavaScript.
template HtmlTemplateElement [] true,
/// The `<textarea>` HTML element represents a multi-line plain-text editing control, useful when you want to allow users to enter a sizeable amount of free-form text, for example a comment on a review or feedback form.
textarea HtmlTextAreaElement [autocomplete, cols, dirname, disabled, form, maxlength, minlength, name, placeholder, readonly, required, rows, wrap] false,
textarea HtmlTextAreaElement [autocomplete, cols, dirname, disabled, form, maxlength, minlength, name, placeholder, readonly, required, rows, wrap] true,
/// The `<tfoot>` HTML element defines a set of rows summarizing the columns of the table.
tfoot HtmlTableSectionElement [] true,
/// The `<th>` HTML element defines a cell as header of a group of table cells. The exact nature of this group is defined by the scope and headers attributes.
@@ -430,3 +430,25 @@ html_element_inner! {
/// The `<option>` HTML element is used to define an item contained in a `<select>`, an` <optgroup>`, or a `<datalist>` element. As such, `<option>` can represent menu items in popups and other lists of items in an HTML document.
option Option_ HtmlOptionElement [disabled, label, selected, value] true
}
#[cfg(all(test, feature = "ssr"))]
mod tests {
use crate::{
html::element::{textarea, ElementChild},
view::RenderHtml,
};
#[test]
fn textarea_escapes_child_content() {
// `<textarea>` content is escapable raw text per the HTML spec, so a
// child string containing `</textarea>` or `<script>` must be escaped
// rather than written verbatim.
let untrusted = "</textarea><script>alert('xss')</script>".to_string();
let html = textarea().child(untrusted).to_html();
assert_eq!(
html,
"<textarea>&lt;/textarea&gt;&lt;script&gt;alert('xss')&lt;/script&\
gt;</textarea>"
);
}
}

View File

@@ -18,7 +18,7 @@ use std::{
// any changes here should also be made in src/reactive_graph/guards.rs
macro_rules! render_primitive {
($($child_type:ty),* $(,)?) => {
($escape:literal; $($child_type:ty),* $(,)?) => {
$(
paste::paste! {
pub struct [<$child_type:camel State>](crate::renderer::types::Text, $child_type);
@@ -80,12 +80,24 @@ macro_rules! render_primitive {
self
}
fn to_html_with_buf(self, buf: &mut String, position: &mut Position, _escape: bool, _mark_branches: bool, _extra_attrs: Vec<AnyAttribute>) {
fn to_html_with_buf(self, buf: &mut String, position: &mut Position, escape: bool, _mark_branches: bool, _extra_attrs: Vec<AnyAttribute>) {
// add a comment node to separate from previous sibling, if any
if matches!(position, Position::NextChildAfterText) {
buf.push_str("<!>")
}
_ = write!(buf, "{}", self);
// `$escape` is `true` only for types whose `Display` output can
// contain HTML-significant characters (e.g. `char`). Numeric
// primitives emit a syntactic subset of HTML text and are written
// directly to avoid an intermediate allocation.
if $escape {
if escape {
buf.push_str(&html_escape::encode_text(&self.to_string()));
} else {
_ = write!(buf, "{}", self);
}
} else {
_ = write!(buf, "{}", self);
}
*position = Position::NextChildAfterText;
}
@@ -145,6 +157,7 @@ macro_rules! render_primitive {
}
render_primitive![
false;
usize,
u8,
u16,
@@ -159,7 +172,6 @@ render_primitive![
i128,
f32,
f64,
char,
bool,
IpAddr,
SocketAddr,
@@ -180,3 +192,54 @@ render_primitive![
NonZeroIsize,
NonZeroUsize,
];
// `char` can be any Unicode scalar value, including HTML-significant
// characters such as `<`, `>`, `&`, `"`, and `'`. Its body output must be
// escaped to avoid an HTML-injection sink.
render_primitive![true; char];
#[cfg(test)]
mod tests {
use crate::view::{Position, RenderHtml};
#[test]
fn char_escapes_html_special_characters() {
let mut buf = String::new();
'<'.to_html_with_buf(
&mut buf,
&mut Position::FirstChild,
true,
false,
vec![],
);
assert_eq!(buf, "&lt;");
}
#[test]
fn char_not_escaped_in_raw_text_context() {
// When the enclosing element opts out of escaping (e.g. `<script>`),
// the char must be written verbatim.
let mut buf = String::new();
'<'.to_html_with_buf(
&mut buf,
&mut Position::FirstChild,
false,
false,
vec![],
);
assert_eq!(buf, "<");
}
#[test]
fn numeric_primitive_renders_unescaped() {
let mut buf = String::new();
42u32.to_html_with_buf(
&mut buf,
&mut Position::FirstChild,
true,
false,
vec![],
);
assert_eq!(buf, "42");
}
}