fix(auth): cookies SameSite=Lax (PWA Android) + en-têtes anti-iframe/cross-siteUnverified
804247e parent: 254a88c modified
src/auth/extractor.rs +1 -1 | @@ -35,7 +35,7 @@ pub struct AuthRedirect; | ||
| 35 | 35 | |
| 36 | 36 | impl IntoResponse for AuthRedirect { |
| 37 | 37 | fn into_response(self) -> Response { |
| 38 | - let clear = format!("{SESSION_COOKIE}=; Path=/; Max-Age=0; HttpOnly; SameSite=Strict"); | |
| 38 | + let clear = format!("{SESSION_COOKIE}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax"); | |
| 39 | 39 | ([(SET_COOKIE, clear)], Redirect::to("/login")).into_response() |
| 40 | 40 | } |
| 41 | 41 | } |
| @@ -35,7 +35,7 @@ pub struct AuthRedirect; | |||
| 35 | 35 | ||
| 36 | impl IntoResponse for AuthRedirect { | 36 | impl IntoResponse for AuthRedirect { |
| 37 | fn into_response(self) -> Response { | 37 | fn into_response(self) -> Response { |
| 38 | - let clear = format!("{SESSION_COOKIE}=; Path=/; Max-Age=0; HttpOnly; SameSite=Strict"); | 38 | + let clear = format!("{SESSION_COOKIE}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax"); |
| 39 | ([(SET_COOKIE, clear)], Redirect::to("/login")).into_response() | 39 | ([(SET_COOKIE, clear)], Redirect::to("/login")).into_response() |
| 40 | } | 40 | } |
| 41 | } | 41 | } |
modified
src/routes/login.rs +4 -1 | @@ -61,7 +61,10 @@ pub async fn login_submit( | ||
| 61 | 61 | .path("/") |
| 62 | 62 | .http_only(true) |
| 63 | 63 | .secure(true) |
| 64 | - .same_site(SameSite::Strict) | |
| 64 | + // Lax, pas Strict : au lancement d'une PWA Android (intent du lanceur), | |
| 65 | + // Chrome retient les cookies Strict → re-login à chaque ouverture. | |
| 66 | + // Les POST restent protégés par le token CSRF. | |
| 67 | + .same_site(SameSite::Lax) | |
| 65 | 68 | .max_age(time::Duration::seconds(SESSION_SECONDS)); |
| 66 | 69 | Ok((jar.add(cookie), Redirect::to("/")).into_response()) |
| 67 | 70 | } |
| @@ -61,7 +61,10 @@ pub async fn login_submit( | |||
| 61 | .path("/") | 61 | .path("/") |
| 62 | .http_only(true) | 62 | .http_only(true) |
| 63 | .secure(true) | 63 | .secure(true) |
| 64 | - .same_site(SameSite::Strict) | 64 | + // Lax, pas Strict : au lancement d'une PWA Android (intent du lanceur), |
| 65 | + // Chrome retient les cookies Strict → re-login à chaque ouverture. | ||
| 66 | + // Les POST restent protégés par le token CSRF. | ||
| 67 | + .same_site(SameSite::Lax) | ||
| 65 | .max_age(time::Duration::seconds(SESSION_SECONDS)); | 68 | .max_age(time::Duration::seconds(SESSION_SECONDS)); |
| 66 | Ok((jar.add(cookie), Redirect::to("/")).into_response()) | 69 | Ok((jar.add(cookie), Redirect::to("/")).into_response()) |
| 67 | } | 70 | } |
modified
src/routes/mod.rs +29 -1 | @@ -6,7 +6,10 @@ mod stats; | ||
| 6 | 6 | mod teams; |
| 7 | 7 | |
| 8 | 8 | use axum::Router; |
| 9 | -use axum::response::Redirect; | |
| 9 | +use axum::extract::Request; | |
| 10 | +use axum::http::HeaderValue; | |
| 11 | +use axum::middleware::{self, Next}; | |
| 12 | +use axum::response::{Redirect, Response}; | |
| 10 | 13 | use axum::routing::{get, post}; |
| 11 | 14 | |
| 12 | 15 | use crate::auth::extractor::AuthUser; |
| @@ -53,9 +56,34 @@ pub fn router(state: AppState) -> Router { | ||
| 53 | 56 | .route("/manifest.json", get(statics::manifest)) |
| 54 | 57 | .route("/icon-512.png", get(statics::icon)) |
| 55 | 58 | .fallback(fallback_404) |
| 59 | + .layer(middleware::from_fn(security_headers)) | |
| 56 | 60 | .with_state(state) |
| 57 | 61 | } |
| 58 | 62 | |
| 63 | +/// En-têtes posés sur toutes les réponses : interdiction d'embarquer l'app | |
| 64 | +/// dans une iframe et de lire ses ressources depuis un autre site. | |
| 65 | +async fn security_headers(req: Request, next: Next) -> Response { | |
| 66 | + let mut res = next.run(req).await; | |
| 67 | + let headers = res.headers_mut(); | |
| 68 | + headers.insert("x-frame-options", HeaderValue::from_static("DENY")); | |
| 69 | + // frame-ancestors seulement : une CSP complète casserait le <script> | |
| 70 | + // inline d'add_step2 et les styles inline. | |
| 71 | + headers.insert( | |
| 72 | + "content-security-policy", | |
| 73 | + HeaderValue::from_static("frame-ancestors 'none'"), | |
| 74 | + ); | |
| 75 | + headers.insert( | |
| 76 | + "cross-origin-opener-policy", | |
| 77 | + HeaderValue::from_static("same-origin"), | |
| 78 | + ); | |
| 79 | + headers.insert( | |
| 80 | + "cross-origin-resource-policy", | |
| 81 | + HeaderValue::from_static("same-origin"), | |
| 82 | + ); | |
| 83 | + headers.insert("x-content-type-options", HeaderValue::from_static("nosniff")); | |
| 84 | + res | |
| 85 | +} | |
| 86 | + | |
| 59 | 87 | /// L'app s'ouvre sur l'écran d'ajout (comme la maquette). |
| 60 | 88 | async fn root(_auth: AuthUser) -> Redirect { |
| 61 | 89 | Redirect::to("/temps/nouveau") |
| @@ -6,7 +6,10 @@ mod stats; | |||
| 6 | mod teams; | 6 | mod teams; |
| 7 | 7 | ||
| 8 | use axum::Router; | 8 | use axum::Router; |
| 9 | -use axum::response::Redirect; | 9 | +use axum::extract::Request; |
| 10 | +use axum::http::HeaderValue; | ||
| 11 | +use axum::middleware::{self, Next}; | ||
| 12 | +use axum::response::{Redirect, Response}; | ||
| 10 | use axum::routing::{get, post}; | 13 | use axum::routing::{get, post}; |
| 11 | 14 | ||
| 12 | use crate::auth::extractor::AuthUser; | 15 | use crate::auth::extractor::AuthUser; |
| @@ -53,9 +56,34 @@ pub fn router(state: AppState) -> Router { | |||
| 53 | .route("/manifest.json", get(statics::manifest)) | 56 | .route("/manifest.json", get(statics::manifest)) |
| 54 | .route("/icon-512.png", get(statics::icon)) | 57 | .route("/icon-512.png", get(statics::icon)) |
| 55 | .fallback(fallback_404) | 58 | .fallback(fallback_404) |
| 59 | + .layer(middleware::from_fn(security_headers)) | ||
| 56 | .with_state(state) | 60 | .with_state(state) |
| 57 | } | 61 | } |
| 58 | 62 | ||
| 63 | +/// En-têtes posés sur toutes les réponses : interdiction d'embarquer l'app | ||
| 64 | +/// dans une iframe et de lire ses ressources depuis un autre site. | ||
| 65 | +async fn security_headers(req: Request, next: Next) -> Response { | ||
| 66 | + let mut res = next.run(req).await; | ||
| 67 | + let headers = res.headers_mut(); | ||
| 68 | + headers.insert("x-frame-options", HeaderValue::from_static("DENY")); | ||
| 69 | + // frame-ancestors seulement : une CSP complète casserait le <script> | ||
| 70 | + // inline d'add_step2 et les styles inline. | ||
| 71 | + headers.insert( | ||
| 72 | + "content-security-policy", | ||
| 73 | + HeaderValue::from_static("frame-ancestors 'none'"), | ||
| 74 | + ); | ||
| 75 | + headers.insert( | ||
| 76 | + "cross-origin-opener-policy", | ||
| 77 | + HeaderValue::from_static("same-origin"), | ||
| 78 | + ); | ||
| 79 | + headers.insert( | ||
| 80 | + "cross-origin-resource-policy", | ||
| 81 | + HeaderValue::from_static("same-origin"), | ||
| 82 | + ); | ||
| 83 | + headers.insert("x-content-type-options", HeaderValue::from_static("nosniff")); | ||
| 84 | + res | ||
| 85 | +} | ||
| 86 | + | ||
| 59 | /// L'app s'ouvre sur l'écran d'ajout (comme la maquette). | 87 | /// L'app s'ouvre sur l'écran d'ajout (comme la maquette). |
| 60 | async fn root(_auth: AuthUser) -> Redirect { | 88 | async fn root(_auth: AuthUser) -> Redirect { |
| 61 | Redirect::to("/temps/nouveau") | 89 | Redirect::to("/temps/nouveau") |
modified
src/team.rs +2 -1 | @@ -21,7 +21,8 @@ pub fn team_cookie(team_id: Uuid) -> Cookie<'static> { | ||
| 21 | 21 | .path("/") |
| 22 | 22 | .http_only(true) |
| 23 | 23 | .secure(true) |
| 24 | - .same_site(SameSite::Strict) | |
| 24 | + // Lax comme tt_session : envoyé au lancement d'une PWA Android. | |
| 25 | + .same_site(SameSite::Lax) | |
| 25 | 26 | .max_age(time::Duration::seconds(SESSION_SECONDS)) |
| 26 | 27 | .build() |
| 27 | 28 | } |
| @@ -21,7 +21,8 @@ pub fn team_cookie(team_id: Uuid) -> Cookie<'static> { | |||
| 21 | .path("/") | 21 | .path("/") |
| 22 | .http_only(true) | 22 | .http_only(true) |
| 23 | .secure(true) | 23 | .secure(true) |
| 24 | - .same_site(SameSite::Strict) | 24 | + // Lax comme tt_session : envoyé au lancement d'une PWA Android. |
| 25 | + .same_site(SameSite::Lax) | ||
| 25 | .max_age(time::Duration::seconds(SESSION_SECONDS)) | 26 | .max_age(time::Duration::seconds(SESSION_SECONDS)) |
| 26 | .build() | 27 | .build() |
| 27 | } | 28 | } |