Skip to content

Commit 4f74ef8

Browse files
authored
Fix escaped text in ssr stylesheets and scripts (#3933)
1 parent 16cae8a commit 4f74ef8

File tree

5 files changed

+441
-55
lines changed

5 files changed

+441
-55
lines changed

packages/document/src/elements/meta.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,12 @@ pub fn Meta(props: MetaProps) -> Element {
6969

7070
use_hook(|| {
7171
let document = document();
72+
let insert_link = document.create_head_component();
73+
74+
if !insert_link {
75+
return;
76+
}
77+
7278
document.create_meta(props);
7379
});
7480

packages/playwright-tests/fullstack.spec.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ test("hydration", async ({ page }) => {
3939
});
4040

4141
test("document elements", async ({ page }) => {
42-
await page.goto("http://localhost:9999");
42+
await page.goto("http://localhost:3333");
4343
// wait until the meta element is mounted
4444
const meta = page.locator("meta#meta-head[name='testing']");
4545
await meta.waitFor({ state: "attached" });

packages/ssr/src/cache.rs

Lines changed: 107 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -78,14 +78,52 @@ impl AddAssign<Segment> for StringChain {
7878
}
7979
}
8080

81-
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
81+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
82+
/// The escape text enum is used to mark segments that should be escaped
83+
/// when rendering. This is used to prevent XSS attacks by escaping user input.
84+
pub(crate) enum EscapeText {
85+
/// Always escape the text. This will be assigned if the text node is under
86+
/// a normal tag like a div in the template
87+
Escape,
88+
/// Don't escape the text. This will be assigned if the text node is under
89+
/// a script or style tag in the template
90+
NoEscape,
91+
/// Only escape the tag if this is rendered under a script or style tag in
92+
/// the parent template. This will be assigned if the text node is a root
93+
/// node in the template
94+
ParentEscape,
95+
}
96+
97+
impl EscapeText {
98+
/// Check if the text should be escaped based on the parent's resolved
99+
/// escape text value
100+
pub fn should_escape(&self, parent_escaped: bool) -> bool {
101+
match self {
102+
EscapeText::Escape => true,
103+
EscapeText::NoEscape => false,
104+
EscapeText::ParentEscape => parent_escaped,
105+
}
106+
}
107+
}
108+
109+
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
82110
pub(crate) enum Segment {
83111
/// A marker for where to insert an attribute with a given index
84112
Attr(usize),
85113
/// A marker for where to insert a node with a given index
86-
Node(usize),
114+
Node {
115+
index: usize,
116+
escape_text: EscapeText,
117+
},
87118
/// Text that we know is static in the template that is pre-rendered
88119
PreRendered(String),
120+
/// Text we know is static in the template that is pre-rendered that may or may not be escaped
121+
PreRenderedMaybeEscaped {
122+
/// The text to render
123+
value: String,
124+
/// Only render this text if the escaped value is this
125+
renderer_if_escaped: bool,
126+
},
89127
/// Anything between this and the segments at the index is only required for hydration. If you don't need to hydrate, you can safely skip to the section at the given index
90128
HydrationOnlySection(usize),
91129
/// A marker for where to insert a dynamic styles
@@ -127,7 +165,14 @@ impl StringCache {
127165
let mut cur_path = vec![];
128166

129167
for (root_idx, root) in template.template.roots.iter().enumerate() {
130-
from_template_recursive(root, &mut cur_path, root_idx, true, &mut chain)?;
168+
from_template_recursive(
169+
root,
170+
&mut cur_path,
171+
root_idx,
172+
true,
173+
EscapeText::ParentEscape,
174+
&mut chain,
175+
)?;
131176
}
132177

133178
Ok(Self {
@@ -141,6 +186,7 @@ fn from_template_recursive(
141186
cur_path: &mut Vec<usize>,
142187
root_idx: usize,
143188
is_root: bool,
189+
escape_text: EscapeText,
144190
chain: &mut StringChain,
145191
) -> Result<(), std::fmt::Error> {
146192
match root {
@@ -171,10 +217,18 @@ fn from_template_recursive(
171217
styles.push((name, value));
172218
} else if BOOL_ATTRS.contains(name) {
173219
if str_truthy(value) {
174-
write!(chain, " {name}=\"{value}\"",)?;
220+
write!(
221+
chain,
222+
" {name}=\"{}\"",
223+
askama_escape::escape(value, askama_escape::Html)
224+
)?;
175225
}
176226
} else {
177-
write!(chain, " {name}=\"{value}\"")?;
227+
write!(
228+
chain,
229+
" {name}=\"{}\"",
230+
askama_escape::escape(value, askama_escape::Html)
231+
)?;
178232
}
179233
}
180234
TemplateAttribute::Dynamic { id: index } => {
@@ -189,7 +243,11 @@ fn from_template_recursive(
189243
if !styles.is_empty() {
190244
write!(chain, " style=\"")?;
191245
for (name, value) in styles {
192-
write!(chain, "{name}:{value};")?;
246+
write!(
247+
chain,
248+
"{name}:{};",
249+
askama_escape::escape(value, askama_escape::Html)
250+
)?;
193251
}
194252
*chain += Segment::StyleMarker {
195253
inside_style_tag: true,
@@ -226,8 +284,15 @@ fn from_template_recursive(
226284
*chain += Segment::InnerHtmlMarker;
227285
}
228286

287+
// Escape the text in children if this is not a style or script tag. If it is a style
288+
// or script tag, we want to allow the user to write code inside the tag
289+
let escape_text = match *tag {
290+
"style" | "script" => EscapeText::NoEscape,
291+
_ => EscapeText::Escape,
292+
};
293+
229294
for child in *children {
230-
from_template_recursive(child, cur_path, root_idx, false, chain)?;
295+
from_template_recursive(child, cur_path, root_idx, false, escape_text, chain)?;
231296
}
232297
write!(chain, "</{tag}>")?;
233298
}
@@ -243,16 +308,45 @@ fn from_template_recursive(
243308
std::fmt::Result::Ok(())
244309
})?;
245310
}
246-
write!(
247-
chain,
248-
"{}",
249-
askama_escape::escape(text, askama_escape::Html)
250-
)?;
311+
match escape_text {
312+
// If we know this is statically escaped we can just write it out
313+
// rsx! { div { "hello" } }
314+
EscapeText::Escape => {
315+
write!(
316+
chain,
317+
"{}",
318+
askama_escape::escape(text, askama_escape::Html)
319+
)?;
320+
}
321+
// If we know this is statically not escaped we can just write it out
322+
// rsx! { script { "console.log('hello')" } }
323+
EscapeText::NoEscape => {
324+
write!(chain, "{}", text)?;
325+
}
326+
// Otherwise, write out both versions and let the renderer decide which one to use
327+
// at runtime
328+
// rsx! { "console.log('hello')" }
329+
EscapeText::ParentEscape => {
330+
*chain += Segment::PreRenderedMaybeEscaped {
331+
value: text.to_string(),
332+
renderer_if_escaped: false,
333+
};
334+
*chain += Segment::PreRenderedMaybeEscaped {
335+
value: askama_escape::escape(text, askama_escape::Html).to_string(),
336+
renderer_if_escaped: true,
337+
};
338+
}
339+
}
251340
if is_root {
252341
chain.if_hydration_enabled(|chain| write!(chain, "<!--#-->"))?;
253342
}
254343
}
255-
TemplateNode::Dynamic { id: idx } => *chain += Segment::Node(*idx),
344+
TemplateNode::Dynamic { id: idx } => {
345+
*chain += Segment::Node {
346+
index: *idx,
347+
escape_text,
348+
}
349+
}
256350
}
257351

258352
Ok(())

packages/ssr/src/renderer.rs

Lines changed: 78 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ impl Renderer {
9595
scope: ScopeId,
9696
) -> std::fmt::Result {
9797
let node = dom.get_scope(scope).unwrap().root_node();
98-
self.render_template(buf, dom, node)?;
98+
self.render_template(buf, dom, node, true)?;
9999

100100
Ok(())
101101
}
@@ -105,6 +105,7 @@ impl Renderer {
105105
mut buf: &mut W,
106106
dom: &VirtualDom,
107107
template: &VNode,
108+
parent_escaped: bool,
108109
) -> std::fmt::Result {
109110
let entry = self
110111
.template_cache
@@ -158,50 +159,66 @@ impl Renderer {
158159
}
159160
}
160161
}
161-
Segment::Node(idx) => match &template.dynamic_nodes[*idx] {
162-
DynamicNode::Component(node) => {
163-
if let Some(render_components) = self.render_components.clone() {
164-
let scope_id = node.mounted_scope_id(*idx, template, dom).unwrap();
165-
166-
render_components(self, &mut buf, dom, scope_id)?;
167-
} else {
168-
let scope = node.mounted_scope(*idx, template, dom).unwrap();
169-
let node = scope.root_node();
170-
self.render_template(buf, dom, node)?
171-
}
172-
}
173-
DynamicNode::Text(text) => {
174-
// in SSR, we are concerned that we can't hunt down the right text node since they might get merged
175-
if self.pre_render {
176-
write!(buf, "<!--node-id{}-->", self.dynamic_node_id)?;
177-
self.dynamic_node_id += 1;
162+
Segment::Node { index, escape_text } => {
163+
let escaped = escape_text.should_escape(parent_escaped);
164+
match &template.dynamic_nodes[*index] {
165+
DynamicNode::Component(node) => {
166+
if let Some(render_components) = self.render_components.clone() {
167+
let scope_id =
168+
node.mounted_scope_id(*index, template, dom).unwrap();
169+
170+
render_components(self, &mut buf, dom, scope_id)?;
171+
} else {
172+
let scope = node.mounted_scope(*index, template, dom).unwrap();
173+
let node = scope.root_node();
174+
self.render_template(buf, dom, node, escaped)?
175+
}
178176
}
177+
DynamicNode::Text(text) => {
178+
// in SSR, we are concerned that we can't hunt down the right text node since they might get merged
179+
if self.pre_render {
180+
write!(buf, "<!--node-id{}-->", self.dynamic_node_id)?;
181+
self.dynamic_node_id += 1;
182+
}
179183

180-
write!(
181-
buf,
182-
"{}",
183-
askama_escape::escape(&text.value, askama_escape::Html)
184-
)?;
184+
if escaped {
185+
write!(
186+
buf,
187+
"{}",
188+
askama_escape::escape(&text.value, askama_escape::Html)
189+
)?;
190+
} else {
191+
write!(buf, "{}", text.value)?;
192+
}
185193

186-
if self.pre_render {
187-
write!(buf, "<!--#-->")?;
194+
if self.pre_render {
195+
write!(buf, "<!--#-->")?;
196+
}
188197
}
189-
}
190-
DynamicNode::Fragment(nodes) => {
191-
for child in nodes {
192-
self.render_template(buf, dom, child)?;
198+
DynamicNode::Fragment(nodes) => {
199+
for child in nodes {
200+
self.render_template(buf, dom, child, escaped)?;
201+
}
193202
}
194-
}
195203

196-
DynamicNode::Placeholder(_) => {
197-
if self.pre_render {
198-
write!(buf, "<!--placeholder{}-->", self.dynamic_node_id)?;
199-
self.dynamic_node_id += 1;
204+
DynamicNode::Placeholder(_) => {
205+
if self.pre_render {
206+
write!(buf, "<!--placeholder{}-->", self.dynamic_node_id)?;
207+
self.dynamic_node_id += 1;
208+
}
200209
}
201210
}
202-
},
211+
}
203212

204213
Segment::PreRendered(contents) => write!(buf, "{contents}")?,
214+
Segment::PreRenderedMaybeEscaped {
215+
value,
216+
renderer_if_escaped,
217+
} => {
218+
if *renderer_if_escaped == parent_escaped {
219+
write!(buf, "{value}")?;
220+
}
221+
}
205222

206223
Segment::StyleMarker { inside_style_tag } => {
207224
if !accumulated_dynamic_styles.is_empty() {
@@ -266,6 +283,7 @@ impl Renderer {
266283

267284
#[test]
268285
fn to_string_works() {
286+
use crate::cache::EscapeText;
269287
use dioxus::prelude::*;
270288

271289
fn app() -> Element {
@@ -311,13 +329,22 @@ fn to_string_works() {
311329
PreRendered(">".to_string()),
312330
InnerHtmlMarker,
313331
PreRendered("Hello world 1 --&gt;".to_string()),
314-
Node(0),
332+
Node {
333+
index: 0,
334+
escape_text: EscapeText::Escape
335+
},
315336
PreRendered(
316337
"&lt;-- Hello world 2<div>nest 1</div><div></div><div>nest 2</div>"
317338
.to_string()
318339
),
319-
Node(1),
320-
Node(2),
340+
Node {
341+
index: 1,
342+
escape_text: EscapeText::Escape
343+
},
344+
Node {
345+
index: 2,
346+
escape_text: EscapeText::Escape
347+
},
321348
PreRendered("</div>".to_string())
322349
]
323350
);
@@ -331,6 +358,7 @@ fn to_string_works() {
331358

332359
#[test]
333360
fn empty_for_loop_works() {
361+
use crate::cache::EscapeText;
334362
use dioxus::prelude::*;
335363

336364
fn app() -> Element {
@@ -360,7 +388,10 @@ fn empty_for_loop_works() {
360388
RootNodeMarker,
361389
PreRendered("\"".to_string()),
362390
PreRendered(">".to_string()),
363-
Node(0),
391+
Node {
392+
index: 0,
393+
escape_text: EscapeText::Escape
394+
},
364395
PreRendered("</div>".to_string())
365396
]
366397
);
@@ -444,7 +475,11 @@ pub(crate) fn write_attribute<W: Write + ?Sized>(
444475
) -> std::fmt::Result {
445476
let name = &attr.name;
446477
match &attr.value {
447-
AttributeValue::Text(value) => write!(buf, " {name}=\"{value}\""),
478+
AttributeValue::Text(value) => write!(
479+
buf,
480+
" {name}=\"{}\"",
481+
askama_escape::escape(value, askama_escape::Html)
482+
),
448483
AttributeValue::Bool(value) => write!(buf, " {name}={value}"),
449484
AttributeValue::Int(value) => write!(buf, " {name}={value}"),
450485
AttributeValue::Float(value) => write!(buf, " {name}={value}"),
@@ -457,7 +492,9 @@ pub(crate) fn write_value_unquoted<W: Write + ?Sized>(
457492
value: &AttributeValue,
458493
) -> std::fmt::Result {
459494
match value {
460-
AttributeValue::Text(value) => write!(buf, "{}", value),
495+
AttributeValue::Text(value) => {
496+
write!(buf, "{}", askama_escape::escape(value, askama_escape::Html))
497+
}
461498
AttributeValue::Bool(value) => write!(buf, "{}", value),
462499
AttributeValue::Int(value) => write!(buf, "{}", value),
463500
AttributeValue::Float(value) => write!(buf, "{}", value),

0 commit comments

Comments
 (0)