Skip to content
This repository was archived by the owner on Mar 11, 2025. It is now read-only.

Commit 7a5bfeb

Browse files
Kijewskidjc
authored andcommitted
Make json filter safe
Previously the built-in json filter had an issue that made it unsafe to use in HTML data. When used in HTML attributes an attacker who is able to supply an arbitrary string that should be JSON encoded could close the containing HTML element e.g. with `"</div>"`, and write arbitrary HTML code afterwards as long as they use apostrophes instead of quotation marks. The programmer could make this use case safe by explicitly escaping the JSON result: `{{data|json|escape}}`. In a `<script>` context the json filter was not usable at all, because in scripts HTML escaped entities are not parsed outside of XHTML documents. Without using the safe filter an attacker could close the current script using `"</script>"`. This PR fixes the problem by always escaping less-than, greater-than, ampersand, and apostrophe characters using their JSON unicode escape sequence `\u00xx`. Unless the programmer explicitly uses the safe filter, quotation marks are HTML encoded as `&quot`. In scripts the programmer should use the safe filter, otherwise not.
1 parent 8fed983 commit 7a5bfeb

File tree

9 files changed

+193
-40
lines changed

9 files changed

+193
-40
lines changed

askama_escape/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ maintenance = { status = "actively-developed" }
1818
[dev-dependencies]
1919
criterion = "0.3"
2020

21+
[features]
22+
json = []
23+
2124
[[bench]]
2225
name = "all"
2326
harness = false

