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

@@ -8,7 +8,7 @@ use axum::{
use crate::{
AppState,
{i18n::Locale, tr},
services::authenticated_request,
services::{authenticated_request, reject_cross_site},
};
use super::{
@@ -29,23 +29,13 @@ pub(crate) async fn create_logic(
headers: HeaderMap,
Form(form): Form<CreateLogicForm>,
) -> Response {
if let Some(rejection) = reject_cross_site(&headers) {
return rejection;
}
let submitted = form.clone();
if let Err(error) = load_page(state.clone(), &headers, submitted.clone(), None).await {
return render_loaded(&headers, Err(error));
}
if headers
.get("sec-fetch-site")
.is_some_and(|value| value == "cross-site")
{
return (
StatusCode::FORBIDDEN,
tr!(
Locale::from_headers(&headers),
"services-cross-site-rejected"
),
)
.into_response();
}
let request = match form.into_request(Locale::from_headers(&headers)) {
Ok(request) => request,
Err(message) => {
@@ -79,26 +69,21 @@ pub(crate) async fn create_logic(
}
fn render_loaded(headers: &HeaderMap, result: Result<super::state::AddLogicPageState, LoadError>) -> Response {
let locale = Locale::from_headers(headers);
match result {
Ok(page) => Html(ui::render_page(&page)).into_response(),
Err(LoadError::Unauthenticated) => Redirect::to("/login").into_response(),
Err(LoadError::Forbidden) => (
StatusCode::FORBIDDEN,
Html(ui::render_submission_error(
Locale::from_headers(&headers),
&tr!(
Locale::from_headers(&headers),
"add-logic-err-permission"
),
)),
)
.into_response(),
Err(LoadError::Forbidden) => {
let message = tr!(locale, "add-logic-err-permission");
(
StatusCode::FORBIDDEN,
Html(ui::render_submission_error(locale, &message)),
)
.into_response()
}
Err(LoadError::Backend(message)) => (
StatusCode::BAD_GATEWAY,
Html(ui::render_submission_error(
Locale::from_headers(&headers),
&message,
)),
Html(ui::render_submission_error(locale, &message)),
)
.into_response(),
}

View File

@@ -464,14 +464,10 @@ impl TableDraft {
column: self.generated_display_name(&generated.name),
data_type: generated.data_type.clone(),
option: if generated.inherits_currency {
let mode = match column.money_mode {
crate::schema::MoneyMode::Exact => "exact",
crate::schema::MoneyMode::Rounded => "half-up",
};
format!(
"{}, {}",
column.currency,
tr!(*locale, &format!("td-money-{mode}")),
column.money_mode.display_label(locale),
)
} else {
String::new()

View File

@@ -8,7 +8,7 @@ use axum::{
use crate::{
AppState,
{i18n::Locale, tr},
services::authenticated_request,
services::{authenticated_request, reject_cross_site},
};
use super::{
@@ -32,20 +32,13 @@ pub(crate) async fn new_validation_set_page(State(state): State<AppState>, heade
pub(crate) async fn save_field_validation(
State(state): State<AppState>, headers: HeaderMap, Form(form): Form<ValidationForm>,
) -> Response {
if let Some(rejection) = reject_cross_site(&headers) {
return rejection;
}
let submitted = form.clone();
if let Err(error) = load_page(state.clone(), &headers, submitted.clone(), false, None).await {
return render_loaded(&headers, Err(error));
}
if cross_site(&headers) {
return (
StatusCode::FORBIDDEN,
tr!(
Locale::from_headers(&headers),
"services-cross-site-rejected"
),
)
.into_response();
}
if !form.validation_set_name.trim().is_empty() {
let request = match form.apply_set_request(Locale::from_headers(&headers)) {
Ok(request) => request,
@@ -127,20 +120,13 @@ pub(crate) async fn save_field_validation(
pub(crate) async fn save_validation_rule(
State(state): State<AppState>, headers: HeaderMap, Form(form): Form<ValidationForm>,
) -> Response {
if let Some(rejection) = reject_cross_site(&headers) {
return rejection;
}
let submitted = form.clone();
if let Err(error) = load_page(state.clone(), &headers, submitted.clone(), true, None).await {
return render_loaded(&headers, Err(error));
}
if cross_site(&headers) {
return (
StatusCode::FORBIDDEN,
tr!(
Locale::from_headers(&headers),
"services-cross-site-rejected"
),
)
.into_response();
}
let request = match form.rule_request(Locale::from_headers(&headers)) {
Ok(request) => request,
Err(message) => {
@@ -183,20 +169,13 @@ pub(crate) async fn save_validation_rule(
pub(crate) async fn save_validation_set(
State(state): State<AppState>, headers: HeaderMap, Form(form): Form<ValidationSetForm>,
) -> Response {
if let Some(rejection) = reject_cross_site(&headers) {
return rejection;
}
let submitted = form.clone();
if let Err(error) = load_set_page(state.clone(), &headers, submitted.clone(), None).await {
return render_set_loaded(&headers, Err(error));
}
if cross_site(&headers) {
return (
StatusCode::FORBIDDEN,
tr!(
Locale::from_headers(&headers),
"services-cross-site-rejected"
),
)
.into_response();
}
let request = match form.request(Locale::from_headers(&headers)) {
Ok(request) => request,
Err(message) => {
@@ -289,7 +268,3 @@ fn render_loaded(
.into_response(),
}
}
fn cross_site(headers: &HeaderMap) -> bool {
headers.get("sec-fetch-site").is_some_and(|value| value == "cross-site")
}

View File

@@ -34,7 +34,10 @@ pub(crate) async fn admin_workspace(
}
}
pub(crate) async fn logout() -> Response {
pub(crate) async fn logout(headers: HeaderMap) -> Response {
if let Some(rejection) = crate::services::reject_cross_site(&headers) {
return rejection;
}
let mut response = Html(String::new()).into_response();
response.headers_mut().insert(
header::SET_COOKIE,

View File

@@ -148,7 +148,9 @@ mod tests {
assert!(html.contains("What can be posted right now"), "{html}");
assert!(html.contains("Ordinary transactions"));
assert!(html.contains("<strong>2026-08-13</strong>"), "{html}");
assert!(html.contains("Statements &amp; decisive dates"));
// Askama escapes `&` numerically. The title now comes from the
// catalogue as plain text, where it was pre-escaped markup before.
assert!(html.contains("Statements &#38; decisive dates"), "{html}");
assert!(html.contains("<strong>2026-08-12</strong>"), "{html}");
}

View File

@@ -454,7 +454,14 @@ pub(crate) async fn delete_table(
response.headers_mut().insert("hx-redirect", location);
response
}
Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Invalid redirect").into_response(),
Err(_) => (
StatusCode::INTERNAL_SERVER_ERROR,
Html(tr!(
Locale::from_headers(&headers),
"ui-err-invalid-redirect"
)),
)
.into_response(),
}
}
Ok(response) => {

View File

@@ -8,6 +8,7 @@ use axum::{
use crate::{
AppState,
{i18n::Locale, tr},
services::reject_cross_site,
ui::{Notice, render},
};
@@ -49,6 +50,9 @@ pub(crate) async fn load_catalog(
headers: HeaderMap,
Form(input): Form<CatalogInput>,
) -> Response {
if let Some(rejection) = reject_cross_site(&headers) {
return rejection;
}
if input.profile_name.trim().is_empty() {
return notice(Notice::error(
Locale::from_headers(&headers),
@@ -73,6 +77,9 @@ pub(crate) async fn run_query(
headers: HeaderMap,
Form(input): Form<QueryInput>,
) -> Response {
if let Some(rejection) = reject_cross_site(&headers) {
return rejection;
}
if input.profile_name.trim().is_empty() || input.sql.trim().is_empty() {
return notice(Notice::error(
Locale::from_headers(&headers),

View File

@@ -12,7 +12,7 @@ use crate::{
tables_data::{GetTableDataByPositionRequest, GetTableDataCountRequest},
},
{i18n::Locale, tr},
services::authenticated_request,
services::{authenticated_request, reject_cross_site},
};
use super::{
@@ -44,20 +44,13 @@ pub(crate) async fn export_csv(
headers: HeaderMap,
Form(form): Form<ExportForm>,
) -> Response {
if let Some(rejection) = reject_cross_site(&headers) {
return rejection;
}
let catalog = match load_page(state.clone(), &headers).await {
Ok(page) => page.catalog,
Err(error) => return load_error(&headers, error),
};
if cross_site(&headers) {
return (
StatusCode::FORBIDDEN,
tr!(
Locale::from_headers(&headers),
"services-cross-site-rejected"
),
)
.into_response();
}
let (profile_name, table_names) = match form.targets(Locale::from_headers(&headers)) {
Ok(targets) => targets,
Err(message) => {
@@ -218,7 +211,3 @@ fn backend_error(headers: &HeaderMap, message: &str) -> Response {
)
.into_response()
}
fn cross_site(headers: &HeaderMap) -> bool {
headers.get("sec-fetch-site").is_some_and(|value| value == "cross-site")
}

View File

@@ -14,7 +14,7 @@ use crate::{
tables_data::{PostTableDataBulkRequest, PostTableDataBulkRow},
},
{i18n::Locale, tr},
services::authenticated_request,
services::{authenticated_request, reject_cross_site},
};
use super::{
@@ -43,21 +43,14 @@ pub(crate) async fn import_csv(
headers: HeaderMap,
Form(form): Form<ImportForm>,
) -> Response {
if let Some(rejection) = reject_cross_site(&headers) {
return rejection;
}
let submitted = form.clone();
let page = match load_page(state.clone(), &headers, submitted.clone(), None).await {
Ok(page) => page,
Err(error) => return load_error(&headers, error),
};
if cross_site(&headers) {
return (
StatusCode::FORBIDDEN,
tr!(
Locale::from_headers(&headers),
"services-cross-site-rejected"
),
)
.into_response();
}
let (profile_name, table_names) = match form.targets(Locale::from_headers(&headers)) {
Ok(targets) => targets,
Err(message) => {
@@ -342,10 +335,6 @@ fn backend_error(headers: &HeaderMap, message: String) -> Response {
.into_response()
}
fn cross_site(headers: &HeaderMap) -> bool {
headers.get("sec-fetch-site").is_some_and(|value| value == "cross-site")
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -66,6 +66,13 @@ pub(crate) async fn login(
headers: HeaderMap,
Form(input): Form<LoginInput>,
) -> Response {
// Logging in is state-changing even though it needs no session: without
// this, another site can post its own credentials here and silently move
// the browser into an account the attacker controls. `SameSite=Strict`
// does not stop it — Strict governs sending the cookie, not setting one.
if let Some(rejection) = reject_cross_site(&headers) {
return rejection;
}
if input.identifier.trim().is_empty() {
let message = tr!(
Locale::from_headers(&headers),

View File

@@ -21,7 +21,7 @@ where
return rejection;
}
match operation.await {
Ok(()) => success_redirect(destination),
Ok(()) => success_redirect(Locale::from_headers(headers), destination),
Err(message) => (
StatusCode::UNPROCESSABLE_ENTITY,
Html(ui::render_mutation_error(
@@ -40,9 +40,16 @@ where
/// the browser follows a 303 itself, transparently — htmx then sees a 200
/// holding the whole redirected page and swaps *that* into the form's target,
/// putting a second copy of the page inside the status line.
fn success_redirect(destination: &str) -> Response {
fn success_redirect(locale: Locale, destination: &str) -> Response {
let Ok(destination) = HeaderValue::try_from(destination) else {
return (StatusCode::INTERNAL_SERVER_ERROR, "Invalid redirect").into_response();
return (
StatusCode::INTERNAL_SERVER_ERROR,
Html(ui::render_mutation_error(
locale,
&tr!(locale, "ui-err-invalid-redirect"),
)),
)
.into_response();
};
let mut response = Html(String::new()).into_response();
response.headers_mut().insert("hx-redirect", destination);
@@ -90,7 +97,8 @@ mod tests {
/// and nothing in the body.
#[test]
fn a_successful_form_redirects_by_header_and_returns_no_markup_to_swap() {
let response = success_redirect("/permissions/users?updated=true");
let response =
success_redirect(Locale::default(), "/permissions/users?updated=true");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(

View File

@@ -6,7 +6,9 @@ use axum::{
};
use tonic::Request;
use crate::{AppState, auth::RegisterRequest, i18n::Locale, ui::Nav};
use crate::{
AppState, auth::RegisterRequest, i18n::Locale, services::reject_cross_site, ui::Nav,
};
use super::{state::RegisterInput, ui};
@@ -19,6 +21,9 @@ pub(crate) async fn register(
headers: HeaderMap,
Form(input): Form<RegisterInput>,
) -> Response {
if let Some(rejection) = reject_cross_site(&headers) {
return rejection;
}
// Same shaping as the client's `RegisterFormState::try_register`: trim
// every field, upper-case the country code, and leave the checking to the
// backend.