Skip to content

Commit b640478

Browse files
committed
Add filter support, add few escaping filters
- feature: add filters support for template syntax - feature: add `builtin.html_entities` filter - feature: add `builtin.quoted_shell_argument` filter
1 parent dd1a663 commit b640478

File tree

13 files changed

+276
-51
lines changed

13 files changed

+276
-51
lines changed

doc/changelog.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,4 +65,4 @@ v0.3.6
6565
dictionaries can be used instead of multiple ``if`` statements or to refer
6666
to the same values multiple times
6767
* feature: Add python list syntax support: ``["item1", "item2"]``
68-
68+
* feature: Add support for escaping of the variables no only validating

doc/index.rst

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,31 @@ Results in (note the indentation):
6565
}
6666
}
6767
68+
Since v0.3.6 it also works well for HTML and has autoescaping,
69+
here is an example:
70+
71+
.. code-block:: html
72+
73+
## syntax: indent
74+
## filter default: builtin.html_entities ### autoescape
75+
## validate n: [0-9]+
76+
<!DOCTYPE html>
77+
<html>
78+
<body>
79+
<h1>{{ title }}</h1> ### auto escaped
80+
<div class="user_canvas"
81+
style="position: absolute; ### note: it's unsafe to just
82+
left: {{ x | n }}px; ### autoescape values here
83+
top: {{ y | n }}px;">
84+
</div>
85+
</body>
86+
</html>
87+
88+
While the validation here looks like excessive (you must prevalidate it in
89+
the app) some time ago we thought that escaping values against XSS manually is
90+
a normal practice, now we always use autoescape. We look at validation of other
91+
values directly in the template as another way to minimize human error.
92+
6893

6994
Indices and tables
7095
==================

doc/template_syntax.rst

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,95 @@ safe to put anything except a quote, so for all variables printed in quotes we
176176
can add a ``quoted`` validator. See :ref:`front page <showcase>` for more
177177
practical example.
178178

179+
.. _filter:
180+
.. index:: pair: Filter; Statement
181+
182+
Filter Statement
183+
==================
184+
185+
Similarly to validate statements there is filter statement. This is useful
186+
if you produce HTML output and escape (qoute) some characters in it. This
187+
works similarly to how filters work in any other template engine, except it
188+
requires to declare an alias to validator, at the top of the template::
189+
190+
## filter h: builtin.html_entities
191+
<html>
192+
<body>
193+
<h1>{{ title | h }}</h1>
194+
</body>
195+
</html>
196+
197+
Of course, there is a default filter declared in the template too.
198+
199+
Validators and filters are in the same namespace so you can override
200+
filter by a validator::
201+
202+
## filter default: builtin.html_entities
203+
## validate ne: .*
204+
<html>
205+
<body>
206+
<h1>{{ title }}</h1> ### auto-escaped
207+
{{ body | ne }} ### a piece of HTML validated in advance
208+
</body>
209+
</html>
210+
211+
212+
Predefined validators
213+
---------------------
214+
215+
216+
.. index:: pair: html_entities; Builtin Filter
217+
218+
:builtin.html_entities:
219+
220+
transforms ``<>"'`/`` into HTML entities. It's good idea to use it as a
221+
``filter default:`` in any HTML template.
222+
223+
.. note:: This is not a catch-all validator for HTML (like in any other
224+
template), you can't put such variables in tag name, attribute name,
225+
script or style tag.
226+
227+
This work the same in amost any other HTML template engine, though.
228+
Consult OWASP_ for more info.
229+
230+
.. _owasp: https://www.owasp.org/index.php/XSS_(Cross_Site_Scripting)_Prevention_Cheat_Sheet
231+
232+
.. index:: pair: quoted_shell_argument; Builtin Filter
233+
234+
:builtin.quoted_shell_argument:
235+
236+
escapes variable value into an argument that's safe to put onto shell
237+
command-line. It's **not safe** to put argument in quotes.
238+
239+
Example:
240+
241+
.. code-block:: bash
242+
243+
## filter arg: builtin.shell_argument
244+
#!/bin/sh
245+
some_cmd {{ value1 | arg }} {{ value2 | arg }}
246+
247+
If the value is a part of the argument you must stop quotes before the arg:
248+
249+
.. code-block:: bash
250+
251+
## filter arg: builtin.shell_argument
252+
#!/bin/sh
253+
echo "Here's arg: "{{ value1 | arg }}". It's safe"
254+
255+
The quoting caveats and brittleness of shell-commands are the reason it's
256+
not recommended to use it as a default argument for example:
257+
258+
.. code-block:: bash
259+
260+
## validate alnum: [a-z0-9]+
261+
#!/bin/sh
262+
cp source/{{ name | alnum }} target/{{ name | alnum }}
263+
264+
The above, wouldn't be safe with just shell escaping as one could use
265+
parent directory specifiers even if shell expansion would be escaped in
266+
``name``.
267+
179268

