From 4783e9054831e69b0544d28e709e6d0bf7ba5fbc Mon Sep 17 00:00:00 2001 From: Vincent Prouillet Date: Fri, 21 Aug 2026 22:15:19 +0200 Subject: [PATCH 1/8] Fix bug on autoescaping of included templates --- tera/src/snapshot_tests/rendering.rs | 11 +++++++++++ .../success/escaping/autoescape_include.txt | 4 ++++ .../success/escaping/autoescape_include2.txt | 4 ++++ ..._rendering_escaping_ok@autoescape_include.txt.snap | 6 ++++++ ...rendering_escaping_ok@autoescape_include2.txt.snap | 6 ++++++ tera/src/vm/interpreter.rs | 7 +++++-- 6 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 tera/src/snapshot_tests/rendering_inputs/success/escaping/autoescape_include.txt create mode 100644 tera/src/snapshot_tests/rendering_inputs/success/escaping/autoescape_include2.txt create mode 100644 tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@autoescape_include.txt.snap create mode 100644 tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@autoescape_include2.txt.snap diff --git a/tera/src/snapshot_tests/rendering.rs b/tera/src/snapshot_tests/rendering.rs index 90c5d5387..1f6b363d3 100644 --- a/tera/src/snapshot_tests/rendering.rs +++ b/tera/src/snapshot_tests/rendering.rs @@ -240,6 +240,17 @@ fn rendering_include_ok() { }); } +#[test] +fn rendering_escaping_ok() { + insta::glob!("rendering_inputs/success/escaping/*.txt", |path| { + let contents = std::fs::read_to_string(path).unwrap(); + let (tera, tpl_name) = create_multi_templates_tera(&contents); + let out = tera.render(&tpl_name, &get_context()).unwrap(); + let normalized_out = normalize_line_endings(&out); + insta::assert_snapshot!(&normalized_out); + }); +} + #[cfg(feature = "unicode")] #[test] fn can_iterate_on_graphemes() { diff --git a/tera/src/snapshot_tests/rendering_inputs/success/escaping/autoescape_include.txt b/tera/src/snapshot_tests/rendering_inputs/success/escaping/autoescape_include.txt new file mode 100644 index 000000000..67b06cb70 --- /dev/null +++ b/tera/src/snapshot_tests/rendering_inputs/success/escaping/autoescape_include.txt @@ -0,0 +1,4 @@ +$$ included.txt +{{ some_html }} +$$ tpl.html +Both escaped: {% include "included.txt" %}{% set x %}{% include "included.txt" %}{% endset %}{{ x }} \ No newline at end of file diff --git a/tera/src/snapshot_tests/rendering_inputs/success/escaping/autoescape_include2.txt b/tera/src/snapshot_tests/rendering_inputs/success/escaping/autoescape_include2.txt new file mode 100644 index 000000000..3f9cd6d30 --- /dev/null +++ b/tera/src/snapshot_tests/rendering_inputs/success/escaping/autoescape_include2.txt @@ -0,0 +1,4 @@ +$$ included.html +{{ some_html }} +$$ tpl +Not escaped: {% include "included.html" %} \ No newline at end of file diff --git a/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@autoescape_include.txt.snap b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@autoescape_include.txt.snap new file mode 100644 index 000000000..bfd4cbffb --- /dev/null +++ b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@autoescape_include.txt.snap @@ -0,0 +1,6 @@ +--- +source: tera/src/snapshot_tests/rendering.rs +expression: "&normalized_out" +input_file: tera/src/snapshot_tests/rendering_inputs/success/escaping/autoescape_include.txt +--- +Both escaped: <p>Some HTML chars & more</p><p>Some HTML chars & more</p> diff --git a/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@autoescape_include2.txt.snap b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@autoescape_include2.txt.snap new file mode 100644 index 000000000..c7c6d86ac --- /dev/null +++ b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@autoescape_include2.txt.snap @@ -0,0 +1,6 @@ +--- +source: tera/src/snapshot_tests/rendering.rs +expression: "&normalized_out" +input_file: tera/src/snapshot_tests/rendering_inputs/success/escaping/autoescape_include2.txt +--- +Not escaped:

Some HTML chars & more

