translations fixed to be safe
This commit is contained in:
@@ -13,9 +13,6 @@ pub(crate) const SESSION_COOKIE: &str = "analytics_token";
|
||||
/// `ui/base.html` renders `ui/navbar.html` unconditionally.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Nav {
|
||||
// Used by templates as they are migrated to Fluent. It is intentionally
|
||||
// present before the first template string is converted.
|
||||
#[allow(dead_code)]
|
||||
pub locale: crate::i18n::Locale,
|
||||
pub authenticated: bool,
|
||||
pub role: String,
|
||||
@@ -45,12 +42,10 @@ impl Nav {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn language(&self) -> &'static str {
|
||||
self.locale.code()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn tr(&self, key: &str) -> String {
|
||||
self.locale.lookup(key)
|
||||
}
|
||||
@@ -59,7 +54,6 @@ impl Nav {
|
||||
/// Values are strings; a template needing a *number* (for Fluent plural
|
||||
/// selection) should have the label built in Rust instead, where the
|
||||
/// `tr!` macro accepts `i64` arguments.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn tr_args(&self, key: &str, args: &[(&str, String)]) -> String {
|
||||
use std::{borrow::Cow, collections::HashMap};
|
||||
let map: HashMap<Cow<'static, str>, fluent_templates::fluent_bundle::FluentValue<'_>> =
|
||||
@@ -69,9 +63,24 @@ impl Nav {
|
||||
self.locale.lookup_args(key, &map)
|
||||
}
|
||||
|
||||
/// Like [`Self::tr_args`], but for the messages that carry markup of their
|
||||
/// own and are therefore rendered with `|safe`.
|
||||
///
|
||||
/// `|safe` turns Askama's escaping off for the whole rendered string,
|
||||
/// message and arguments alike. The escaping the arguments still need
|
||||
/// happens here instead: the *message* is trusted markup from the
|
||||
/// catalogue, the *values* are data — a role name, a column type — and stay
|
||||
/// data even if one ever arrives holding a `<`.
|
||||
pub(crate) fn tr_args_html(&self, key: &str, args: &[(&str, String)]) -> String {
|
||||
let escaped: Vec<(&str, String)> = args
|
||||
.iter()
|
||||
.map(|(name, value)| (*name, escape_html(value)))
|
||||
.collect();
|
||||
self.tr_args(key, &escaped)
|
||||
}
|
||||
|
||||
/// Translates a key with one numeric argument, for Fluent plural
|
||||
/// selection (`{ $count -> [one] … *[other] … }`).
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn tr_count(&self, key: &str, name: &str, count: &i64) -> String {
|
||||
use std::{borrow::Cow, collections::HashMap};
|
||||
let mut map: HashMap<
|
||||
@@ -103,6 +112,23 @@ impl Nav {
|
||||
}
|
||||
}
|
||||
|
||||
/// The escaping Askama's `escape` filter would have applied, for the values
|
||||
/// interpolated into a message rendered `|safe`.
|
||||
fn escape_html(value: &str) -> String {
|
||||
let mut escaped = String::with_capacity(value.len());
|
||||
for character in value.chars() {
|
||||
match character {
|
||||
'&' => escaped.push_str("&"),
|
||||
'<' => escaped.push_str("<"),
|
||||
'>' => escaped.push_str(">"),
|
||||
'"' => escaped.push_str("""),
|
||||
'\'' => escaped.push_str("'"),
|
||||
_ => escaped.push(character),
|
||||
}
|
||||
}
|
||||
escaped
|
||||
}
|
||||
|
||||
impl Default for Nav {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -203,6 +229,33 @@ pub(crate) fn render<T: Template>(template: &T) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The messages rendered `|safe` carry their own markup, so Askama does no
|
||||
/// escaping for them at all — which would make an interpolated value a way
|
||||
/// to write HTML into the page. The value is escaped here instead.
|
||||
///
|
||||
/// Nothing today can put a `<` in one of these: role names are checked by
|
||||
/// the backend and the column type is matched against the catalogue. This
|
||||
/// test is what keeps that from mattering.
|
||||
#[test]
|
||||
fn a_value_interpolated_into_html_cannot_carry_markup_of_its_own() {
|
||||
let nav = Nav::default();
|
||||
|
||||
let rendered = nav.tr_args_html(
|
||||
"grants-inherits-none",
|
||||
&[("role", "<script>alert(1)</script>".to_string())],
|
||||
);
|
||||
|
||||
assert!(
|
||||
!rendered.contains("<script>"),
|
||||
"an argument reached the page as markup: {rendered}"
|
||||
);
|
||||
assert!(rendered.contains("<script>"), "{rendered}");
|
||||
// The message's own markup is left alone: it is the catalogue's, and
|
||||
// `messages_rendered_as_html_use_only_balanced_inline_tags` vouches
|
||||
// for it.
|
||||
assert!(rendered.contains("<strong>"), "{rendered}");
|
||||
}
|
||||
|
||||
/// Every form on the site answers through `Alert`, so this is the one
|
||||
/// place that decides a failure cannot go unnoticed.
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user