7.1 KiB
i18n web commits — audit notes
Scope: the three commits that introduced Fluent i18n on the web crate:
2dc0d94—i18n on the web8f1bfdb—i18n on the web - deepseek translations967335d—i18n on the web - deepseek translations2
Baseline used for "what changed": git diff 2dc0d94^ HEAD.
Everything below has been fixed in the working tree. cargo check -p web and
cargo test -p web both pass (172 tests). Where the fix could regress silently,
there is now a test rather than a note.
What the tests found that the reading did not
cargo test -p web had 13 failures at HEAD — the audit's first pass never
ran it. Three separate causes, all now fixed:
- Fluent bidi isolation. Every interpolated value was wrapped in U+2068 /
U+2069, so a table name rendered as
invoice. Invisible on screen, but they are in the copied text and in everycontainsassertion. All three languages are left-to-right, so the loader now setsuse_isolating(false)(web/src/i18n/mod.rs). 7 failures. td-type-to-confirmlost half its sentence. The English message had been reduced to the single wordType, with the table name concatenated by the template — so the delete confirmation read "Typeinvoice" with no "to confirm", in all three languages. The message is now one whole sentence with{ $table }inside it, which also lets sk/cs put the verb where it belongs. 2 failures.- A hardcoded limit that was wrong.
schema-err-table-name-too-longsaid "63 characters";MAX_TABLE_NAME_LENGTHis 38. It anderror-identifier-too-longnow take{ $limit }from the constant, so the message cannot drift from the rule again. 1 failure.
The remaining 3 were a stale expectation: Askama escapes & as &, not
&.
Fixed — security
1. POST /login had no cross-site check at all
change_password directly above it had one; login did not. That is login
CSRF: another site can post its own credentials and move the victim's browser
into an account the attacker controls, and everything done there afterwards.
SameSite=Strict does not prevent it — Strict governs sending a cookie
cross-site, not setting one from a cross-site response.
register, logout, and the two analytics endpoints were also uncovered. All
now check. The helper's doc comment claimed it "covers every state-changing
endpoint the same way", which was false when written; it is true now, and
web/src/lib.rs has
a_cross_site_post_is_refused_before_any_backend_call walking all 22 POST
routes to keep it true.
2. The check ran after the backend call in four handlers
add_logic, add_validation (×3), import, and export loaded the page — a
gRPC round trip — and only then asked whether the post was cross-site. A refusal
that happens after the work is not a refusal. All moved to the first line of the
handler.
That same test pins the ordering rather than just the presence: the test router
points at a dead port, so a handler that called the backend first could not
answer 403.
3. Three copies of the check, plus one inline
services::reject_cross_site existed alongside private cross_site helpers in
add_validation, import, and export, and an inline copy in add_logic.
Four codepaths to keep in agreement. Deleted; everything calls the shared one.
4. |safe on messages that interpolate values
Three sites render a translation as raw HTML with a value substituted into it.
Neither was exploitable — role names are validated server-side to
[a-z][a-z0-9_-]* (server/src/auth/handlers/roles/store.rs:43), and $type
only renders behind catalog.is_compound() — but the guard sat in another crate
across a gRPC boundary, and the web crate re-rendered the result as trusted
markup without rechecking.
Nav::tr_args_html now escapes the argument values while leaving the message's
own markup alone, and the three sites use it. The invariant no longer depends on
a validator in a different process.
5. The bigger |safe exposure: the catalogues themselves
The audit's original framing missed this. There are 11 |safe sites, and the
sk/cs catalogues were machine-translated. Every one of those messages is
raw HTML written by something nobody reviewed tag-by-tag. A malformed tag is a
broken page; a hostile one is XSS.
web/src/i18n/catalogue.rs now checks, for all three locales on every test run:
- the
|safekeys — read out of the templates, so the list cannot drift — contain only<strong>,<code>,<em>,<a>, balanced; - an
<a>carries nothing but a same-apphref="/…"(nojavascript:, no off-site redirect); - every other message contains no markup at all, so a message cannot arrive at
a
|safesite later with tags already in it; - key parity and no duplicates, which the first pass had checked by hand.
Writing these three tests immediately turned up <a href="/login"> inside
notice-log-in-first and a literal <1s in ecb-less-than-1s — neither a bug,
both things nobody had looked at.
Fixed — correctness
-
A malformed
q=counted as1.0(web/src/i18n/mod.rs).q=bananaoutranked every honest preference in the header. Unparseable and out-of-range qvalues are now0.0per RFC 9110 §12.4.2; a missingq=is still1.0. Three tests. -
⟪key⟫shipped to production. The sentinel had no build-mode gate. It is nowdebug_assertions-only; a release build degrades a missing key to its own words rather than putting debug output on a user's screen. -
Runtime-built keys.
format!("td-money-{mode}")in two places, which no compiler can check. Both now call the existingMoneyMode::display_label, which matches on the enum. -
Misleading
#[allow(dead_code)]onlanguage,tr,tr_args,tr_count,lookup_args, andNav::locale— all of them used. Removed; the crate is still warning-free, so they were suppressing nothing but future signal. -
Untranslated strings reaching the UI: the dialog's
aria-label="close modal", and the two hardcoded"Invalid redirect"bodies. -
&headerswhere the parameter was already&HeaderMapinadd_logic, plus the repeatedLocale::from_headersin the same function — hoisted to onelet locale. Clippy is quiet onweb/srcnow apart from five pre-existing warnings unrelated to i18n.
Left alone, deliberately
&tr!(...)temporaries. The first pass called this "fragile" and implied a use-after-free risk. That was overstated: the failure mode is a compile error, which is the safe direction. Not worth touching.tr_args/tr_countsplit. A singleFluentValue-taking method would be nicer. It is an ergonomics wart, not a defect.tr!exporting a$crate::i18n::…path from a private module. Fine in-crate; only matters if this ever becomes a library.- ECB duration units (
d/h/m/s). Latin abbreviations, read the same in sk/cs. Worth an explicit decision someday, not a bug today. - The
czalias. Accepted on input, never emitted. Correct as-is.