180269
.. index:: pair: If; Statement
181270

src/escape.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
pub fn html_entities(dest: &mut String, src: &str) {
2+
for c in src.chars() {
3+
match c {
4+
'&' => dest.push_str("&amp;"),
5+
'<' => dest.push_str("&lt;"),
6+
'>' => dest.push_str("&gt;"),
7+
'"' => dest.push_str("&quot;"),
8+
'\'' => dest.push_str("&#x27;"),
9+
'/' => dest.push_str("&#x2f;"),
10+
'`' => dest.push_str("&#x96;"),
11+
_ => dest.push(c),
12+
}
13+
}
14+
}
15+
16+
pub fn quoted_shell_argument(dest: &mut String, src: &str) {
17+
// Do same as python and bash: enclouse in single quotes
18+
// and escape a single quote.
19+
// Known quirk: zsh converges double backslash even in single-quotes
20+
dest.push('\'');
21+
for c in src.chars() {
22+
match c {
23+
'\'' => dest.push_str(r#"'"'"'"#),
24+
_ => dest.push(c),
25+
}
26+
}
27+
dest.push('\'');
28+
}

src/lib.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ extern crate regex;
1616
#[cfg(feature="json")] extern crate serde_json;
1717

1818
mod compare;
19+
mod escape;
1920
mod grammar;
2021
mod helpers;
2122
mod indent;
@@ -70,8 +71,8 @@ pub struct Options {
7071
square: bool,
7172
round: bool,
7273

73-
default_validator: validators::Validator,
74-
validators: HashMap<String, validators::Validator>,
74+
default_filter: validators::Filter,
75+
filters: HashMap<String, validators::Filter>,
7576
}
7677

7778
/// Variable reference returned from methods of Variable trait

src/options.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use std::collections::HashMap;
22

33
use preparser::Syntax;
4-
use validators::Validator;
4+
use validators::Filter;
55
use {Options};
66

77
impl Options {
@@ -13,8 +13,8 @@ impl Options {
1313
curly: false,
1414
square: false,
1515
round: false,
16-
default_validator: Validator::Anything,
17-
validators: HashMap::new(),
16+
default_filter: Filter::NoFilter,
17+
filters: HashMap::new(),
1818
}
1919
}
2020
/// Enables `oneline` syntax by default

src/parse_error.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ quick_error! {
3333
description("Validator regexp is invalid")
3434
display("Validator regex {:?} is invalid: {}", value, err)
3535
}
36+
BadFilter(value: String) {
37+
display("Filter {:?} is unknown", value)
38+
}
3639
}
3740
}
3841

src/preparser.rs

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use regex::{Regex, RegexSet};
22

33
use parse_error::{ParseError, ParseErrorEnum};
4-
use validators::Validator;
4+
use validators::Filter;
55
use {Options};
66

77