diff --git a/tera/src/vm/interpreter.rs b/tera/src/vm/interpreter.rs index 8dbe7faf7..93eee937f 100644 --- a/tera/src/vm/interpreter.rs +++ b/tera/src/vm/interpreter.rs @@ -962,7 +962,9 @@ impl<'tera> VirtualMachine<'tera> { let vm = Self { tera: self.tera, template: tpl, - autoescape_override: self.autoescape_override, + // If we include eg a .txt file that includes html in a html file with autoescape on .html + // we want it escaped + autoescape_override: Some(self.autoescape_enabled()), component_recursion_depth: self.component_recursion_depth, }; @@ -971,7 +973,8 @@ impl<'tera> VirtualMachine<'tera> { self.tera, state.context, &tpl.chunk, - vm.autoescape_enabled(), + // We use the current template autoescape, not the one being included + self.autoescape_enabled(), ); include_state.include_parent = Some(state); vm.interpret(&mut include_state, output)?; From b543aa654822631cb16a4b295964b4f43b23acf8 Mon Sep 17 00:00:00 2001 From: Vincent Prouillet Date: Fri, 21 Aug 2026 22:29:43 +0200 Subject: [PATCH 2/8] Only mark capture/component as safe if autoescape is enabled --- tera/src/snapshot_tests/rendering.rs | 23 +++++++++++++++++++++++ tera/src/vm/interpreter.rs | 16 +++++++++++++--- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/tera/src/snapshot_tests/rendering.rs b/tera/src/snapshot_tests/rendering.rs index 1f6b363d3..92d4efc1c 100644 --- a/tera/src/snapshot_tests/rendering.rs +++ b/tera/src/snapshot_tests/rendering.rs @@ -356,3 +356,26 @@ fn render_str_errors() { let out = tera.render_str(r#"{% include "missing.html" %}"#, &ctx, false); assert!(out.is_err()); } + +#[test] +fn render_capture_body_safety() { + let mut tera = Tera::default(); + tera.autoescape_on(vec![".html"]); + tera.register_filter("is_safe", |s: Value, _: Kwargs, _: &State| s.is_safe()); + tera.add_raw_templates(vec![ + ("components.html", "{% component hey() %}a & b{% endcomponent %}{% component ho() %}{{body | is_safe }}{% endcomponent %}"), + ("a.txt", "{% set x %}a & b{% endset %}{{ x | is_safe }}-{{ | is_safe }}-{% %}a & b{% %}"), + ("a.html", "{% set x %}a & b{% endset %}{{ x | is_safe }}-{{ | is_safe }}-{% %}a & b{% %}"), + ]).unwrap(); + let ctx = Context::default(); + + // It should only mark things as safe when autoescaping is enabled: capture, component output and component body + assert_eq!( + tera.render("a.txt", &ctx).unwrap().as_str(), + "false-false-false" + ); + assert_eq!( + tera.render("a.html", &ctx).unwrap().as_str(), + "true-true-true" + ); +} diff --git a/tera/src/vm/interpreter.rs b/tera/src/vm/interpreter.rs index 93eee937f..71775fbc5 100644 --- a/tera/src/vm/interpreter.rs +++ b/tera/src/vm/interpreter.rs @@ -155,7 +155,7 @@ impl<'tera> VirtualMachine<'tera> { let current_span: SpanRange = $span_idx..=$span_idx; let body = if $has_body { - Some(state.stack.pop().0.mark_safe()) + Some(state.stack.pop().0) } else { None }; @@ -182,7 +182,12 @@ impl<'tera> VirtualMachine<'tera> { return Err(e); } }; - state.stack.push(Value::safe_string(&val), current_span); + let val = if self.autoescape_enabled() { + Value::safe_string(&val) + } else { + Value::from(val) + }; + state.stack.push(val, current_span); }}; } @@ -620,7 +625,12 @@ impl<'tera> VirtualMachine<'tera> { } Instruction::EndCapture => { let captured = state.capture_buffers.pop().unwrap(); - let val = Value::safe_string(&String::from_utf8(captured)?); + let raw = String::from_utf8(captured)?; + let val = if self.autoescape_enabled() { + Value::safe_string(&raw) + } else { + Value::from(raw) + }; state.stack.push(val, current_ip..=current_ip); } Instruction::StartIterate(is_key_value) From 78f0ff0de10dc09f6036da67a37d97d04ed8e1fd Mon Sep 17 00:00:00 2001 From: Vincent Prouillet Date: Sat, 22 Aug 2026 00:29:06 +0200 Subject: [PATCH 3/8] Take StringInput from minijinja --- CHANGELOG.md | 3 + docs/content/_index.md | 13 ++ tera/src/args.rs | 70 +++++++++- tera/src/filters.rs | 123 +++++++++++++----- tera/src/lib.rs | 2 +- .../success/escaping/escape_filter.txt | 5 + .../escaping/filter_after_safe_value.txt | 2 + .../success/escaping/filter_block_html.txt | 5 + .../escaping/filter_block_html_var.txt | 4 + .../success/escaping/join_filter.txt | 4 + .../escaping/replace_filter_block_html.txt | 2 + .../replace_filter_block_html_var.txt | 2 + .../escaping/set_filter_block_html.txt | 2 + .../success/escaping/set_filter_block_var.txt | 4 + .../escaping/truncate_filter_block_html.txt | 2 + .../escaping/var_escaped_after_filter.txt | 2 + ...ring_errors@filter_wrong_arg_type.txt.snap | 2 +- ...ndering_escaping_ok@escape_filter.txt.snap | 9 ++ ...caping_ok@filter_after_safe_value.txt.snap | 6 + ...ing_escaping_ok@filter_block_html.txt.snap | 8 ++ ...escaping_ok@filter_block_html_var.txt.snap | 6 + ...rendering_escaping_ok@join_filter.txt.snap | 8 ++ ...ping_ok@replace_filter_block_html.txt.snap | 6 + ..._ok@replace_filter_block_html_var.txt.snap | 6 + ...escaping_ok@set_filter_block_html.txt.snap | 6 + ..._escaping_ok@set_filter_block_var.txt.snap | 8 ++ ...ing_ok@truncate_filter_block_html.txt.snap | 6 + ...aping_ok@var_escaped_after_filter.txt.snap | 6 + ...ints_to_right_tpl_error_in_parent.txt.snap | 2 +- ...dering_ok@filter_section_escaping.txt.snap | 4 +- ...__rendering__rendering_ok@filters.txt.snap | 2 +- tera/src/tera.rs | 3 +- 32 files changed, 288 insertions(+), 45 deletions(-) create mode 100644 tera/src/snapshot_tests/rendering_inputs/success/escaping/escape_filter.txt create mode 100644 tera/src/snapshot_tests/rendering_inputs/success/escaping/filter_after_safe_value.txt create mode 100644 tera/src/snapshot_tests/rendering_inputs/success/escaping/filter_block_html.txt create mode 100644 tera/src/snapshot_tests/rendering_inputs/success/escaping/filter_block_html_var.txt create mode 100644 tera/src/snapshot_tests/rendering_inputs/success/escaping/join_filter.txt create mode 100644 tera/src/snapshot_tests/rendering_inputs/success/escaping/replace_filter_block_html.txt create mode 100644 tera/src/snapshot_tests/rendering_inputs/success/escaping/replace_filter_block_html_var.txt create mode 100644 tera/src/snapshot_tests/rendering_inputs/success/escaping/set_filter_block_html.txt create mode 100644 tera/src/snapshot_tests/rendering_inputs/success/escaping/set_filter_block_var.txt create mode 100644 tera/src/snapshot_tests/rendering_inputs/success/escaping/truncate_filter_block_html.txt create mode 100644 tera/src/snapshot_tests/rendering_inputs/success/escaping/var_escaped_after_filter.txt create mode 100644 tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@escape_filter.txt.snap create mode 100644 tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@filter_after_safe_value.txt.snap create mode 100644 tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@filter_block_html.txt.snap create mode 100644 tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@filter_block_html_var.txt.snap create mode 100644 tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@join_filter.txt.snap create mode 100644 tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@replace_filter_block_html.txt.snap create mode 100644 tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@replace_filter_block_html_var.txt.snap create mode 100644 tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@set_filter_block_html.txt.snap create mode 100644 tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@set_filter_block_var.txt.snap create mode 100644 tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@truncate_filter_block_html.txt.snap create mode 100644 tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@var_escaped_after_filter.txt.snap diff --git a/CHANGELOG.md b/CHANGELOG.md index 811856633..51a838c12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ ## 2.3.0 (unreleased) - Add `State::{escape,autoescaping_enabled,escape_if_needed}` +- Add `StringInput` to use for input/parameters that is safety aware and use it for built-in filters: this +means you should need less `| safe` and that string safety is passed around correctly. Filters that delete +characters (`trim*`, `truncate`) do not automatically mark their output as safe even if the input was safe. ## 2.2.0 (2026-08-20) diff --git a/docs/content/_index.md b/docs/content/_index.md index 772d61fe3..d2e05f567 100644 --- a/docs/content/_index.md +++ b/docs/content/_index.md @@ -864,18 +864,24 @@ Also takes an optional `pat` argument to trim by that pattern instead of whitesp Example: `{{ value | trim(pat="|") }}` +The output is never a safe string. + ##### trim_start Removes leading whitespace if the variable is a string. Also takes an optional `pat` argument to trim by that pattern instead of whitespace: Example: `{{ value | trim_start(pat="|") }}` +The output is never a safe string. + ##### trim_end Removes trailing whitespace if the variable is a string. Also takes an optional `pat` argument to trim by that pattern instead of whitespace: Example: `{{ value | trim_end(pat="|") }}` +The output is never a safe string. + ##### truncate Truncates a string to the indicated length. If the string has a smaller length than the `length` argument, the string is returned as is. @@ -889,6 +895,8 @@ For example, `{{ value | truncate(length=10, end="") }}` will not append anythin If you have the `unicode` feature enabled, the truncation will be done by graphemes rather than bytes. Avoid using that filter with user strings if that feature is not enabled. +The output is never a safe string. + ##### newlines_to_br Replaces line breaks (`\n` or `\r\n`) with HTML line breaks (`
`). @@ -934,6 +942,11 @@ Returns the length of an array, an object, or a string. ##### reverse Returns a reversed string or array. +##### escape + +Escape a string input using the currently defined escape function. +This is aware of the current state of the input so an already safe string will not be escaped. + ##### escape_html Escapes a string's HTML. Specifically, it makes these replacements: diff --git a/tera/src/args.rs b/tera/src/args.rs index e2194b3ea..b900dc1dc 100644 --- a/tera/src/args.rs +++ b/tera/src/args.rs @@ -2,13 +2,13 @@ use serde::Deserialize; use std::borrow::Cow; use std::sync::Arc; -use crate::Value; use crate::errors::{Error, TeraResult}; use crate::value::number::Number; use crate::value::{Key, Map, ValueInner}; +use crate::{State, Value}; mod private { - use super::{Map, Number, Value}; + use super::{Map, Number, StringInput, Value}; use std::borrow::Cow; pub trait Sealed {} @@ -38,6 +38,7 @@ mod private { impl Sealed for Map {} impl Sealed for &Map {} impl Sealed for Vec {} + impl Sealed for StringInput<'_> {} } /// Converts a template Value into a type that can be used in Rust code @@ -350,6 +351,71 @@ impl From<[(&'static str, Value); N]> for Kwargs { } } +/// A Value::String with some helpers around it for safety +/// The main usage is to use it as the value or string parameters for filters operating +/// on strings (for example a filter block) +/// +/// # Examples +/// +/// ``` +/// use tera::{Tera, Kwargs, State, StringInput}; +/// let mut tera = Tera::default(); +/// tera.register_filter("is_safe", |x: StringInput, _: Kwargs, _: &State| x.is_safe()); +/// tera.register_filter("uppercase", |x: StringInput, _: Kwargs, _: &State| x.inherit_safety(x.as_str().to_uppercase())); +/// ``` +#[derive(Debug)] +pub struct StringInput<'a> { + pub(crate) inner: &'a Value, +} + +impl<'a> StringInput<'a> { + /// Returns this StringInput as a Value, with the correct safety flag + pub fn into_value(self) -> Value { + self.inner.clone() + } + + /// Returns the actual string + pub fn as_str(&self) -> &'a str { + self.inner.as_str().unwrap() + } + + /// `true` if the original value was marked safe + pub fn is_safe(&self) -> bool { + self.inner.is_safe() + } + + /// Returns a new Value for the given output, inheriting the StringInput safety + pub fn inherit_safety(&self, output: String) -> Value { + if self.is_safe() { + Value::safe_string(&output) + } else { + Value::from(output) + } + } + + /// Render the current string as if it was used in `{{ }}` context, taking + /// into account its own safety flag as well as whether autoescaping is currently enabled. + pub fn rendered(&self, state: &State) -> TeraResult> { + if self.is_safe() || !state.autoescaping_enabled() { + Ok(Cow::Borrowed(self.as_str())) + } else { + Ok(Cow::Owned(state.escape(self.as_str())?)) + } + } +} + +impl<'k> ArgFromValue<'k> for StringInput<'_> { + type Output = StringInput<'k>; + + fn from_value(value: &'k Value) -> TeraResult { + if value.is_string() { + Ok(StringInput { inner: value }) + } else { + Err(Error::invalid_arg_type("string", value.name())) + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/tera/src/filters.rs b/tera/src/filters.rs index 0c5490496..a1e5deaf7 100644 --- a/tera/src/filters.rs +++ b/tera/src/filters.rs @@ -5,11 +5,11 @@ use std::sync::Arc; use crate::args::{ArgFromValue, Kwargs}; use crate::errors::{Error, TeraResult}; -use crate::utils::escape_html; +use crate::utils; use crate::value::number::Number; use crate::value::{FunctionResult, Key, Map, ValueKind}; use crate::vm::state::State; -use crate::{HashMap, Value}; +use crate::{HashMap, StringInput, Value}; /// The filter function type definition pub trait Filter: Sync + Send + 'static { @@ -99,21 +99,29 @@ pub(crate) fn default(val: Value, kwargs: Kwargs, _: &State) -> TeraResult String { - val.to_uppercase() +pub(crate) fn upper(val: StringInput, _: Kwargs, _: &State) -> Value { + val.inherit_safety(val.as_str().to_uppercase()) } -pub(crate) fn lower(val: &str, _: Kwargs, _: &State) -> String { - val.to_lowercase() +pub(crate) fn lower(val: StringInput, _: Kwargs, _: &State) -> Value { + val.inherit_safety(val.as_str().to_lowercase()) } pub(crate) fn wordcount(val: &str, _: Kwargs, _: &State) -> usize { val.split_whitespace().count() } -pub(crate) fn escape(val: &str, _: Kwargs, _: &State) -> String { +pub(crate) fn escape(val: StringInput, _: Kwargs, state: &State) -> TeraResult { + if val.is_safe() { + Ok(val.into_value()) + } else { + Ok(Value::safe_string(&state.escape(val.as_str())?)) + } +} + +pub(crate) fn escape_html(val: &str, _: Kwargs, _: &State) -> String { let mut buf = Vec::with_capacity(val.len()); - escape_html(val, &mut buf).unwrap(); + utils::escape_html(val, &mut buf).unwrap(); // SAFETY: escape_html only produces valid UTF-8 unsafe { String::from_utf8_unchecked(buf) } } @@ -183,27 +191,41 @@ pub(crate) fn trim_end(val: &str, kwargs: Kwargs, _: &State) -> TeraResult TeraResult { - let from = kwargs.must_get::<&str>("from")?; - let to = kwargs.must_get::<&str>("to")?; +pub(crate) fn replace(val: StringInput, kwargs: Kwargs, state: &State) -> TeraResult { + let from = kwargs.must_get::("from")?; + let to = kwargs.must_get::("to")?; - Ok(val.replace(from, to)) + // If we have autoescaping enabled and either the value or the replacement is safe, + // we go through StringInput to render everything as if it was {{ }} + // otherwise we do a basic str replace + let be_safe = state.autoescaping_enabled() && (val.is_safe() || to.is_safe()); + if be_safe { + let out = val + .rendered(state)? + .replace(&*from.rendered(state)?, &to.rendered(state)?); + Ok(Value::safe_string(&out)) + } else { + Ok(Value::from( + val.as_str().replace(from.as_str(), to.as_str()), + )) + } } /// Uppercase the first char and lowercase the rest. -pub(crate) fn capitalize(val: &str, _: Kwargs, _: &State) -> String { - let mut chars = val.chars(); +pub(crate) fn capitalize(val: StringInput, _: Kwargs, _: &State) -> Value { + let mut chars = val.as_str().chars(); match chars.next() { - None => String::new(), - Some(f) => f.to_uppercase().collect::() + &chars.as_str().to_lowercase(), + None => Value::from(String::new()), + Some(f) => val + .inherit_safety(f.to_uppercase().collect::() + &chars.as_str().to_lowercase()), } } /// Uppercase the first letter of each word -pub(crate) fn title(val: &str, _: Kwargs, _: &State) -> String { - let mut res = String::with_capacity(val.len()); +pub(crate) fn title(val: StringInput, _: Kwargs, _: &State) -> Value { + let mut res = String::with_capacity(val.as_str().len()); let mut capitalize = true; - for c in val.chars() { + for c in val.as_str().chars() { if c.is_ascii_punctuation() || c.is_whitespace() { res.push(c); // Special case the apostrophe so that it doesn't mess up the English 's etc @@ -217,7 +239,7 @@ pub(crate) fn title(val: &str, _: Kwargs, _: &State) -> String { write!(res, "{}", c.to_lowercase()).unwrap(); } } - res + val.inherit_safety(res) } /// Works on char/graphemes, not bytes. @@ -248,7 +270,7 @@ pub(crate) fn truncate(val: &str, kwargs: Kwargs, _: &State) -> TeraResult TeraResult { +pub(crate) fn indent(val: StringInput, kwargs: Kwargs, _: &State) -> TeraResult { let width = kwargs.get::("width")?.unwrap_or(4).min(1000); let indentation = kwargs.get::<&str>("indentation")?.unwrap_or(" "); let mut characters = indentation.chars(); @@ -262,10 +284,11 @@ pub(crate) fn indent(val: &str, kwargs: Kwargs, _: &State) -> TeraResult let indent_blank_line = kwargs.get::("blank")?.unwrap_or(false); let indent = indent_character.to_string().repeat(width); - let mut res = String::with_capacity(val.len() * 2); + let s = val.as_str(); + let mut res = String::with_capacity(s.len() * 2); let mut first_line = true; - for line in val.lines() { + for line in s.lines() { if first_line { if indent_first_line { res.push_str(&indent); @@ -280,11 +303,11 @@ pub(crate) fn indent(val: &str, kwargs: Kwargs, _: &State) -> TeraResult res.push_str(line); } - if val.ends_with('\n') { + if s.ends_with('\n') { res.push('\n'); } - Ok(res) + Ok(val.inherit_safety(res)) } pub(crate) fn as_str(val: Value, _: Kwargs, _: &State) -> String { @@ -479,13 +502,34 @@ pub(crate) fn nth(val: &[Value], kwargs: Kwargs, _: &State) -> TeraResult } /// Joins the elements -pub(crate) fn join(val: &[Value], kwargs: Kwargs, _: &State) -> TeraResult { - let sep = kwargs.get::<&str>("sep")?.unwrap_or(""); - Ok(val - .iter() - .map(|x| format!("{x}")) - .collect::>() - .join(sep)) +pub(crate) fn join(val: &[Value], kwargs: Kwargs, state: &State) -> TeraResult { + let sep = kwargs.get::("sep")?; + + // If we have autoescaping enabled and either one of the values or the separator is safe, + // we go through StringInput to render everything as if it was {{ }} + // otherwise we do a basic str replace + let be_safe = state.autoescaping_enabled() + && (val.iter().any(|x| x.is_safe()) || sep.as_ref().is_some_and(|x| x.is_safe())); + + if be_safe { + let mut parts = Vec::with_capacity(val.len()); + for v in val { + parts.push(state.escape_if_needed(v)?); + } + let sep_rendered = match &sep { + Some(s) => s.rendered(state)?, + None => Cow::Borrowed(""), + }; + Ok(Value::safe_string(&parts.join(&*sep_rendered))) + } else { + let sep_str = sep.as_ref().map(|s| s.as_str()).unwrap_or(""); + Ok(Value::from( + val.iter() + .map(|x| format!("{x}")) + .collect::>() + .join(sep_str), + )) + } } /// We want to check if the items can actually be sorted, eg be comparable. We allow null @@ -652,7 +696,11 @@ mod tests { ("foo's bar", "Foo's Bar"), ]; for (input, expected) in tests { - assert_eq!(title(input, Kwargs::default(), &state), expected); + let val = Value::from(input); + assert_eq!( + title(StringInput { inner: &val }, Kwargs::default(), &state), + expected.into() + ); } } @@ -680,13 +728,16 @@ mod tests { fn test_indent_indentation() { let ctx = Context::new(); let state = State::new(&ctx); - + let val = Value::from("one\ntwo"); let kwargs = Kwargs::from([("width", 2.into()), ("indentation", "·".into())]); - assert_eq!(indent("one\ntwo", kwargs, &state).unwrap(), "one\n··two"); + assert_eq!( + indent(StringInput { inner: &val }, kwargs, &state).unwrap(), + "one\n··two".into() + ); for indentation in ["", "ab"] { let kwargs = Kwargs::from([("indentation", indentation.into())]); - assert!(indent("one\ntwo", kwargs, &state).is_err()); + assert!(indent(StringInput { inner: &val }, kwargs, &state).is_err()); } } diff --git a/tera/src/lib.rs b/tera/src/lib.rs index 1d9cf6558..246e58663 100644 --- a/tera/src/lib.rs +++ b/tera/src/lib.rs @@ -85,7 +85,7 @@ pub mod value; pub(crate) mod vm; pub use crate::tera::{EscapeFn, Tera}; -pub use args::{ArgFromValue, Kwargs}; +pub use args::{ArgFromValue, Kwargs, StringInput}; pub use components::{ComponentArg, ComponentArgType, ComponentInfo}; pub use context::Context; pub use delimiters::Delimiters; diff --git a/tera/src/snapshot_tests/rendering_inputs/success/escaping/escape_filter.txt b/tera/src/snapshot_tests/rendering_inputs/success/escaping/escape_filter.txt new file mode 100644 index 000000000..bf44a335c --- /dev/null +++ b/tera/src/snapshot_tests/rendering_inputs/success/escaping/escape_filter.txt @@ -0,0 +1,5 @@ +$$ tpl.html +{% set x %}{{ some_html }}{% endset %} +{{ x | escape }} +{{ some_html | escape }} +{{ some_html | safe | escape }} \ No newline at end of file diff --git a/tera/src/snapshot_tests/rendering_inputs/success/escaping/filter_after_safe_value.txt b/tera/src/snapshot_tests/rendering_inputs/success/escaping/filter_after_safe_value.txt new file mode 100644 index 000000000..50498600e --- /dev/null +++ b/tera/src/snapshot_tests/rendering_inputs/success/escaping/filter_after_safe_value.txt @@ -0,0 +1,2 @@ +$$ tpl.html +{{ some_html | safe | upper }} \ No newline at end of file diff --git a/tera/src/snapshot_tests/rendering_inputs/success/escaping/filter_block_html.txt b/tera/src/snapshot_tests/rendering_inputs/success/escaping/filter_block_html.txt new file mode 100644 index 000000000..65a2c2000 --- /dev/null +++ b/tera/src/snapshot_tests/rendering_inputs/success/escaping/filter_block_html.txt @@ -0,0 +1,5 @@ +$$ tpl.html +{% filter indent %} +

I should not be escaped

+

This either

+{%- endfilter %} \ No newline at end of file diff --git a/tera/src/snapshot_tests/rendering_inputs/success/escaping/filter_block_html_var.txt b/tera/src/snapshot_tests/rendering_inputs/success/escaping/filter_block_html_var.txt new file mode 100644 index 000000000..44db1466d --- /dev/null +++ b/tera/src/snapshot_tests/rendering_inputs/success/escaping/filter_block_html_var.txt @@ -0,0 +1,4 @@ +$$ tpl.html +{% filter indent -%} +{{ some_html }} +{%- endfilter %} \ No newline at end of file diff --git a/tera/src/snapshot_tests/rendering_inputs/success/escaping/join_filter.txt b/tera/src/snapshot_tests/rendering_inputs/success/escaping/join_filter.txt new file mode 100644 index 000000000..898b116d9 --- /dev/null +++ b/tera/src/snapshot_tests/rendering_inputs/success/escaping/join_filter.txt @@ -0,0 +1,4 @@ +$$ tpl.html +{% set x %}{{ some_html }}{% endset %} +{{ [x, ""] | join(sep=" & ") }} +{{ ["a", "b"] | join(sep=" & ") }} \ No newline at end of file diff --git a/tera/src/snapshot_tests/rendering_inputs/success/escaping/replace_filter_block_html.txt b/tera/src/snapshot_tests/rendering_inputs/success/escaping/replace_filter_block_html.txt new file mode 100644 index 000000000..faff25629 --- /dev/null +++ b/tera/src/snapshot_tests/rendering_inputs/success/escaping/replace_filter_block_html.txt @@ -0,0 +1,2 @@ +$$ tpl.html +{% filter replace(from="&", to="and") %}A & B{% endfilter %} \ No newline at end of file diff --git a/tera/src/snapshot_tests/rendering_inputs/success/escaping/replace_filter_block_html_var.txt b/tera/src/snapshot_tests/rendering_inputs/success/escaping/replace_filter_block_html_var.txt new file mode 100644 index 000000000..d8112819b --- /dev/null +++ b/tera/src/snapshot_tests/rendering_inputs/success/escaping/replace_filter_block_html_var.txt @@ -0,0 +1,2 @@ +$$ tpl.html +{% filter replace(from="&", to="and") %}{{ some_html }}{% endfilter %} \ No newline at end of file diff --git a/tera/src/snapshot_tests/rendering_inputs/success/escaping/set_filter_block_html.txt b/tera/src/snapshot_tests/rendering_inputs/success/escaping/set_filter_block_html.txt new file mode 100644 index 000000000..7c5cdc3dd --- /dev/null +++ b/tera/src/snapshot_tests/rendering_inputs/success/escaping/set_filter_block_html.txt @@ -0,0 +1,2 @@ +$$ tpl.html +{% set x | upper %}a{% endset %}{{ x }} \ No newline at end of file diff --git a/tera/src/snapshot_tests/rendering_inputs/success/escaping/set_filter_block_var.txt b/tera/src/snapshot_tests/rendering_inputs/success/escaping/set_filter_block_var.txt new file mode 100644 index 000000000..e93b2c42f --- /dev/null +++ b/tera/src/snapshot_tests/rendering_inputs/success/escaping/set_filter_block_var.txt @@ -0,0 +1,4 @@ +$$ tpl.html +{% set x %}{{ some_html }} {% endset %} +Escaped only once: {{x}} +Escaped twice: {{x | trim}} \ No newline at end of file diff --git a/tera/src/snapshot_tests/rendering_inputs/success/escaping/truncate_filter_block_html.txt b/tera/src/snapshot_tests/rendering_inputs/success/escaping/truncate_filter_block_html.txt new file mode 100644 index 000000000..7d86d4149 --- /dev/null +++ b/tera/src/snapshot_tests/rendering_inputs/success/escaping/truncate_filter_block_html.txt @@ -0,0 +1,2 @@ +$$ tpl.html +{% filter truncate(length=10) %}

should be escaped

{% endfilter %} \ No newline at end of file diff --git a/tera/src/snapshot_tests/rendering_inputs/success/escaping/var_escaped_after_filter.txt b/tera/src/snapshot_tests/rendering_inputs/success/escaping/var_escaped_after_filter.txt new file mode 100644 index 000000000..44ce38c0f --- /dev/null +++ b/tera/src/snapshot_tests/rendering_inputs/success/escaping/var_escaped_after_filter.txt @@ -0,0 +1,2 @@ +$$ tpl.html +{{ some_html | upper }} diff --git a/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_errors@filter_wrong_arg_type.txt.snap b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_errors@filter_wrong_arg_type.txt.snap index 7e48d0528..57e4f9c5f 100644 --- a/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_errors@filter_wrong_arg_type.txt.snap +++ b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_errors@filter_wrong_arg_type.txt.snap @@ -3,7 +3,7 @@ source: tera/src/snapshot_tests/rendering.rs expression: "&err" input_file: tera/src/snapshot_tests/rendering_inputs/errors/filter_wrong_arg_type.txt --- -error: Invalid type for the value, expected `&str` but got `array` +error: Invalid type for the value, expected `string` but got `array` --> filter_wrong_arg_type.txt:1:4 | 1 | {{ [1] | upper }} diff --git a/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@escape_filter.txt.snap b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@escape_filter.txt.snap new file mode 100644 index 000000000..4be9bc9d5 --- /dev/null +++ b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@escape_filter.txt.snap @@ -0,0 +1,9 @@ +--- +source: tera/src/snapshot_tests/rendering.rs +expression: "&normalized_out" +input_file: tera/src/snapshot_tests/rendering_inputs/success/escaping/escape_filter.txt +--- + +<p>Some HTML chars & more</p> +<p>Some HTML chars & more</p> +

Some HTML chars & more

diff --git a/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@filter_after_safe_value.txt.snap b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@filter_after_safe_value.txt.snap new file mode 100644 index 000000000..e34b9451e --- /dev/null +++ b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@filter_after_safe_value.txt.snap @@ -0,0 +1,6 @@ +--- +source: tera/src/snapshot_tests/rendering.rs +expression: "&normalized_out" +input_file: tera/src/snapshot_tests/rendering_inputs/success/escaping/filter_after_safe_value.txt +--- +

SOME HTML CHARS & MORE

diff --git a/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@filter_block_html.txt.snap b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@filter_block_html.txt.snap new file mode 100644 index 000000000..0d96c3dc5 --- /dev/null +++ b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@filter_block_html.txt.snap @@ -0,0 +1,8 @@ +--- +source: tera/src/snapshot_tests/rendering.rs +expression: "&normalized_out" +input_file: tera/src/snapshot_tests/rendering_inputs/success/escaping/filter_block_html.txt +--- + +

I should not be escaped

+

This either

diff --git a/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@filter_block_html_var.txt.snap b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@filter_block_html_var.txt.snap new file mode 100644 index 000000000..e048287a5 --- /dev/null +++ b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@filter_block_html_var.txt.snap @@ -0,0 +1,6 @@ +--- +source: tera/src/snapshot_tests/rendering.rs +expression: "&normalized_out" +input_file: tera/src/snapshot_tests/rendering_inputs/success/escaping/filter_block_html_var.txt +--- +<p>Some HTML chars & more</p> diff --git a/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@join_filter.txt.snap b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@join_filter.txt.snap new file mode 100644 index 000000000..bc83a7709 --- /dev/null +++ b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@join_filter.txt.snap @@ -0,0 +1,8 @@ +--- +source: tera/src/snapshot_tests/rendering.rs +expression: "&normalized_out" +input_file: tera/src/snapshot_tests/rendering_inputs/success/escaping/join_filter.txt +--- + +<p>Some HTML chars & more</p> & <raw> +a & b diff --git a/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@replace_filter_block_html.txt.snap b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@replace_filter_block_html.txt.snap new file mode 100644 index 000000000..9a2beeb16 --- /dev/null +++ b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@replace_filter_block_html.txt.snap @@ -0,0 +1,6 @@ +--- +source: tera/src/snapshot_tests/rendering.rs +expression: "&normalized_out" +input_file: tera/src/snapshot_tests/rendering_inputs/success/escaping/replace_filter_block_html.txt +--- +A & B diff --git a/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@replace_filter_block_html_var.txt.snap b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@replace_filter_block_html_var.txt.snap new file mode 100644 index 000000000..da540ccaa --- /dev/null +++ b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@replace_filter_block_html_var.txt.snap @@ -0,0 +1,6 @@ +--- +source: tera/src/snapshot_tests/rendering.rs +expression: "&normalized_out" +input_file: tera/src/snapshot_tests/rendering_inputs/success/escaping/replace_filter_block_html_var.txt +--- +<p>Some HTML chars and more</p> diff --git a/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@set_filter_block_html.txt.snap b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@set_filter_block_html.txt.snap new file mode 100644 index 000000000..039d7bfa9 --- /dev/null +++ b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@set_filter_block_html.txt.snap @@ -0,0 +1,6 @@ +--- +source: tera/src/snapshot_tests/rendering.rs +expression: "&normalized_out" +input_file: tera/src/snapshot_tests/rendering_inputs/success/escaping/set_filter_block_html.txt +--- +A diff --git a/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@set_filter_block_var.txt.snap b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@set_filter_block_var.txt.snap new file mode 100644 index 000000000..8bf70fc60 --- /dev/null +++ b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@set_filter_block_var.txt.snap @@ -0,0 +1,8 @@ +--- +source: tera/src/snapshot_tests/rendering.rs +expression: "&normalized_out" +input_file: tera/src/snapshot_tests/rendering_inputs/success/escaping/set_filter_block_var.txt +--- + +Escaped only once: <p>Some HTML chars & more</p> +Escaped twice: &lt;p&gt;Some HTML chars &amp; more&lt;/p&gt; diff --git a/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@truncate_filter_block_html.txt.snap b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@truncate_filter_block_html.txt.snap new file mode 100644 index 000000000..b970a30a7 --- /dev/null +++ b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@truncate_filter_block_html.txt.snap @@ -0,0 +1,6 @@ +--- +source: tera/src/snapshot_tests/rendering.rs +expression: "&normalized_out" +input_file: tera/src/snapshot_tests/rendering_inputs/success/escaping/truncate_filter_block_html.txt +--- +<p>should … diff --git a/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@var_escaped_after_filter.txt.snap b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@var_escaped_after_filter.txt.snap new file mode 100644 index 000000000..4ca4efbb1 --- /dev/null +++ b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@var_escaped_after_filter.txt.snap @@ -0,0 +1,6 @@ +--- +source: tera/src/snapshot_tests/rendering.rs +expression: "&normalized_out" +input_file: tera/src/snapshot_tests/rendering_inputs/success/escaping/var_escaped_after_filter.txt +--- +<P>SOME HTML CHARS & MORE</P> diff --git a/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_inheritance_errors@points_to_right_tpl_error_in_parent.txt.snap b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_inheritance_errors@points_to_right_tpl_error_in_parent.txt.snap index 733a254bf..6f9b3e3d6 100644 --- a/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_inheritance_errors@points_to_right_tpl_error_in_parent.txt.snap +++ b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_inheritance_errors@points_to_right_tpl_error_in_parent.txt.snap @@ -3,7 +3,7 @@ source: tera/src/snapshot_tests/rendering.rs expression: "&err" input_file: tera/src/snapshot_tests/rendering_inputs/errors/inheritance/points_to_right_tpl_error_in_parent.txt --- -error: Invalid type for the value, expected `&str` but got `i64` +error: Invalid type for the value, expected `string` but got `i64` --> top:1:45 | 1 | {% block content %}{% endblock content %}{{ 1 | upper }} diff --git a/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_ok@filter_section_escaping.txt.snap b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_ok@filter_section_escaping.txt.snap index d8431aa0c..b00a9ea4b 100644 --- a/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_ok@filter_section_escaping.txt.snap +++ b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_ok@filter_section_escaping.txt.snap @@ -3,5 +3,5 @@ source: tera/src/snapshot_tests/rendering.rs expression: "&normalized_out" input_file: tera/src/snapshot_tests/rendering_inputs/success/filter_section_escaping.txt --- -filter: &LT;P&GT;SOME HTML CHARS &AMP; MORE&LT;/P&GT; -filter safe inside: <P>SOME HTML CHARS & MORE</P> +filter: <P>SOME HTML CHARS & MORE</P> +filter safe inside:

SOME HTML CHARS & MORE

diff --git a/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_ok@filters.txt.snap b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_ok@filters.txt.snap index ef3c43768..9523eef96 100644 --- a/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_ok@filters.txt.snap +++ b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_ok@filters.txt.snap @@ -24,7 +24,7 @@ HTML <HTML> -<HTML> + hello hello $ diff --git a/tera/src/tera.rs b/tera/src/tera.rs index 58872c9c6..d603baee3 100644 --- a/tera/src/tera.rs +++ b/tera/src/tera.rs @@ -474,7 +474,8 @@ impl Tera { self.register_filter("upper", crate::filters::upper); self.register_filter("lower", crate::filters::lower); self.register_filter("wordcount", crate::filters::wordcount); - self.register_filter("escape_html", crate::filters::escape); + self.register_filter("escape", crate::filters::escape); + self.register_filter("escape_html", crate::filters::escape_html); self.register_filter("escape_xml", crate::filters::escape_xml); self.register_filter("newlines_to_br", crate::filters::newlines_to_br); self.register_filter("pluralize", crate::filters::pluralize); From c3cee57deebf51ca0428fb8549bc445b4429218c Mon Sep 17 00:00:00 2001 From: Vincent Prouillet Date: Sat, 22 Aug 2026 16:55:56 +0200 Subject: [PATCH 4/8] Update spaceless in tera-contrib --- tera-contrib/src/lib.rs | 13 +++++++++++++ tera-contrib/src/regex.rs | 20 ++++++++++++++------ tera/src/snapshot_tests/rendering.rs | 3 --- 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/tera-contrib/src/lib.rs b/tera-contrib/src/lib.rs index bfd8fb04e..7beb8e3a1 100644 --- a/tera-contrib/src/lib.rs +++ b/tera-contrib/src/lib.rs @@ -1,3 +1,16 @@ +//! Additional features for Tera that require 3rd party dependencies +//! These are in a separate package so the crate version can be changed independently +//! of the tera crate. +//! +//! To use them, call the Tera.{register_filter,register_function,register_test} functions: +//! +//! ```no_compile +//! use tera::Tera; +//! +//! let mut tera = Tera::default(); +//! tera.register_filter("b64_encode", tera_contrib::base64::b64_encode); +//! ``` +//! #[cfg(feature = "base64")] pub mod base64; #[cfg(feature = "date")] diff --git a/tera-contrib/src/regex.rs b/tera-contrib/src/regex.rs index 021a1c3b6..7c1feeb20 100644 --- a/tera-contrib/src/regex.rs +++ b/tera-contrib/src/regex.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use std::sync::{LazyLock, RwLock}; use regex::Regex; -use tera::{Filter, Kwargs, State, TeraResult, Test}; +use tera::{Filter, Kwargs, State, StringInput, TeraResult, Test, Value}; static STRIPTAGS_RE: LazyLock = LazyLock::new(|| Regex::new(r"(|<[^>]*>)").unwrap()); @@ -30,8 +30,10 @@ pub fn striptags(val: &str, _: Kwargs, _: &State) -> String { /// ```text /// {{ value | spaceless }} /// ``` -pub fn spaceless(val: &str, _: Kwargs, _: &State) -> String { - SPACELESS_RE.replace_all(val, "><").into_owned() +pub fn spaceless(val: StringInput, _: Kwargs, _: &State) -> Value { + // We're removing spaces between HTML tags so if it was safe before, it should still be safe + // Of course if it's used outside of HTML content it might be wrong. + val.inherit_safety(SPACELESS_RE.replace_all(val.as_str(), "><").into_owned()) } fn get_or_create_regex(cache: &RwLock>, pattern: &str) -> TeraResult { @@ -94,8 +96,8 @@ impl Filter<&str, TeraResult> for RegexReplace { mod tests { use super::*; use std::sync::Arc; - use tera::Context; use tera::value::Map; + use tera::{ArgFromValue, Context, StringInput}; #[test] fn test_striptags() { @@ -127,6 +129,7 @@ mod tests { r#"foobar"#, "foobar", ), + ("a & b", "a & b"), ]; for (input, expected) in tests { let ctx = Context::new(); @@ -149,8 +152,13 @@ mod tests { for (input, expected) in tests { let ctx = Context::new(); let state = State::new(&ctx); - let res = spaceless(input, Kwargs::default(), &state); - assert_eq!(expected, res); + let val = Value::from(input); + let res = spaceless( + StringInput::from_value(&val).unwrap(), + Kwargs::default(), + &state, + ); + assert_eq!(expected, res.as_str().unwrap()); } } diff --git a/tera/src/snapshot_tests/rendering.rs b/tera/src/snapshot_tests/rendering.rs index 92d4efc1c..0be3198b7 100644 --- a/tera/src/snapshot_tests/rendering.rs +++ b/tera/src/snapshot_tests/rendering.rs @@ -5,10 +5,7 @@ use crate::delimiters::Delimiters; use crate::snapshot_tests::utils::{create_multi_templates_tera, normalize_line_endings}; use crate::tera::Tera; -#[cfg(not(feature = "preserve_order"))] use crate::args::Kwargs; - -#[cfg(not(feature = "preserve_order"))] use crate::vm::state::State; use crate::{Context, Value}; From bda10e05e5ac49f79e13c87a16a8285f3c566dbd Mon Sep 17 00:00:00 2001 From: Vincent Prouillet Date: Sat, 22 Aug 2026 17:27:48 +0200 Subject: [PATCH 5/8] Docs --- CHANGELOG.md | 2 ++ docs/content/_index.md | 9 ++++++--- tera/src/args.rs | 3 +++ tera/src/filters.rs | 2 +- tera/src/tera.rs | 4 +++- 5 files changed, 15 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51a838c12..157a35c6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ - Add `StringInput` to use for input/parameters that is safety aware and use it for built-in filters: this means you should need less `| safe` and that string safety is passed around correctly. Filters that delete characters (`trim*`, `truncate`) do not automatically mark their output as safe even if the input was safe. +- Add `escape` filter that is safety aware (it will not escape an already safe string) and uses the current instance +escape function ## 2.2.0 (2026-08-20) diff --git a/docs/content/_index.md b/docs/content/_index.md index d2e05f567..4d86c1412 100644 --- a/docs/content/_index.md +++ b/docs/content/_index.md @@ -827,11 +827,14 @@ If you are building with something like HTMX you can also re-render a single com Tera has the following filters built-in: ##### safe -Marks a variable as safe: HTML will not be escaped anymore. -`safe` only works if it is the last filter of the expression: +Marks a variable as safe. +- `{{ content | safe }}` will not be escaped - `{{ content | replace(from="Robert", to="Bob") | safe }}` will not be escaped -- `{{ content | safe | replace(from="Robert", to="Bob") }}` will be escaped +- `{{ content | safe | replace(from="Robert", to="Bob") }}` will not be escaped either because the `content` is marked as safe and the `replace` filter is safety aware and will escape the `to` parameter if needed +- `{{ content | safe | trim }}` will be escaped because `trim` is not safety aware + +Safety is preserved through safety aware filters when possible (`replace`, `upper` etc) but dropped by all others. ##### lower Converts a string to lowercase. diff --git a/tera/src/args.rs b/tera/src/args.rs index b900dc1dc..266b87c96 100644 --- a/tera/src/args.rs +++ b/tera/src/args.rs @@ -385,6 +385,9 @@ impl<'a> StringInput<'a> { } /// Returns a new Value for the given output, inheriting the StringInput safety + /// This should only be used when the new output is taken directly from the StringInput + /// and there's no deletion/decoding etc that could make the output unsafe. + /// If it's misused, it could mark some unsafe strings as safe. pub fn inherit_safety(&self, output: String) -> Value { if self.is_safe() { Value::safe_string(&output) diff --git a/tera/src/filters.rs b/tera/src/filters.rs index a1e5deaf7..5659902e1 100644 --- a/tera/src/filters.rs +++ b/tera/src/filters.rs @@ -215,7 +215,7 @@ pub(crate) fn replace(val: StringInput, kwargs: Kwargs, state: &State) -> TeraRe pub(crate) fn capitalize(val: StringInput, _: Kwargs, _: &State) -> Value { let mut chars = val.as_str().chars(); match chars.next() { - None => Value::from(String::new()), + None => val.inherit_safety(String::new()), Some(f) => val .inherit_safety(f.to_uppercase().collect::() + &chars.as_str().to_lowercase()), } diff --git a/tera/src/tera.rs b/tera/src/tera.rs index d603baee3..f3efa5aef 100644 --- a/tera/src/tera.rs +++ b/tera/src/tera.rs @@ -294,7 +294,9 @@ impl Tera { /// Register a filter with Tera. /// - /// If a filter with that name already exists, it will be overwritten + /// If a filter with that name already exists, it will be overwritten. + /// If your filter is returning a String, make sure you check [crate::StringInput] to handle + /// string safety correctly if relevant. /// /// ``` /// # use tera::{Tera, Kwargs, State}; From 7dd7f80831544a1e2e5f72440d303c6e9478d74d Mon Sep 17 00:00:00 2001 From: Vincent Prouillet Date: Tue, 25 Aug 2026 22:16:18 +0200 Subject: [PATCH 6/8] Fix some filter issues --- CHANGELOG.md | 2 +- docs/content/_index.md | 14 +-- tera/src/filters.rs | 104 +++++++++++------- .../success/escaping/join_filter.txt | 3 +- .../success/escaping/set_filter_block_var.txt | 2 +- ...rendering_escaping_ok@join_filter.txt.snap | 1 + ...ping_ok@replace_filter_block_html.txt.snap | 2 +- ..._ok@replace_filter_block_html_var.txt.snap | 2 +- ..._escaping_ok@set_filter_block_var.txt.snap | 2 +- 9 files changed, 76 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 157a35c6b..83d873122 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ - Add `State::{escape,autoescaping_enabled,escape_if_needed}` - Add `StringInput` to use for input/parameters that is safety aware and use it for built-in filters: this means you should need less `| safe` and that string safety is passed around correctly. Filters that delete -characters (`trim*`, `truncate`) do not automatically mark their output as safe even if the input was safe. +characters (`truncate`) do not automatically mark their output as safe even if the input was safe. - Add `escape` filter that is safety aware (it will not escape an already safe string) and uses the current instance escape function diff --git a/docs/content/_index.md b/docs/content/_index.md index 4d86c1412..46292a31d 100644 --- a/docs/content/_index.md +++ b/docs/content/_index.md @@ -624,6 +624,10 @@ If you want to do that, use components. While you can `set` values in included templates, those values only exist while rendering them: the template calling `include` doesn't see them. +For escaping purposes, what's important is where the file is being included. +For example if you have autoescape on for `.html` files, `` in `partial.txt` and +`{% include "partial.txt" %}` in `base.html`, the content of `partial.txt` will be rendered escaped. + ### Inheritance Tera uses the same kind of inheritance as Jinja2 and Django templates: @@ -832,7 +836,7 @@ Marks a variable as safe. - `{{ content | safe }}` will not be escaped - `{{ content | replace(from="Robert", to="Bob") | safe }}` will not be escaped - `{{ content | safe | replace(from="Robert", to="Bob") }}` will not be escaped either because the `content` is marked as safe and the `replace` filter is safety aware and will escape the `to` parameter if needed -- `{{ content | safe | trim }}` will be escaped because `trim` is not safety aware +- `{{ content | safe | truncate(length=10) }}` will be escaped because `truncate` is not safety aware Safety is preserved through safety aware filters when possible (`replace`, `upper` etc) but dropped by all others. @@ -867,24 +871,18 @@ Also takes an optional `pat` argument to trim by that pattern instead of whitesp Example: `{{ value | trim(pat="|") }}` -The output is never a safe string. - ##### trim_start Removes leading whitespace if the variable is a string. Also takes an optional `pat` argument to trim by that pattern instead of whitespace: Example: `{{ value | trim_start(pat="|") }}` -The output is never a safe string. - ##### trim_end Removes trailing whitespace if the variable is a string. Also takes an optional `pat` argument to trim by that pattern instead of whitespace: Example: `{{ value | trim_end(pat="|") }}` -The output is never a safe string. - ##### truncate Truncates a string to the indicated length. If the string has a smaller length than the `length` argument, the string is returned as is. @@ -1254,6 +1252,4 @@ The template rendering will error with the given message when encountered. There is only one string argument: `message` which is the message to display as the error - - {% endraw %} diff --git a/tera/src/filters.rs b/tera/src/filters.rs index 5659902e1..3dc2ef38e 100644 --- a/tera/src/filters.rs +++ b/tera/src/filters.rs @@ -164,50 +164,51 @@ pub(crate) fn pluralize(val: Value, kwargs: Kwargs, _: &State) -> TeraResult TeraResult { - if let Some(pat) = kwargs.get::<&str>("pat")? { - Ok(val +pub(crate) fn trim(val: StringInput, kwargs: Kwargs, _: &State) -> TeraResult { + let res = if let Some(pat) = kwargs.get::<&str>("pat")? { + val.as_str() .trim_start_matches(pat) .trim_end_matches(pat) - .to_string()) + .to_string() } else { - Ok(val.trim().to_string()) - } + val.as_str().trim().to_string() + }; + + Ok(val.inherit_safety(res)) } -pub(crate) fn trim_start(val: &str, kwargs: Kwargs, _: &State) -> TeraResult { - if let Some(pat) = kwargs.get::<&str>("pat")? { - Ok(val.trim_start_matches(pat).to_string()) +pub(crate) fn trim_start(val: StringInput, kwargs: Kwargs, _: &State) -> TeraResult { + let res = if let Some(pat) = kwargs.get::<&str>("pat")? { + val.as_str().trim_start_matches(pat).to_string() } else { - Ok(val.trim_start().to_string()) - } + val.as_str().trim_start().to_string() + }; + + Ok(val.inherit_safety(res)) } -pub(crate) fn trim_end(val: &str, kwargs: Kwargs, _: &State) -> TeraResult { - if let Some(pat) = kwargs.get::<&str>("pat")? { - Ok(val.trim_end_matches(pat).to_string()) +pub(crate) fn trim_end(val: StringInput, kwargs: Kwargs, _: &State) -> TeraResult { + let res = if let Some(pat) = kwargs.get::<&str>("pat")? { + val.as_str().trim_end_matches(pat).to_string() } else { - Ok(val.trim_end().to_string()) - } + val.as_str().trim_end().to_string() + }; + + Ok(val.inherit_safety(res)) } pub(crate) fn replace(val: StringInput, kwargs: Kwargs, state: &State) -> TeraResult { - let from = kwargs.must_get::("from")?; + let from = kwargs.must_get::<&str>("from")?; let to = kwargs.must_get::("to")?; // If we have autoescaping enabled and either the value or the replacement is safe, - // we go through StringInput to render everything as if it was {{ }} - // otherwise we do a basic str replace + // we go through StringInput otherwise we do a basic str replace let be_safe = state.autoescaping_enabled() && (val.is_safe() || to.is_safe()); if be_safe { - let out = val - .rendered(state)? - .replace(&*from.rendered(state)?, &to.rendered(state)?); + let out = val.rendered(state)?.replace(from, &to.rendered(state)?); Ok(Value::safe_string(&out)) } else { - Ok(Value::from( - val.as_str().replace(from.as_str(), to.as_str()), - )) + Ok(Value::from(val.as_str().replace(from, to.as_str()))) } } @@ -270,25 +271,38 @@ pub(crate) fn truncate(val: &str, kwargs: Kwargs, _: &State) -> TeraResult TeraResult { +pub(crate) fn indent(val: StringInput, kwargs: Kwargs, state: &State) -> TeraResult { let width = kwargs.get::("width")?.unwrap_or(4).min(1000); - let indentation = kwargs.get::<&str>("indentation")?.unwrap_or(" "); - let mut characters = indentation.chars(); - let indent_character = characters - .next() - .filter(|_| characters.next().is_none()) - .ok_or_else(|| { - Error::message("The `indentation` argument must contain exactly one character") - })?; + let indentation = kwargs.get::("indentation")?; let indent_first_line = kwargs.get::("first")?.unwrap_or(false); let indent_blank_line = kwargs.get::("blank")?.unwrap_or(false); - let indent = indent_character.to_string().repeat(width); - let s = val.as_str(); - let mut res = String::with_capacity(s.len() * 2); + if let Some(ref i) = indentation + && i.as_str().chars().count() != 1 + { + return Err(Error::message( + "The `indentation` argument must contain exactly one character", + )); + } + + let be_safe = state.autoescaping_enabled() + && (val.is_safe() || indentation.as_ref().is_some_and(|x| x.is_safe())); + + let val = if be_safe { + val.rendered(state)? + } else { + Cow::Borrowed(val.as_str()) + }; + let indent = match indentation { + Some(ref i) if be_safe => i.rendered(state)?.repeat(width), + Some(i) => i.as_str().repeat(width), + None => " ".repeat(width), + }; + + let mut res = String::with_capacity(val.len() * 2); let mut first_line = true; - for line in s.lines() { + for line in val.lines() { if first_line { if indent_first_line { res.push_str(&indent); @@ -303,11 +317,15 @@ pub(crate) fn indent(val: StringInput, kwargs: Kwargs, _: &State) -> TeraResult< res.push_str(line); } - if s.ends_with('\n') { + if val.ends_with('\n') { res.push('\n'); } - Ok(val.inherit_safety(res)) + if be_safe { + Ok(Value::safe_string(&res)) + } else { + Ok(Value::from(res)) + } } pub(crate) fn as_str(val: Value, _: Kwargs, _: &State) -> String { @@ -514,7 +532,11 @@ pub(crate) fn join(val: &[Value], kwargs: Kwargs, state: &State) -> TeraResult s.rendered(state)?, diff --git a/tera/src/snapshot_tests/rendering_inputs/success/escaping/join_filter.txt b/tera/src/snapshot_tests/rendering_inputs/success/escaping/join_filter.txt index 898b116d9..d86ec8daf 100644 --- a/tera/src/snapshot_tests/rendering_inputs/success/escaping/join_filter.txt +++ b/tera/src/snapshot_tests/rendering_inputs/success/escaping/join_filter.txt @@ -1,4 +1,5 @@ $$ tpl.html {% set x %}{{ some_html }}{% endset %} {{ [x, ""] | join(sep=" & ") }} -{{ ["a", "b"] | join(sep=" & ") }} \ No newline at end of file +{{ ["a", "b"] | join(sep=" & ") }} +{{ [none, 1, true, ""] | join(sep=",") }} \ No newline at end of file diff --git a/tera/src/snapshot_tests/rendering_inputs/success/escaping/set_filter_block_var.txt b/tera/src/snapshot_tests/rendering_inputs/success/escaping/set_filter_block_var.txt index e93b2c42f..410595cac 100644 --- a/tera/src/snapshot_tests/rendering_inputs/success/escaping/set_filter_block_var.txt +++ b/tera/src/snapshot_tests/rendering_inputs/success/escaping/set_filter_block_var.txt @@ -1,4 +1,4 @@ $$ tpl.html {% set x %}{{ some_html }} {% endset %} Escaped only once: {{x}} -Escaped twice: {{x | trim}} \ No newline at end of file +Escaped once as well: {{x | trim}} \ No newline at end of file diff --git a/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@join_filter.txt.snap b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@join_filter.txt.snap index bc83a7709..cbaea56f0 100644 --- a/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@join_filter.txt.snap +++ b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@join_filter.txt.snap @@ -6,3 +6,4 @@ input_file: tera/src/snapshot_tests/rendering_inputs/success/escaping/join_filte <p>Some HTML chars & more</p> & <raw> a & b +,1,true,<b> diff --git a/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@replace_filter_block_html.txt.snap b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@replace_filter_block_html.txt.snap index 9a2beeb16..cb7cc7f0a 100644 --- a/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@replace_filter_block_html.txt.snap +++ b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@replace_filter_block_html.txt.snap @@ -3,4 +3,4 @@ source: tera/src/snapshot_tests/rendering.rs expression: "&normalized_out" input_file: tera/src/snapshot_tests/rendering_inputs/success/escaping/replace_filter_block_html.txt --- -A & B +A and B diff --git a/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@replace_filter_block_html_var.txt.snap b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@replace_filter_block_html_var.txt.snap index da540ccaa..a967c4fb6 100644 --- a/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@replace_filter_block_html_var.txt.snap +++ b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@replace_filter_block_html_var.txt.snap @@ -3,4 +3,4 @@ source: tera/src/snapshot_tests/rendering.rs expression: "&normalized_out" input_file: tera/src/snapshot_tests/rendering_inputs/success/escaping/replace_filter_block_html_var.txt --- -<p>Some HTML chars and more</p> +andlt;pandgt;Some HTML chars andamp; moreandlt;/pandgt; diff --git a/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@set_filter_block_var.txt.snap b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@set_filter_block_var.txt.snap index 8bf70fc60..a9b51992b 100644 --- a/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@set_filter_block_var.txt.snap +++ b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_escaping_ok@set_filter_block_var.txt.snap @@ -5,4 +5,4 @@ input_file: tera/src/snapshot_tests/rendering_inputs/success/escaping/set_filter --- Escaped only once: <p>Some HTML chars & more</p> -Escaped twice: &lt;p&gt;Some HTML chars &amp; more&lt;/p&gt; +Escaped once as well: <p>Some HTML chars & more</p> From 0a96b7c38867079e606bb15b1ae9b3b204d0362c Mon Sep 17 00:00:00 2001 From: Vincent Prouillet Date: Tue, 25 Aug 2026 22:44:36 +0200 Subject: [PATCH 7/8] No need for zola workaround anymore --- .github/workflows/docs.yml | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 27a999b14..3eb7e89a5 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -24,19 +24,10 @@ jobs: - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@stable - - name: Resolve Zola next commit - id: zola - run: echo "sha=$(git ls-remote https://github.com/getzola/zola refs/heads/next | cut -f1)" >> "$GITHUB_OUTPUT" - - name: Cache Zola binary - id: cache-zola - uses: actions/cache@v5 + - name: Build Zola + upload Pages artifact + uses: getzola/github-pages@066755243e69f508fd1a74739fbf1a65f656c790 with: - path: ~/.cargo/bin/zola - key: zola-next-${{ steps.zola.outputs.sha }} - - name: Install Zola (next branch) - if: steps.cache-zola.outputs.cache-hit != 'true' - # Use next branch temporarily since these docs are built with it - run: cargo install --git https://github.com/getzola/zola --branch next --locked zola + zola_version: v0.23.4 - name: Build docs run: zola build From 8d41e7642ee63b7ef5505b538844a3f1130fb9da Mon Sep 17 00:00:00 2001 From: Vincent Prouillet Date: Wed, 26 Aug 2026 22:53:42 +0200 Subject: [PATCH 8/8] Last fixes? --- CHANGELOG.md | 7 +++++-- docs/content/_index.md | 3 ++- tera-contrib/src/lib.rs | 2 +- tera/src/filters.rs | 7 ++++--- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83d873122..b4c9352f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,13 @@ - Add `State::{escape,autoescaping_enabled,escape_if_needed}` - Add `StringInput` to use for input/parameters that is safety aware and use it for built-in filters: this -means you should need less `| safe` and that string safety is passed around correctly. Filters that delete -characters (`truncate`) do not automatically mark their output as safe even if the input was safe. +means you should need less `| safe` and that string safety is passed around correctly. Safety is dropped by +filters where the changes depend on the content itself (`truncate` can cut anywhere) and preserved when they specify exactly what changes (`trim`, `replace`). - Add `escape` filter that is safety aware (it will not escape an already safe string) and uses the current instance escape function +- Escaping included templates is now done base on _where_ it's included rather than the included template itself: +for example a `.txt` included in a `.html` will now be autoescaped if Tera autoescapes on `.html`. +- Fix double escape of filter/set blocks ## 2.2.0 (2026-08-20) diff --git a/docs/content/_index.md b/docs/content/_index.md index 46292a31d..f99c2ad85 100644 --- a/docs/content/_index.md +++ b/docs/content/_index.md @@ -838,7 +838,8 @@ Marks a variable as safe. - `{{ content | safe | replace(from="Robert", to="Bob") }}` will not be escaped either because the `content` is marked as safe and the `replace` filter is safety aware and will escape the `to` parameter if needed - `{{ content | safe | truncate(length=10) }}` will be escaped because `truncate` is not safety aware -Safety is preserved through safety aware filters when possible (`replace`, `upper` etc) but dropped by all others. +Safety is preserved through safety aware filters when the template author specifies exactly what changes +(`replace`, `trim`, `upper` etc) but dropped by the rest. ##### lower Converts a string to lowercase. diff --git a/tera-contrib/src/lib.rs b/tera-contrib/src/lib.rs index 7beb8e3a1..3948c5aed 100644 --- a/tera-contrib/src/lib.rs +++ b/tera-contrib/src/lib.rs @@ -4,7 +4,7 @@ //! //! To use them, call the Tera.{register_filter,register_function,register_test} functions: //! -//! ```no_compile +//! ```ignore //! use tera::Tera; //! //! let mut tera = Tera::default(); diff --git a/tera/src/filters.rs b/tera/src/filters.rs index 3dc2ef38e..3988e1538 100644 --- a/tera/src/filters.rs +++ b/tera/src/filters.rs @@ -523,11 +523,12 @@ pub(crate) fn nth(val: &[Value], kwargs: Kwargs, _: &State) -> TeraResult pub(crate) fn join(val: &[Value], kwargs: Kwargs, state: &State) -> TeraResult { let sep = kwargs.get::("sep")?; - // If we have autoescaping enabled and either one of the values or the separator is safe, - // we go through StringInput to render everything as if it was {{ }} + // If we have autoescaping enabled and either one of the values (only string counts) + // or the separator is safe, we go through StringInput to render everything as if it was {{ }} // otherwise we do a basic str replace let be_safe = state.autoescaping_enabled() - && (val.iter().any(|x| x.is_safe()) || sep.as_ref().is_some_and(|x| x.is_safe())); + && (val.iter().any(|x| x.is_string() && x.is_safe()) + || sep.as_ref().is_some_and(|x| x.is_safe())); if be_safe { let mut parts = Vec::with_capacity(val.len());