translations fixed to be safe

This commit is contained in:
Priec
2026-08-15 12:44:36 +02:00
parent 6ef2694fef
commit 794efac594
26 changed files with 746 additions and 418 deletions

View File

@@ -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("&amp;"),
'<' => escaped.push_str("&lt;"),
'>' => escaped.push_str("&gt;"),
'"' => escaped.push_str("&quot;"),
'\'' => escaped.push_str("&#x27;"),
_ => 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("&lt;script&gt;"), "{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]