@@ -21,6 +21,7 @@ pub enum Syntax {
2121
pub enum Token {
2222
Syntax,
2323
Validate,
24+
Filter,
2425
Comment,
2526
}
2627

@@ -32,6 +33,8 @@ impl Preparser {
3233
(r"^##\s*syntax:\s*(\w+)(?:\n|$)", Syntax),
3334
(r"^##\s*validate\s+(\w+):[ \t]*(.*)\s*(?:\n|$)",
3435
Validate),
36+
(r"^##\s*filter\s+(\w+):[ \t]*(.*)\s*(?:\n|$)",
37+
Filter),
3538
(r"^#.*(?:\n|$)", Comment),
3639
(r"^###.*(?:\n|$)", Comment),
3740
(r"^\s*\n", Comment),
@@ -99,11 +102,22 @@ impl Preparser {
99102
.map_err(|e| ParseErrorEnum::BadRegexValidator(
100103
regex.to_string(), e))?;
101104
if name == "default" {
102-
options.default_validator =
103-
Validator::Regex(regex);
105+
options.default_filter =
106+
Filter::Validate(regex);
104107
} else {
105-
options.validators.insert(
106-
name.to_string(), Validator::Regex(regex));
108+
options.filters.insert(
109+
name.to_string(), Filter::Validate(regex));
110+
}
111+
}
112+
Token::Filter => {
113+
let name = m.get(1).unwrap().as_str();
114+
let filter = m.get(2).unwrap().as_str().parse()?;
115+
if name == "default" {
116+
options.default_filter =
117+
Filter::Escape(filter);
118+
} else {
119+
options.filters.insert(name.to_string(),
120+
Filter::Escape(filter));
107121
}
108122
}
109123
Token::Comment => {
@@ -120,7 +134,7 @@ impl Preparser {
120134

121135
#[cfg(test)]
122136
mod test {
123-
use validators::Validator;
137+
use validators::Filter;
124138
use super::{Preparser, Syntax};
125139
use {Options};
126140

@@ -129,17 +143,17 @@ mod test {
129143
let opt = Preparser::new().scan("## syntax: indent\n",
130144
Options::new().clone()).unwrap();
131145
assert_eq!(opt.syntax, Syntax::Indent);
132-
assert!(matches!(opt.default_validator, Validator::Anything));
133-
assert_eq!(opt.validators.len(), 0);
146+
assert!(matches!(opt.default_filter, Filter::NoFilter));
147+
assert_eq!(opt.filters.len(), 0);
134148
}
135149

136150
#[test]
137151
fn oneline() {
138152
let opt = Preparser::new().scan("## syntax: oneline\n",
139153
Options::new().clone()).unwrap();
140154
assert_eq!(opt.syntax, Syntax::Oneline);
141-
assert!(matches!(opt.default_validator, Validator::Anything));
142-
assert_eq!(opt.validators.len(), 0);
155+
assert!(matches!(opt.default_filter, Filter::NoFilter));
156+
assert_eq!(opt.filters.len(), 0);
143157
}
144158

145159
#[test]

src/render.rs

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ use preparser::Syntax::Oneline;
1616
use render_error::{RenderError, DataError};
1717
use varmap::{Context, SubContext, set, get};
1818
use vars::{UNDEFINED, TRUE, FALSE, Val, VarRef, RefVar};
19+
use validators::Filter;
1920
use {Pos, Variable, Var};
2021

2122

@@ -389,28 +390,40 @@ fn write_block<'x, 'render>(r: &mut Renderer,
389390
let var = &eval_expr(r, root, &e);
390391
match var.output() {
391392
Ok(value) => {
392-
let start = r.buf.len();
393-
write!(&mut r.buf, "{}", value.0)?;
394-
let val = match *validator {
393+
let filter = match *validator {
395394
Some(ref name) => {
396-
match r.template.options.validators.get(name) {
395+
match r.template.options.filters.get(name) {
397396
Some(val) => val,
398397
None => {
399398
r.errors.push((item.position.0,
400399
UnknownValidator(
401400
name.to_string())));
402-
&r.template.options.default_validator
401+
&r.template.options.default_filter
403402
}
404403
}
405404
}
406405
None => {
407-
&r.template.options.default_validator
406+
&r.template.options.default_filter
408407
}
409408
};
410-
match val.validate(&r.buf[start..]) {
411-
Ok(()) => {}
412-
Err(e) => {
413-
r.errors.push((item.position.0, e));
409+
let start = r.buf.len();
410+
match *filter {
411+
Filter::NoFilter => {
412+
write!(&mut r.buf, "{}", value.0)?;
413+
}
414+
Filter::Validate(ref re) => {
415+
write!(&mut r.buf, "{}", value.0)?;
416+
if !re.is_match(&r.buf[start..]) {
417+
r.errors.push((item.position.0,
418+
DataError::RegexValidationError(
419+
r.buf[start..].to_string(),
420+
re.as_str().to_string())));
421+
}
422+
}
423+
Filter::Escape(ref escaper) => {
424+
let mut buf = String::with_capacity(1024);
425+
write!(&mut buf, "{}", value.0)?;
426+
escaper.escape(&mut r.buf, &buf);
414427
}
415428
}
416429
}

src/tests/filter.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
use grammar::{Parser};
2+
use {Context};
3+
4+
fn render_x(template: &str, x: &str) -> String {
5+
let tpl = Parser::new().parse(template).unwrap();
6+
let mut vars: Context = Context::new();
7+
vars.set("x", &x);
8+
tpl.render(&vars).unwrap()
9+
}
10+
11+
#[test]
12+
fn filter_default() {
13+
assert_diff!(
14+
&render_x("## syntax: oneline\n\
15+
## filter default: builtin.html_entities\n\
16+
{{ x }}", "<a>"),
17+
"&lt;a&gt;", "\n", 0);
18+
}
19+
20+
#[test]
21+
fn filter_html() {
22+
assert_diff!(
23+
&render_x("## syntax: oneline\n\
24+
## filter h: builtin.html_entities\n\
25+
{{ x | h }}", "<a>"),
26+
"&lt;a&gt;", "\n", 0);
27+
}
28+
29+
#[test]
30+
fn filter_shell_argument() {
31+
assert_diff!(
32+
&render_x("## syntax: oneline\n\
33+
## filter arg: builtin.quoted_shell_argument\n\
34+
echo {{ x | arg }}", "don't crash"),
35+
r#"echo 'don'"'"'t crash'"#, "\n", 0);
36+
}

0 commit comments

Comments
 (0)