mirror of
https://github.com/leptos-rs/leptos.git
synced 2026-07-16 15:18:42 -04:00
Merge pull request #4812 from leptos-rs/escaping-fixes
Escaping fixes backported from 0.9
This commit is contained in:
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 < b & 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></textarea><script>alert('xss')</script>\
|
||||
</textarea>"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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></textarea><script>alert('xss')</script&\
|
||||
gt;</textarea>"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, "<");
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user