askama_escape/src/lib.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,57 @@ pub trait Escaper {
165165

166166
const FLAG: u8 = b'>' - b'"';
167167

168+
/// Escape chevrons, ampersand and apostrophes for use in JSON
169+
#[cfg(feature = "json")]
170+
#[derive(Debug, Clone, Default)]
171+
pub struct JsonEscapeBuffer(Vec<u8>);
172+
173+
#[cfg(feature = "json")]
174+
impl JsonEscapeBuffer {
175+
pub fn new() -> Self {
176+
Self(Vec::new())
177+
}
178+
179+
pub fn finish(self) -> String {
180+
unsafe { String::from_utf8_unchecked(self.0) }
181+
}
182+
}
183+
184+
#[cfg(feature = "json")]
185+
impl std::io::Write for JsonEscapeBuffer {
186+
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
187+
macro_rules! push_esc_sequence {
188+
($start:ident, $i:ident, $self:ident, $bytes:ident, $quote:expr) => {{
189+
if $start < $i {
190+
$self.0.extend_from_slice(&$bytes[$start..$i]);
191+
}
192+
$self.0.extend_from_slice($quote);
193+
$start = $i + 1;
194+
}};
195+
}
196+
197+
self.0.reserve(bytes.len());
198+
let mut start = 0;
199+
for (i, b) in bytes.iter().enumerate() {
200+
match *b {
201+
b'&' => push_esc_sequence!(start, i, self, bytes, br#"\u0026"#),
202+
b'\'' => push_esc_sequence!(start, i, self, bytes, br#"\u0027"#),
203+
b'<' => push_esc_sequence!(start, i, self, bytes, br#"\u003c"#),
204+
b'>' => push_esc_sequence!(start, i, self, bytes, br#"\u003e"#),
205+
_ => (),
206+
}
207+
}
208+
if start < bytes.len() {
209+
self.0.extend_from_slice(&bytes[start..]);
210+
}
211+
Ok(bytes.len())
212+
}
213+
214+
fn flush(&mut self) -> std::io::Result<()> {
215+
Ok(())
216+
}
217+
}
218+
168219
#[cfg(test)]
169220
mod tests {
170221
use super::*;

askama_shared/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ edition = "2018"
1313
[features]
1414
default = ["config", "humansize", "num-traits", "percent-encoding"]
1515
config = ["serde", "toml"]
16-
json = ["serde", "serde_json"]
16+
json = ["serde", "serde_json", "askama_escape/json"]
1717
yaml = ["serde", "serde_yaml"]
1818

1919
[dependencies]

askama_shared/src/filters/json.rs

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,40 @@
11
use crate::error::{Error, Result};
2-
use askama_escape::{Escaper, MarkupDisplay};
2+
use askama_escape::JsonEscapeBuffer;
33
use serde::Serialize;
4+
use serde_json::to_writer_pretty;
45

5-
/// Serialize to JSON (requires `serde_json` feature)
6+
/// Serialize to JSON (requires `json` feature)
67
///
7-
/// ## Errors
8+
/// The generated string does not contain ampersands `&`, chevrons `< >`, or apostrophes `'`.
9+
/// To use it in a `<script>` you can combine it with the safe filter:
810
///
9-
/// This will panic if `S`'s implementation of `Serialize` decides to fail,
10-
/// or if `T` contains a map with non-string keys.
11-
pub fn json<E: Escaper, S: Serialize>(e: E, s: &S) -> Result<MarkupDisplay<E, String>> {
12-
match serde_json::to_string_pretty(s) {
13-
Ok(s) => Ok(MarkupDisplay::new_safe(s, e)),
14-
Err(e) => Err(Error::from(e)),
15-
}
11+
/// ``` html
12+
/// <script>
13+
/// var data = {{data|json|safe}};
14+
/// </script>
15+
/// ```
16+
///
17+
/// To use it in HTML attributes, you can either use it in quotation marks `"{{data|json}}"` as is,
18+
/// or in apostrophes with the (optional) safe filter `'{{data|json|safe}}'`.
19+
/// In HTML texts the output of e.g. `<pre>{{data|json|safe}}</pre>` is safe, too.
20+
pub fn json<S: Serialize>(s: S) -> Result<String> {
21+
let mut writer = JsonEscapeBuffer::new();
22+
to_writer_pretty(&mut writer, &s).map_err(Error::from)?;
23+
Ok(writer.finish())
1624
}
1725

1826
#[cfg(test)]
1927
mod tests {
2028
use super::*;
21-
use askama_escape::Html;
2229

2330
#[test]
2431
fn test_json() {
25-
assert_eq!(json(Html, &true).unwrap().to_string(), "true");
26-
assert_eq!(json(Html, &"foo").unwrap().to_string(), r#""foo""#);
32+
assert_eq!(json(true).unwrap(), "true");
33+
assert_eq!(json("foo").unwrap(), r#""foo""#);
34+
assert_eq!(json(&true).unwrap(), "true");
35+
assert_eq!(json(&"foo").unwrap(), r#""foo""#);
2736
assert_eq!(
28-
json(Html, &vec!["foo", "bar"]).unwrap().to_string(),
37+
json(&vec!["foo", "bar"]).unwrap(),
2938
r#"[
3039
"foo",
3140
"bar"

askama_shared/src/generator.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1104,7 +1104,7 @@ impl<'a, S: std::hash::BuildHasher> Generator<'a, S> {
11041104
return Ok(DisplayWrap::Unwrapped);
11051105
}
11061106

1107-
if name == "escape" || name == "safe" || name == "e" || name == "json" {
1107+
if name == "escape" || name == "safe" || name == "e" {
11081108
buf.write(&format!(
11091109
"::askama::filters::{}({}, ",
11101110
name, self.input.escaper

book/src/filters.md

Lines changed: 47 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,53 @@ Count the words in that string
231231
5
232232
```
233233

234+
## Optional / feature gated filters
235+
236+
The following filters can be enabled by requesting the respective feature in the Cargo.toml
237+
[dependencies section](https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html), e.g.
238+
239+
```
240+
[dependencies]
241+
askama = { version = "0.11.0", features = "serde-json" }
242+
```
243+
244+
### `json` | `tojson`
245+
246+
Enabling the `serde-json` feature will enable the use of the `json` filter.
247+
This will output formatted JSON for any value that implements the required
248+
[`Serialize`](https://docs.rs/serde/1.*/serde/trait.Serialize.html) trait.
249+
The generated string does not contain ampersands `&`, chevrons `< >`, or apostrophes `'`.
250+
251+
To use it in a `<script>` you can combine it with the safe filter.
252+
In HTML attributes, you can either use it in quotation marks `"{{data|json}}"` as is,
253+
or in apostrophes with the (optional) safe filter `'{{data|json|safe}}'`.
254+
In HTML texts the output of e.g. `<pre>{{data|json|safe}}</pre>` is safe, too.
255+
256+
```
257+
Good: <li data-extra="{{data|json}}">…</li>
258+
Good: <li data-extra='{{data|json|safe}}'>…</li>
259+
Good: <pre>{{data|json|safe}}</pre>
260+
Good: <script>var data = {{data|json|safe}};</script>
261+
262+
Bad: <li data-extra="{{data|json|safe}}">…</li>
263+
Bad: <script>var data = {{data|json}};</script>
264+
Bad: <script>var data = "{{data|json|safe}}";</script>
265+
266+
Ugly: <script>var data = "{{data|json}}";</script>
267+
Ugly: <script>var data = '{{data|json|safe}}';</script>
268+
```
269+
270+
### `yaml`
271+
272+
Enabling the `serde-yaml` feature will enable the use of the `yaml` filter.
273+
This will output formatted YAML for any value that implements the required
274+
[`Serialize`](https://docs.rs/serde/1.*/serde/trait.Serialize.html) trait.
275+
276+
```jinja
277+
{{ foo|yaml }}
278+
```
279+
280+
234281
## Custom Filters
235282

236283
To define your own filters, simply have a module named filters in scope of the context deriving a `Template` impl.
@@ -255,26 +302,3 @@ fn main() {
255302
assert_eq!(t.render().unwrap(), "faa");
256303
}
257304
```
258-
259-
## The `json` filter
260-
261-
Enabling the `serde-json` filter will enable the use of the `json` filter.
262-
This will output formatted JSON for any value that implements the required
263-
`Serialize` trait.
264-
265-
```
266-
{
267-
"foo": "{{ foo }}",
268-
"bar": {{ bar|json }}
269-
}
270-
```
271-
272-
## The `yaml` filter
273-
274-
Enabling the `serde-yaml` filter will enable the use of the `yaml` filter.
275-
This will output formatted JSON for any value that implements the required
276-
`Serialize` trait.
277-
278-
```
279-
{{ foo|yaml }}
280-
```

testing/templates/json.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
{
22
"foo": "{{ foo }}",
3-
"bar": {{ bar|json }}
3+
"bar": {{ bar|json|safe }}
44
}

testing/tests/filters.rs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,3 +202,63 @@ fn test_filter_truncate() {
202202
};
203203
assert_eq!(t.render().unwrap(), "alpha baralpha...");
204204
}
205+
206+
#[cfg(feature = "serde-json")]
207+
#[derive(Template)]
208+
#[template(source = r#"<li data-name="{{name|json}}"></li>"#, ext = "html")]
209+
struct JsonAttributeTemplate<'a> {
210+
name: &'a str,
211+
}
212+
213+
#[cfg(feature = "serde-json")]
214+
#[test]
215+
fn test_json_attribute() {
216+
let t = JsonAttributeTemplate {
217+
name: r#""><button>Hacked!</button>"#,
218+
};
219+
assert_eq!(
220+
t.render().unwrap(),
221+
r#"<li data-name="&quot;\&quot;\u003e\u003cbutton\u003eHacked!\u003c/button\u003e&quot;"></li>"#
222+
);
223+
}
224+
225+
#[cfg(feature = "serde-json")]
226+
#[derive(Template)]
227+
#[template(source = r#"<li data-name='{{name|json|safe}}'></li>"#, ext = "html")]
228+
struct JsonAttribute2Template<'a> {
229+
name: &'a str,
230+
}
231+
232+
#[cfg(feature = "serde-json")]
233+
#[test]
234+
fn test_json_attribute2() {
235+
let t = JsonAttribute2Template {
236+
name: r#"'><button>Hacked!</button>"#,
237+
};
238+
assert_eq!(
239+
t.render().unwrap(),
240+
r#"<li data-name='"\u0027\u003e\u003cbutton\u003eHacked!\u003c/button\u003e"'></li>"#
241+
);
242+
}
243+
244+
#[cfg(feature = "serde-json")]
245+
#[derive(Template)]
246+
#[template(
247+
source = r#"<script>var user = {{name|json|safe}}</script>"#,
248+
ext = "html"
249+
)]
250+
struct JsonScriptTemplate<'a> {
251+
name: &'a str,
252+
}
253+
254+
#[cfg(feature = "serde-json")]
255+
#[test]
256+
fn test_json_script() {
257+
let t = JsonScriptTemplate {
258+
name: r#"</script><button>Hacked!</button>"#,
259+
};
260+
assert_eq!(
261+
t.render().unwrap(),
262+
r#"<script>var user = "\u003c/script\u003e\u003cbutton\u003eHacked!\u003c/button\u003e"</script>"#
263+
);
264+
}

testing/tests/whitespace.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
#![cfg(feature = "serde-json")]
2+
13
use askama::Template;
24

35
#[derive(askama::Template, Default)]
@@ -37,5 +39,9 @@ fn test_extra_whitespace() {
3739
let mut template = AllowWhitespaces::default();
3840
template.nested_1.nested_2.array = &["a0", "a1", "a2", "a3"];
3941
template.nested_1.nested_2.hash.insert("key", "value");
42+
<<<<<<< HEAD
4043
assert_eq!(template.render().unwrap(), "\n0\n0\n0\n0\n\n\n\n0\n0\n0\n0\n0\n\na0\na1\nvalue\n\n\n\n\n\n[\n \"a0\",\n \"a1\",\n \"a2\",\n \"a3\"\n]\n[\n \"a0\",\n \"a1\",\n \"a2\",\n \"a3\"\n][\n \"a0\",\n \"a1\",\n \"a2\",\n \"a3\"\n]\n[\n \"a1\"\n][\n \"a1\"\n]\n[\n \"a1\",\n \"a2\"\n][\n \"a1\",\n \"a2\"\n]\n[\n \"a1\"\n][\n \"a1\"\n]1-1-1\n3333 3\n2222 2\n0000 0\n3333 3\n\ntruefalse\nfalsefalsefalse\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n");
44+
=======
45+
assert_eq!(template.render().unwrap(), "\n0\n0\n0\n0\n\n\n\n0\n0\n0\n0\n0\n\na0\na1\nvalue\n\n\n\n\n\n[\n &quot;a0&quot;,\n &quot;a1&quot;,\n &quot;a2&quot;,\n &quot;a3&quot;\n]\n[\n &quot;a0&quot;,\n &quot;a1&quot;,\n &quot;a2&quot;,\n &quot;a3&quot;\n][\n &quot;a0&quot;,\n &quot;a1&quot;,\n &quot;a2&quot;,\n &quot;a3&quot;\n]\n[\n &quot;a1&quot;\n][\n &quot;a1&quot;\n]\n[\n &quot;a1&quot;,\n &quot;a2&quot;\n][\n &quot;a1&quot;,\n &quot;a2&quot;\n]\n[\n &quot;a1&quot;\n][\n &quot;a1&quot;\n]1-1-1\n3333 3\n2222 2\n0000 0\n3333 3\n\ntruefalse\nfalsefalsefalse\n\n\n\n\n\n\n\n\n\n\n\n\n\n");
46+
>>>>>>> 29f0c06 (Make json filter safe)
4147
}

0 commit comments

Comments
 (0)