max / makenotwork
11 files changed,
+239 insertions,
-20 deletions
| @@ -404,15 +404,45 @@ fn is_hiding_property(prop: &Property) -> bool { | |||
| 404 | 404 | | "width:0px" | |
| 405 | 405 | | "height:0" | |
| 406 | 406 | | "height:0px" | |
| 407 | + | // UX-M4: 0-sized box via max-* and 0 font-size hide content too. | |
| 408 | + | | "max-width:0" | |
| 409 | + | | "max-width:0px" | |
| 410 | + | | "max-height:0" | |
| 411 | + | | "max-height:0px" | |
| 412 | + | | "font-size:0" | |
| 413 | + | | "font-size:0px" | |
| 414 | + | // Legacy clip:rect(0,0,0,0) screen-reader-hide trick. | |
| 415 | + | | "clip:rect(0,0,0,0)" | |
| 416 | + | | "clip:rect(0px,0px,0px,0px)" | |
| 407 | 417 | ) || (norm.starts_with("transform:") && norm.contains("scale(0)")) | |
| 418 | + | // clip-path clipping the element to nothing (UX-M4). | |
| 419 | + | || (norm.starts_with("clip-path:") | |
| 420 | + | && (norm.contains("inset(100%") || norm.contains("circle(0"))) | |
| 421 | + | // Off-screen text via large-negative text-indent (UX-M4). | |
| 422 | + | || is_offscreen_text_indent(&norm) | |
| 423 | + | } | |
| 424 | + | ||
| 425 | + | /// Large-negative `text-indent` — the classic off-screen text-hiding trick | |
| 426 | + | /// (`text-indent:-9999px`). Anything ≤ -1000px (or unitless) counts as hiding. | |
| 427 | + | fn is_offscreen_text_indent(norm: &str) -> bool { | |
| 428 | + | norm.strip_prefix("text-indent:") | |
| 429 | + | .map(|rest| rest.strip_suffix("px").unwrap_or(rest)) | |
| 430 | + | .and_then(|n| n.parse::<f32>().ok()) | |
| 431 | + | .map(|v| v <= -1000.0) | |
| 432 | + | .unwrap_or(false) | |
| 408 | 433 | } | |
| 409 | 434 | ||
| 410 | 435 | /// Drop infinite animations faster than 2s (strobe guard, decision #3). The | |
| 411 | 436 | /// reduced-motion override handles accessibility; this caps the worst abuse for | |
| 412 | 437 | /// everyone else. | |
| 413 | 438 | fn enforce_animation_budget(decls: &mut DeclarationBlock, rejections: &mut Vec<Rejection>) { | |
| 439 | + | /// A finite iteration-count this high at a sub-2s duration strobes just like | |
| 440 | + | /// `infinite` does (UX-M5) — `infinite` was the only case caught before. | |
| 441 | + | const STROBE_MAX_ITERATIONS: f32 = 20.0; | |
| 442 | + | ||
| 414 | 443 | let mut has_infinite = false; | |
| 415 | 444 | let mut min_duration: Option<f32> = None; | |
| 445 | + | let mut max_iterations: Option<f32> = None; | |
| 416 | 446 | ||
| 417 | 447 | for list in [&decls.declarations, &decls.important_declarations] { | |
| 418 | 448 | for prop in list { | |
| @@ -424,13 +454,18 @@ fn enforce_animation_budget(decls: &mut DeclarationBlock, rejections: &mut Vec<R | |||
| 424 | 454 | } | |
| 425 | 455 | if let Some(rest) = raw.strip_prefix("animation-duration:") { | |
| 426 | 456 | update_min_duration(rest, &mut min_duration); | |
| 457 | + | } else if let Some(rest) = raw.strip_prefix("animation-iteration-count:") { | |
| 458 | + | update_max_iterations(rest, &mut max_iterations); | |
| 427 | 459 | } else if let Some(rest) = raw.strip_prefix("animation:") { | |
| 428 | 460 | update_min_duration(rest, &mut min_duration); | |
| 461 | + | update_max_iterations(rest, &mut max_iterations); | |
| 429 | 462 | } | |
| 430 | 463 | } | |
| 431 | 464 | } | |
| 432 | 465 | ||
| 433 | - | let strobe = has_infinite && min_duration.map(|d| d < 2.0).unwrap_or(false); | |
| 466 | + | let fast = min_duration.map(|d| d < 2.0).unwrap_or(false); | |
| 467 | + | let high_count = max_iterations.map(|n| n >= STROBE_MAX_ITERATIONS).unwrap_or(false); | |
| 468 | + | let strobe = (has_infinite || high_count) && fast; | |
| 434 | 469 | if !strobe { | |
| 435 | 470 | return; | |
| 436 | 471 | } | |
| @@ -465,6 +500,22 @@ fn update_min_duration(value: &str, min: &mut Option<f32>) { | |||
| 465 | 500 | } | |
| 466 | 501 | } | |
| 467 | 502 | ||
| 503 | + | /// Track the largest finite iteration-count seen, scanning either the explicit | |
| 504 | + | /// `animation-iteration-count` value or the `animation` shorthand (UX-M5). A bare | |
| 505 | + | /// unitless number is the count; durations carry `s`/`ms` and percentages `%`, so | |
| 506 | + | /// they're skipped. `infinite` is handled separately. | |
| 507 | + | fn update_max_iterations(value: &str, max: &mut Option<f32>) { | |
| 508 | + | for token in value.split([' ', ',']) { | |
| 509 | + | let token = token.trim(); | |
| 510 | + | if token.is_empty() || token.ends_with('s') || token.ends_with('%') { | |
| 511 | + | continue; | |
| 512 | + | } | |
| 513 | + | if let Ok(n) = token.parse::<f32>() { | |
| 514 | + | *max = Some(max.map_or(n, |m| m.max(n))); | |
| 515 | + | } | |
| 516 | + | } | |
| 517 | + | } | |
| 518 | + | ||
| 468 | 519 | /// Parse a CSS time token to seconds. Returns None for non-time tokens. | |
| 469 | 520 | fn parse_seconds(token: &str) -> Option<f32> { | |
| 470 | 521 | let t = token.trim(); | |
| @@ -603,6 +654,24 @@ mod tests { | |||
| 603 | 654 | } | |
| 604 | 655 | ||
| 605 | 656 | #[test] | |
| 657 | + | fn mnw_widened_hiding_properties_stripped() { | |
| 658 | + | // UX-M4: clip-path, font-size:0, and off-screen text-indent are all hides. | |
| 659 | + | for decl in [ | |
| 660 | + | "clip-path: inset(100%)", | |
| 661 | + | "font-size: 0", | |
| 662 | + | "text-indent: -9999px", | |
| 663 | + | "max-height: 0", | |
| 664 | + | "clip: rect(0, 0, 0, 0)", | |
| 665 | + | ] { | |
| 666 | + | let (_out, rej) = san(&format!(".mnw-buy {{ {decl} }}")); | |
| 667 | + | assert!( | |
| 668 | + | rej.iter().any(|r| r.kind == RejectionKind::HidingProperty), | |
| 669 | + | "expected {decl} to be treated as hiding" | |
| 670 | + | ); | |
| 671 | + | } | |
| 672 | + | } | |
| 673 | + | ||
| 674 | + | #[test] | |
| 606 | 675 | fn reduced_motion_appended() { | |
| 607 | 676 | let out = scoped("p { color: red }"); | |
| 608 | 677 | assert!(out.contains("prefers-reduced-motion")); | |
| @@ -624,6 +693,29 @@ mod tests { | |||
| 624 | 693 | } | |
| 625 | 694 | ||
| 626 | 695 | #[test] | |
| 696 | + | fn fast_high_finite_count_animation_dropped() { | |
| 697 | + | // UX-M5: a fast animation with a high *finite* iteration-count strobes too, | |
| 698 | + | // not just `infinite`. | |
| 699 | + | let (out, rej) = san(".spin { animation: spin 1s linear 100 }"); | |
| 700 | + | assert!(!normalize(&out).contains("animation:spin")); | |
| 701 | + | assert!(rej.iter().any(|r| r.kind == RejectionKind::AnimationBudget)); | |
| 702 | + | ||
| 703 | + | // Explicit property form is caught as well. | |
| 704 | + | let (_out2, rej2) = san( | |
| 705 | + | ".spin { animation-name: spin; animation-duration: 0.5s; animation-iteration-count: 50 }", | |
| 706 | + | ); | |
| 707 | + | assert!(rej2.iter().any(|r| r.kind == RejectionKind::AnimationBudget)); | |
| 708 | + | } | |
| 709 | + | ||
| 710 | + | #[test] | |
| 711 | + | fn fast_low_finite_count_animation_kept() { | |
| 712 | + | // A handful of iterations at a fast duration is fine — not a strobe. | |
| 713 | + | let (out, rej) = san(".spin { animation: spin 1s linear 3 }"); | |
| 714 | + | assert!(out.to_lowercase().contains("animation")); | |
| 715 | + | assert!(!rej.iter().any(|r| r.kind == RejectionKind::AnimationBudget)); | |
| 716 | + | } | |
| 717 | + | ||
| 718 | + | #[test] | |
| 627 | 719 | fn expression_function_recorded() { | |
| 628 | 720 | let (_out, rej) = san(".x { width: expression(alert(1)) }"); | |
| 629 | 721 | assert!(rej.iter().any(|r| r.kind == RejectionKind::BlockedFunction)); |
| @@ -0,0 +1,120 @@ | |||
| 1 | + | //! Body extractors that route deserialization/validation failures through | |
| 2 | + | //! [`AppError::validation`] instead of axum's raw plain-text rejection (UX-S4). | |
| 3 | + | //! | |
| 4 | + | //! Several request structs embed validating newtypes (e.g. `Slug`, `PriceCents`) | |
| 5 | + | //! whose `Deserialize` impls reject bad input. With a bare `Form<T>`/`Json<T>` | |
| 6 | + | //! that rejection surfaces as axum's default plain-text 4xx — no friendly error | |
| 7 | + | //! template, no JSON-on-API-routes treatment, no preserved status semantics. These | |
| 8 | + | //! wrappers map any extraction failure to `AppError::validation`, so the request | |
| 9 | + | //! flows through the same error path as an explicit validation failure: the | |
| 10 | + | //! friendly template for page routes, `{"error": "..."}` for API routes (via | |
| 11 | + | //! `json_error_layer`), and a 422. | |
| 12 | + | ||
| 13 | + | use axum::extract::{FromRequest, Request}; | |
| 14 | + | use serde::de::DeserializeOwned; | |
| 15 | + | ||
| 16 | + | use crate::error::AppError; | |
| 17 | + | ||
| 18 | + | /// `axum::Form<T>` whose extraction failures become [`AppError::validation`]. | |
| 19 | + | pub struct ValidatedForm<T>(pub T); | |
| 20 | + | ||
| 21 | + | impl<T, S> FromRequest<S> for ValidatedForm<T> | |
| 22 | + | where | |
| 23 | + | T: DeserializeOwned, | |
| 24 | + | S: Send + Sync, | |
| 25 | + | { | |
| 26 | + | type Rejection = AppError; | |
| 27 | + | ||
| 28 | + | async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> { | |
| 29 | + | match axum::Form::<T>::from_request(req, state).await { | |
| 30 | + | Ok(axum::Form(value)) => Ok(Self(value)), | |
| 31 | + | Err(rejection) => Err(rejection_to_validation(rejection.body_text())), | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | /// `axum::Json<T>` whose extraction failures become [`AppError::validation`]. | |
| 37 | + | pub struct ValidatedJson<T>(pub T); | |
| 38 | + | ||
| 39 | + | impl<T, S> FromRequest<S> for ValidatedJson<T> | |
| 40 | + | where | |
| 41 | + | T: DeserializeOwned, | |
| 42 | + | S: Send + Sync, | |
| 43 | + | { | |
| 44 | + | type Rejection = AppError; | |
| 45 | + | ||
| 46 | + | async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> { | |
| 47 | + | match axum::Json::<T>::from_request(req, state).await { | |
| 48 | + | Ok(axum::Json(value)) => Ok(Self(value)), | |
| 49 | + | Err(rejection) => Err(rejection_to_validation(rejection.body_text())), | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | /// `axum_extra::extract::Form<T>` (repeated-field form) whose extraction failures | |
| 55 | + | /// become [`AppError::validation`]. Used by the project wizard, whose `BasicsForm` | |
| 56 | + | /// embeds a validating `Slug` newtype — without this, a bad slug fails extraction | |
| 57 | + | /// with a raw rejection while a bad title (validated in-handler) got the friendly | |
| 58 | + | /// path, the exact split the audit called out (UX-S4). | |
| 59 | + | pub struct ValidatedHtmlForm<T>(pub T); | |
| 60 | + | ||
| 61 | + | impl<T, S> FromRequest<S> for ValidatedHtmlForm<T> | |
| 62 | + | where | |
| 63 | + | T: DeserializeOwned, | |
| 64 | + | S: Send + Sync, | |
| 65 | + | { | |
| 66 | + | type Rejection = AppError; | |
| 67 | + | ||
| 68 | + | async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> { | |
| 69 | + | match axum_extra::extract::Form::<T>::from_request(req, state).await { | |
| 70 | + | Ok(axum_extra::extract::Form(value)) => Ok(Self(value)), | |
| 71 | + | Err(rejection) => Err(rejection_to_validation(rejection.to_string())), | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | /// Strip axum's "Failed to deserialize …: " machinery so the user-facing message | |
| 77 | + | /// is the underlying cause (which, for a validating newtype, is its own error | |
| 78 | + | /// string). Falls back to a generic message when nothing useful remains. | |
| 79 | + | fn rejection_to_validation(detail: String) -> AppError { | |
| 80 | + | let msg = detail | |
| 81 | + | .strip_prefix("Failed to deserialize form body: ") | |
| 82 | + | .or_else(|| { | |
| 83 | + | detail.strip_prefix("Failed to deserialize the JSON body into the target type: ") | |
| 84 | + | }) | |
| 85 | + | .unwrap_or(&detail) | |
| 86 | + | .trim(); | |
| 87 | + | let msg = if msg.is_empty() { | |
| 88 | + | "Please check your input and try again.".to_string() | |
| 89 | + | } else { | |
| 90 | + | msg.to_string() | |
| 91 | + | }; | |
| 92 | + | AppError::validation(msg) | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | #[cfg(test)] | |
| 96 | + | mod tests { | |
| 97 | + | use super::*; | |
| 98 | + | ||
| 99 | + | #[test] | |
| 100 | + | fn strips_form_prefix() { | |
| 101 | + | let e = rejection_to_validation( | |
| 102 | + | "Failed to deserialize form body: slug must be lowercase".to_string(), | |
| 103 | + | ); | |
| 104 | + | assert_eq!(e.user_message(), "slug must be lowercase"); | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | #[test] | |
| 108 | + | fn strips_json_prefix() { | |
| 109 | + | let e = rejection_to_validation( | |
| 110 | + | "Failed to deserialize the JSON body into the target type: price too high".to_string(), | |
| 111 | + | ); | |
| 112 | + | assert_eq!(e.user_message(), "price too high"); | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | #[test] | |
| 116 | + | fn falls_back_when_empty() { | |
| 117 | + | let e = rejection_to_validation(String::new()); | |
| 118 | + | assert_eq!(e.user_message(), "Please check your input and try again."); | |
| 119 | + | } | |
| 120 | + | } |
| @@ -12,6 +12,7 @@ pub mod custom_pages; | |||
| 12 | 12 | pub mod db; | |
| 13 | 13 | pub mod email; | |
| 14 | 14 | pub mod error; | |
| 15 | + | pub mod extractors; | |
| 15 | 16 | pub mod git; | |
| 16 | 17 | pub mod git_ssh; | |
| 17 | 18 | pub mod license_templates; |
| @@ -17,6 +17,7 @@ use crate::{ | |||
| 17 | 17 | AppState, | |
| 18 | 18 | }; | |
| 19 | 19 | ||
| 20 | + | use crate::extractors::ValidatedJson; | |
| 20 | 21 | use super::{verify_blog_post_ownership, verify_project_ownership}; | |
| 21 | 22 | ||
| 22 | 23 | // ============================================================================= | |
| @@ -126,7 +127,7 @@ pub(super) async fn create_blog_post( | |||
| 126 | 127 | State(state): State<AppState>, | |
| 127 | 128 | AuthUser(user): AuthUser, | |
| 128 | 129 | Path(project_id): Path<ProjectId>, | |
| 129 | - | Json(req): Json<CreateBlogPostRequest>, | |
| 130 | + | ValidatedJson(req): ValidatedJson<CreateBlogPostRequest>, | |
| 130 | 131 | ) -> Result<impl IntoResponse> { | |
| 131 | 132 | user.check_not_suspended()?; | |
| 132 | 133 | verify_project_ownership(&state, project_id, user.id).await?; | |
| @@ -184,7 +185,7 @@ pub(super) async fn update_blog_post( | |||
| 184 | 185 | State(state): State<AppState>, | |
| 185 | 186 | AuthUser(user): AuthUser, | |
| 186 | 187 | Path(id): Path<BlogPostId>, | |
| 187 | - | Json(req): Json<UpdateBlogPostRequest>, | |
| 188 | + | ValidatedJson(req): ValidatedJson<UpdateBlogPostRequest>, | |
| 188 | 189 | ) -> Result<impl IntoResponse> { | |
| 189 | 190 | user.check_not_suspended()?; | |
| 190 | 191 | let existing = verify_blog_post_ownership(&state, id, user.id).await?; |
| @@ -18,6 +18,7 @@ use crate::{ | |||
| 18 | 18 | AppState, | |
| 19 | 19 | }; | |
| 20 | 20 | ||
| 21 | + | use crate::extractors::{ValidatedForm, ValidatedJson}; | |
| 21 | 22 | use super::verify_project_ownership; | |
| 22 | 23 | ||
| 23 | 24 | // ============================================================================= | |
| @@ -53,7 +54,7 @@ pub(super) async fn create_project( | |||
| 53 | 54 | State(state): State<AppState>, | |
| 54 | 55 | headers: HeaderMap, | |
| 55 | 56 | AuthUser(user): AuthUser, | |
| 56 | - | Form(req): Form<CreateProjectRequest>, | |
| 57 | + | ValidatedForm(req): ValidatedForm<CreateProjectRequest>, | |
| 57 | 58 | ) -> Result<Response> { | |
| 58 | 59 | user.check_not_suspended()?; | |
| 59 | 60 | ||
| @@ -214,7 +215,7 @@ pub(super) async fn update_project( | |||
| 214 | 215 | State(state): State<AppState>, | |
| 215 | 216 | AuthUser(user): AuthUser, | |
| 216 | 217 | Path(id): Path<ProjectId>, | |
| 217 | - | Json(req): Json<UpdateProjectRequest>, | |
| 218 | + | ValidatedJson(req): ValidatedJson<UpdateProjectRequest>, | |
| 218 | 219 | ) -> Result<impl IntoResponse> { | |
| 219 | 220 | tracing::Span::current().record("project_id", tracing::field::display(&id)); | |
| 220 | 221 | user.check_not_suspended()?; |
| @@ -18,6 +18,7 @@ use crate::{ | |||
| 18 | 18 | AppState, | |
| 19 | 19 | }; | |
| 20 | 20 | ||
| 21 | + | use crate::extractors::ValidatedJson; | |
| 21 | 22 | use super::verify_project_ownership; | |
| 22 | 23 | ||
| 23 | 24 | /// JSON response representing a subscription tier. | |
| @@ -54,7 +55,7 @@ pub(super) async fn create_tier( | |||
| 54 | 55 | State(state): State<AppState>, | |
| 55 | 56 | AuthUser(user): AuthUser, | |
| 56 | 57 | Path(project_id): Path<ProjectId>, | |
| 57 | - | Json(req): Json<CreateTierRequest>, | |
| 58 | + | ValidatedJson(req): ValidatedJson<CreateTierRequest>, | |
| 58 | 59 | ) -> Result<impl IntoResponse> { | |
| 59 | 60 | user.check_not_suspended()?; | |
| 60 | 61 | // Verify ownership | |
| @@ -151,7 +152,7 @@ pub(super) async fn update_tier( | |||
| 151 | 152 | State(state): State<AppState>, | |
| 152 | 153 | AuthUser(user): AuthUser, | |
| 153 | 154 | Path(tier_id): Path<SubscriptionTierId>, | |
| 154 | - | Json(req): Json<UpdateTierRequest>, | |
| 155 | + | ValidatedJson(req): ValidatedJson<UpdateTierRequest>, | |
| 155 | 156 | ) -> Result<impl IntoResponse> { | |
| 156 | 157 | user.check_not_suspended()?; | |
| 157 | 158 | // Get tier and verify project ownership |
| @@ -4,7 +4,7 @@ use axum::{ | |||
| 4 | 4 | extract::State, | |
| 5 | 5 | http::{header::HeaderMap, StatusCode}, | |
| 6 | 6 | response::{Html, IntoResponse, Response}, | |
| 7 | - | Form, Json, | |
| 7 | + | Json, | |
| 8 | 8 | }; | |
| 9 | 9 | use serde::{Deserialize, Serialize}; | |
| 10 | 10 | use tower_sessions::Session; | |
| @@ -20,6 +20,7 @@ use crate::{ | |||
| 20 | 20 | AppState, | |
| 21 | 21 | }; | |
| 22 | 22 | ||
| 23 | + | use crate::extractors::ValidatedForm; | |
| 23 | 24 | use super::SuccessMessageResponse; | |
| 24 | 25 | ||
| 25 | 26 | /// JSON response for profile updates. | |
| @@ -44,7 +45,7 @@ pub(in crate::routes::api) async fn update_profile( | |||
| 44 | 45 | State(state): State<AppState>, | |
| 45 | 46 | headers: HeaderMap, | |
| 46 | 47 | AuthUser(user): AuthUser, | |
| 47 | - | Form(req): Form<UpdateProfileRequest>, | |
| 48 | + | ValidatedForm(req): ValidatedForm<UpdateProfileRequest>, | |
| 48 | 49 | ) -> Result<Response> { | |
| 49 | 50 | user.check_not_suspended()?; | |
| 50 | 51 | // Validate input | |
| @@ -91,7 +92,7 @@ pub(in crate::routes::api) async fn update_profile_theme( | |||
| 91 | 92 | State(state): State<AppState>, | |
| 92 | 93 | headers: HeaderMap, | |
| 93 | 94 | AuthUser(user): AuthUser, | |
| 94 | - | Form(req): Form<UpdateThemeRequest>, | |
| 95 | + | ValidatedForm(req): ValidatedForm<UpdateThemeRequest>, | |
| 95 | 96 | ) -> Result<Response> { | |
| 96 | 97 | user.check_not_suspended()?; | |
| 97 | 98 | let theme_id = crate::theming::normalize_theme_id(req.theme_id.as_deref()) | |
| @@ -121,7 +122,7 @@ pub(in crate::routes::api) async fn update_password( | |||
| 121 | 122 | headers: HeaderMap, | |
| 122 | 123 | session: Session, | |
| 123 | 124 | AuthUser(user): AuthUser, | |
| 124 | - | Form(req): Form<UpdatePasswordRequest>, | |
| 125 | + | ValidatedForm(req): ValidatedForm<UpdatePasswordRequest>, | |
| 125 | 126 | ) -> Result<Response> { | |
| 126 | 127 | user.check_not_sandbox()?; | |
| 127 | 128 | let is_htmx = is_htmx_request(&headers); | |
| @@ -417,7 +418,7 @@ pub(in crate::routes::api) async fn request_account_deletion( | |||
| 417 | 418 | State(state): State<AppState>, | |
| 418 | 419 | headers: HeaderMap, | |
| 419 | 420 | AuthUser(user): AuthUser, | |
| 420 | - | Form(form): Form<RequestDeletionForm>, | |
| 421 | + | ValidatedForm(form): ValidatedForm<RequestDeletionForm>, | |
| 421 | 422 | ) -> Result<Response> { | |
| 422 | 423 | user.check_not_sandbox()?; | |
| 423 | 424 | let is_htmx = is_htmx_request(&headers); | |
| @@ -488,7 +489,7 @@ pub(in crate::routes::api) async fn submit_appeal( | |||
| 488 | 489 | State(state): State<AppState>, | |
| 489 | 490 | headers: HeaderMap, | |
| 490 | 491 | AuthUser(user): AuthUser, | |
| 491 | - | Form(form): Form<AppealForm>, | |
| 492 | + | ValidatedForm(form): ValidatedForm<AppealForm>, | |
| 492 | 493 | ) -> Result<Response> { | |
| 493 | 494 | let is_htmx = is_htmx_request(&headers); | |
| 494 | 495 |
| @@ -34,14 +34,14 @@ pub(in crate::routes::api) async fn submit_support_ticket( | |||
| 34 | 34 | let message = form.message.trim(); | |
| 35 | 35 | let category = form.category.trim(); | |
| 36 | 36 | ||
| 37 | - | if subject.is_empty() || subject.len() > 200 { | |
| 37 | + | if subject.is_empty() || subject.chars().count() > 200 { | |
| 38 | 38 | return Ok(Html(FormStatusTemplate { | |
| 39 | 39 | success: false, | |
| 40 | 40 | message: "Subject must be between 1 and 200 characters.".to_string(), | |
| 41 | 41 | }.render_string()).into_response()); | |
| 42 | 42 | } | |
| 43 | 43 | ||
| 44 | - | if message.is_empty() || message.len() > 5000 { | |
| 44 | + | if message.is_empty() || message.chars().count() > 5000 { | |
| 45 | 45 | return Ok(Html(FormStatusTemplate { | |
| 46 | 46 | success: false, | |
| 47 | 47 | message: "Message must be between 1 and 5000 characters.".to_string(), |
| @@ -11,7 +11,7 @@ use axum::{ | |||
| 11 | 11 | routing::get, | |
| 12 | 12 | Form, Json, | |
| 13 | 13 | }; | |
| 14 | - | use crate::csrf::{post_csrf_skip, CsrfRouter}; | |
| 14 | + | use crate::csrf::{post_csrf_manual, post_csrf_skip, CsrfRouter}; | |
| 15 | 15 | use rand::RngCore; | |
| 16 | 16 | use serde::{Deserialize, Serialize}; | |
| 17 | 17 | use sha2::{Digest, Sha256}; | |
| @@ -1029,7 +1029,7 @@ pub fn oauth_routes() -> CsrfRouter<AppState> { | |||
| 1029 | 1029 | ||
| 1030 | 1030 | let authorize_routes = CsrfRouter::new() | |
| 1031 | 1031 | .route_get("/oauth/authorize", get(authorize_get)) | |
| 1032 | - | .route("/oauth/authorize", post_csrf_skip("pre-auth OAuth authorize endpoint", authorize_post)) | |
| 1032 | + | .route("/oauth/authorize", post_csrf_manual("OAuth authorize validates the consent form _csrf in-handler via validate_token_consuming", authorize_post)) | |
| 1033 | 1033 | .route_layer(GovernorLayer { | |
| 1034 | 1034 | config: authorize_rate_limit, | |
| 1035 | 1035 | }); |
| @@ -9,7 +9,6 @@ use axum::{ | |||
| 9 | 9 | response::{IntoResponse, Response}, | |
| 10 | 10 | Form, | |
| 11 | 11 | }; | |
| 12 | - | use axum_extra::extract::Form as HtmlForm; | |
| 13 | 12 | use tower_sessions::Session; | |
| 14 | 13 | ||
| 15 | 14 | use crate::{ | |
| @@ -23,6 +22,7 @@ use crate::{ | |||
| 23 | 22 | AppState, | |
| 24 | 23 | }; | |
| 25 | 24 | ||
| 25 | + | use crate::extractors::ValidatedHtmlForm; | |
| 26 | 26 | use super::build_step_nav; | |
| 27 | 27 | ||
| 28 | 28 | /// Ordered step names for the project wizard. | |
| @@ -105,7 +105,7 @@ pub async fn step_basics_create( | |||
| 105 | 105 | State(state): State<AppState>, | |
| 106 | 106 | session: Session, | |
| 107 | 107 | AuthUser(user): AuthUser, | |
| 108 | - | HtmlForm(form): HtmlForm<BasicsForm>, | |
| 108 | + | ValidatedHtmlForm(form): ValidatedHtmlForm<BasicsForm>, | |
| 109 | 109 | ) -> Result<Response> { | |
| 110 | 110 | user.check_not_suspended()?; | |
| 111 | 111 | if !user.can_create_projects { |
| @@ -124,7 +124,9 @@ pub(super) async fn list( | |||
| 124 | 124 | .ok_or(AppError::Unauthorized)?; | |
| 125 | 125 | ||
| 126 | 126 | let limit = req.limit.unwrap_or(100).clamp(1, 1000) as i64; | |
| 127 | - | let offset = req.offset.unwrap_or(0) as i64; | |
| 127 | + | // Clamp the offset too (UX-M2): an unbounded offset becomes a giant SQL OFFSET | |
| 128 | + | // deep-scan, the same DoS-shaped cost the limit clamp guards against. | |
| 129 | + | let offset = (req.offset.unwrap_or(0) as i64).clamp(0, 1_000_000_000); | |
| 128 | 130 | ||
| 129 | 131 | let rows = synckit_billing::list_active_keys(&state.db, app.id, limit, offset).await?; | |
| 130 | 132 |