translations fixed to be safe
This commit is contained in:
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 & 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 & decisive dates"), "{html}");
|
||||
assert!(html.contains("<strong>2026-08-12</strong>"), "{html}");
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user