RFC 0039 — SecurityHeadersConfig (CSP / COOP / CORP additifs)
- Auteur : EBO (origine audit Gitrust / patch 1.0.1)
- Date : 2026-09-18
- Statut : Implemented (2026-10-01) — phase 1 (API) et phase 2 (défaut sans
'unsafe-eval').'unsafe-inline'reste. - Apps consommatrices concernées : Gitrust (pourra retirer
SetResponseHeaderLayer::overriding) ;rustwarden-ui/rustwarden-app; tout consommateur deRustwardenApp::builder() - Crate cible :
rustwarden-core - Origine : constats M-10 + F-UI du plan
docs/plans/2026-09-17-securisation-upstream-audit-gitrust.md(Task 16). CSP Alpine :docs/plans/PLAN_CSP_HTMX.md.
Summary
Ajouter une config additive SecurityHeadersConfig (CSP configurable, COOP/CORP opt-in) branchée sur security_headers_middleware par capture Arc à la construction, sans changer la CSP par défaut du patch 1.0.1 (unsafe-inline / unsafe-eval conservés). Phase 2 (après Accept et Alpine CSP) : défaut sans 'unsafe-eval'.
Motivation
Problème concret (M-10)
security_headers_middleware (~L853–895) pose des en-têtes hardcodés, dont :
content-security-policy: default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'
'unsafe-inline' + 'unsafe-eval' sur script-src annulent l’essentiel de la protection XSS (finding H2 / M-10). Gitrust overlay déjà une politique plus stricte via tower_http::SetResponseHeaderLayer::overriding(CONTENT_SECURITY_POLICY, …) après le middleware framework.
Le middleware est monté à l’extérieur des Extension(...) :
let app = router.with_state(db).layer( ServiceBuilder::new() .layer(cors_layer) .layer(axum::middleware::from_fn(security_headers_middleware)) .layer_fn(move |service| AdminService::new(service, db_for_admin.clone())) // ... .layer(axum::Extension(i18n_service)) .layer(axum::Extension(hooks)) .layer(axum::Extension(auth_config)) .layer(axum::Extension(db_for_ext)) .into_inner(), );
Dans Tower/Axum, le premier .layer() de ServiceBuilder est le plus externe : il voit la requête avant que les Extension internes ne soient insérées. Un request.extensions().get::<Arc<SecurityHeadersConfig>>() dans security_headers_middleware est toujours None sur le chemin aller. D’où : pas Extension.
Pourquoi ne pas durcir le défaut dans 1.0.1
Retirer 'unsafe-eval' casse l’admin UI : Alpine.js standard utilise new Function() pour x-data / x-bind (PLAN_CSP_HTMX.md Phase 1). Le patch 1.0.1 n’a pas changé la CSP. Cette RFC livre d’abord l’API pour que Gitrust (et d’autres) cessent d’overrider en couche externe ; le durcissement du défaut est la Phase 2, après migration @alpinejs/csp.
Pas de COOP/CORP aujourd’hui. Gitrust / isolation Cross-Origin-Opener-Policy: same-origin doivent pouvoir s’activer sans fork du middleware.
Qui est impacté
- Gitrust : drop
SetResponseHeaderLayer::overridingune fois le builder exposé. - Apps qui gardent le défaut : aucun changement d’en-têtes (même CSP).
rustwarden-ui: Phase 2 seulement, après Alpine CSP.
Si on ne fait rien
Chaque consommateur réimplémente un overlay Tower. Le défaut reste indurcissable sans patch core. COOP/CORP restent absents du framework.
Urgence
Pas un patch 1.0.1. RFC obligatoire : nouvel item pub + builder. Le défaut CSP ne bouge pas dans le premier PR d’implémentation.
Non-goals
- Pas de changement de CSP par défaut dans le train d’implémentation initial (ni 1.0.1, ni le PR « API additive »).
'unsafe-inline'et'unsafe-eval'restent dansDefault. - Pas de nonce / hash CSP (Phases 2–4 de
PLAN_CSP_HTMX.md). - Pas de COOP/CORP on by default :
Option::None(opt-in). - Pas d’injection via
Extension(piège d’ordre des layers, ci-dessus). - Pas de toucher à
X-Content-Type-Options,X-Frame-Options,X-XSS-Protection,Referrer-Policy,Permissions-Policy, HSTS (APP_DEBUG) dans cette RFC — ils restent hardcodés comme aujourd’hui. - Pas de CORS (déjà
CorsLayerséparé).
API proposée
Nouveau fichier crates/rustwarden-core/src/config/security_headers.rs, pub mod security_headers dans config/mod.rs. Re-export optionnel prelude : SecurityHeadersConfig (recommandé, item d’usage builder).
Config
// crates/rustwarden-core/src/config/security_headers.rs use std::borrow::Cow; /// Default CSP shipped since SEC-H1 / 1.0.1. Do not tighten in the first /// implementation PR (Alpine.js still needs 'unsafe-eval'). pub const DEFAULT_CONTENT_SECURITY_POLICY: &str = "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"; /// Candidate default **after** PLAN_CSP_HTMX.md Phase 1 (Alpine CSP build). /// Not used by [`SecurityHeadersConfig::default`] until Phase 2 of this RFC. pub const CSP_WITHOUT_UNSAFE_EVAL: &str = "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"; /// Configurable security headers applied by `security_headers_middleware`. #[derive(Clone, Debug)] pub struct SecurityHeadersConfig { /// `Content-Security-Policy` value. Default keeps 1.0.1 (unsafe-inline/eval). pub content_security_policy: Cow<'static, str>, /// `Cross-Origin-Opener-Policy`. `None` = do not set the header (opt-in). pub cross_origin_opener_policy: Option<Cow<'static, str>>, /// `Cross-Origin-Resource-Policy`. `None` = do not set the header (opt-in). pub cross_origin_resource_policy: Option<Cow<'static, str>>, } impl Default for SecurityHeadersConfig { fn default() -> Self { Self { content_security_policy: Cow::Borrowed(DEFAULT_CONTENT_SECURITY_POLICY), cross_origin_opener_policy: None, cross_origin_resource_policy: None, } } } impl SecurityHeadersConfig { /// Opt-in isolation headers commonly used by Gitrust. pub fn with_same_origin_isolation(mut self) -> Self { self.cross_origin_opener_policy = Some(Cow::Borrowed("same-origin")); self.cross_origin_resource_policy = Some(Cow::Borrowed("same-origin")); self } }
Les snippets sont proposés (Draft). DEFAULT_CONTENT_SECURITY_POLICY est la chaîne exacte lue dans app.rs L881 (vérifiée 2026-09-18).
Builder
impl RustwardenBuilder { /// Override security headers (CSP / optional COOP / CORP). /// /// Default: [`SecurityHeadersConfig::default`] (current 1.0.1 CSP, /// no COOP/CORP). Call this to let Gitrust drop /// `SetResponseHeaderLayer::overriding`. pub fn security_headers(mut self, cfg: SecurityHeadersConfig) -> Self { self.security_headers = cfg; self } }
Champ nouveau sur RustwardenBuilder : security_headers: SecurityHeadersConfig initialisé à SecurityHeadersConfig::default() dans Default.
Usage consommateur :
use rustwarden_core::app::RustwardenApp; use rustwarden_core::config::security_headers::SecurityHeadersConfig; async fn build_gitrust() -> anyhow::Result<()> { let cfg = SecurityHeadersConfig::default() .with_same_origin_isolation(); // Optionally: cfg.content_security_policy = Cow::Borrowed("..."); let _app = RustwardenApp::builder() .from_env() .security_headers(cfg) .build() .await?; Ok(()) }
from_env() actuel ne lit pas de CSP env — volontaire (politique dans le code / builder, pas une string .env non revue). Open question si un CSP= est souhaité plus tard.
Câblage middleware — capture Arc, pas Extension
Même patron de clone dans la closure que locale_middleware (app.rs ~L708–712). Position inchangée (toujours le layer externe juste après CORS) :
let headers_cfg = std::sync::Arc::new(self.security_headers.clone()); .layer(axum::middleware::from_fn({ let cfg = headers_cfg.clone(); move |req, next| { let cfg = cfg.clone(); async move { security_headers_middleware(cfg, req, next).await } } }))
Signature interne (reste pub(crate) / privée au module app) :
async fn security_headers_middleware( cfg: Arc<SecurityHeadersConfig>, request: Request, next: Next, ) -> Response { let mut response = next.run(request).await; let headers = response.headers_mut(); // ... existing X-Content-Type-Options, X-Frame-Options, etc. ... let csp = HeaderValue::from_str(cfg.content_security_policy.as_ref()) .unwrap_or_else(|_| HeaderValue::from_static(DEFAULT_CONTENT_SECURITY_POLICY)); headers.insert(HeaderName::from_static("content-security-policy"), csp); if let Some(coop) = cfg.cross_origin_opener_policy.as_deref() { if let Ok(val) = HeaderValue::from_str(coop) { headers.insert( HeaderName::from_static("cross-origin-opener-policy"), val, ); } } if let Some(corp) = cfg.cross_origin_resource_policy.as_deref() { if let Ok(val) = HeaderValue::from_str(corp) { headers.insert( HeaderName::from_static("cross-origin-resource-policy"), val, ); } } // HSTS inchangé (APP_DEBUG) response }
from_str invalide → fallback CSP 1.0.1 (pas de unwrap sur le hot path). COOP/CORP mal formés : skip + tracing::warn, pas de 500.
Constitution : pas d’unwrap/expect hors tests.
Phase 2 (livrée le 2026-10-01)
Prérequis : PLAN_CSP_HTMX.md Phase 1 (@alpinejs/csp, inventaire x-data inline → Alpine.data). Fait : static/js/alpine-csp.min.js, composants dans static/js/alpine-components.js, SecurityHeadersConfig::default pointe sur CSP_WITHOUT_UNSAFE_EVAL. La constante DEFAULT_CONTENT_SECURITY_POLICY conserve la politique 1.0.1 pour un retour arrière explicite.
Alors seulement : Default pointe sur CSP_WITHOUT_UNSAFE_EVAL (retirer 'unsafe-eval', garder 'unsafe-inline' jusqu’aux phases suivantes du plan). C’est un changement de défaut : PR séparé, mention CHANGELOG Security, éventuellement minor. Hors du premier PR d’implémentation de cette RFC.
Gitrust, une fois le builder livré (Phase 1 API), pose sa CSP (déjà sans eval) via .security_headers(cfg) et retire SetResponseHeaderLayer::overriding.
Analyse de compatibilité
Checklist :
- API existante non modifiée → le middleware existait déjà (privé).
-
API existante étendue avec défaut → non-breaking (minor) :
security_headers()optionnel,Default= CSP actuelle. - API existante modifiée ou supprimée → non.
- Nouveau comportement par défaut → non en Phase 1 API. Phase 2 = oui (sans eval) — PR distinct.
- Changement de schéma DB → non.
- Changement de format sérialisé → non (en-têtes HTTP seulement).
Apps qui n’appellent pas le builder method : bit-identical headers vs 1.0.1.
Alternatives considérées
Minimum 3 alternatives avec analyse pros/cons, y compris "ne rien faire". Sans ça la RFC est incomplète : on ne peut pas juger une proposition sans son coût relatif.
Alternative A (recommandée) : config additive + capture Arc + défaut 1.0.1
Pros : Gitrust drop l’overlay ; COOP/CORP opt-in ; zéro casse UI ; évite le piège Extension (layer externe). Phase 2 documentée, pas mélangée.
Cons : la CSP par défaut reste faible jusqu’à Alpine CSP. Deux PR (API, puis défaut).
Alternative B : changer le défaut CSP maintenant (sans 'unsafe-eval')
Pros : ferme M-10 plus vite pour les apps UI vanilla.
Cons : casse l’admin (Alpine new Function()). Interdit explicitement pour 1.0.1 et pour le premier train de cette RFC. Gitrust n’a pas ce problème (overlay) mais rustwarden-ui / apps wizard si.
Alternative C (ne rien faire / garder SetResponseHeaderLayer::overriding)
Pros : zéro coût upstream. Gitrust conserve son workaround.
Cons : chaque consommateur réinvente l’override ; COOP/CORP absents du framework ; Phase 2 défaut impossible à partager. Divergence M-10.
Testing plan
Tests unitaires :
- Happy path :
SecurityHeadersConfig::default().content_security_policyégale à la constante actuelleapp.rsL881 (test de non-dérive). - Erreur 1 — CSP non-ASCII / CR-LF :
from_strfail → réponse sertDEFAULT_CONTENT_SECURITY_POLICY, pas de panic. - Erreur 2 — COOP
Some("same-origin\n")invalide → header COOP absent, CSP toujours présent. - Erreur 3 —
with_same_origin_isolation(): COOP et CORPsame-origin; sans appel, les deux absents.
Tests d’intégration (middleware / client axum fake) :
- Router de test avec le
from_fn+Arcclone :GET /→content-security-policydéfaut ; pas decross-origin-opener-policy. - Même router avec
with_same_origin_isolation(): les trois en-têtes présents. - Régression :
x-frame-options: DENY,x-content-type-options: nosnifftoujours là. - Contrôle négatif Extension : ne pas injecter
Extension(cfg)et vérifier que le middleware (Arc capturé) fonctionne quand même — documente le piège.
Tests de non-régression :
- Consommateurs qui n’appellent pas
.security_headers()compilent (Defaultdu builder). cargo testUI non requis au PR API (Phase 2 seulement).
Doctests :
- Au moins un exemple copie/collable sur
SecurityHeadersConfig::default/with_same_origin_isolation(sans I/O), exécuté comme doctest.
Benchs : non (insert headers, hors bcrypt). Pas de seuil 10 %.
Les exemples builder .build().await restent ignore (DB). Le doctest de la struct Default est exécutable.
Migration guide
Section à copier telle quelle dans le CHANGELOG.md au moment du merge.
### API changes **Before (v1.0.1)** : `security_headers_middleware` hardcodait la CSP (avec `'unsafe-inline'` et `'unsafe-eval'`). Pas de COOP/CORP. Gitrust overlay `SetResponseHeaderLayer::overriding`. **After (vX.Y.Z)** : Nouveau `rustwarden_core::config::security_headers::SecurityHeadersConfig`. `RustwardenApp::builder().security_headers(cfg)`. Défaut **identique** à 1.0.1. COOP/CORP opt-in (`None`). Le middleware capte `Arc<…>` à la construction (pas `Extension` : le layer est plus externe que les `Extension` dans `app.rs`). Phase 2 (PR ultérieur, après Alpine `@alpinejs/csp`, cf. `docs/plans/PLAN_CSP_HTMX.md`) : `Default` sans `'unsafe-eval'`. ```rust use rustwarden_core::config::security_headers::SecurityHeadersConfig; RustwardenApp::builder() .from_env() .security_headers(SecurityHeadersConfig::default().with_same_origin_isolation()) .build() .await?;
Migration steps
- Bump
rustwarden-coreà la version qui livre RFC 0039 (Phase 1 API). - Gitrust : passer la CSP actuelle au builder ; retirer
SetResponseHeaderLayer::overriding(CONTENT_SECURITY_POLICY, …). - Apps UI vanilla : ne rien faire (défaut inchangé).
- Ne pas retirer
'unsafe-eval'du défaut tant quePLAN_CSP_HTMX.mdPhase 1 n'est pas faite.
## Open questions Liste des points en suspens. La RFC ne peut pas être acceptée tant qu’ils ne sont pas résolus : - [x] **Re-export prelude** : `SecurityHeadersConfig` dans `prelude` ou seulement `config::security_headers` ? **Résolu (2026-09-18) : oui — `SecurityHeadersConfig` re-exporté dans le prelude (item d'usage builder).** - [x] **Env `CSP=`** : hors v1 (recommandé) vs lu par `from_env()` ? **Résolu (2026-09-18) : hors v1 — la politique vit dans le code/builder, pas dans une string `.env` non revue.** - [x] **Phase 2 dans cette RFC** (acceptée comme intention) vs RFC 00xx dédiée au changement de défaut ? Recommandé : rester ici comme phase datée, PR séparé. **Résolu (2026-09-18) : reste dans cette RFC comme phase datée ; PR séparé après PLAN_CSP_HTMX.md Phase 1 — hors du train d'implémentation actuel.** - [x] **Tracking GitHub** : ouvrir l’issue avant `Under Review`. **Résolu (2026-09-18) : suivi interne via docs/plans/2026-09-17-securisation-upstream-audit-gitrust.md.** ## Tracking issue À ouvrir sur le dépôt Rustwarden (labels `area:http` + `security` + `rfc`) en référençant M-10, `PLAN_CSP_HTMX.md` et Gitrust. Pas d’URL tant que le Draft n’est pas soumis. --- ## Score d'évaluation (rempli par les reviewers) Notes **proposées par l’auteur** (Draft) ; les reviewers confirment ou ajustent. Seuil Accepted : ≥ 25/35. | Critère | Note (1-5) | Commentaire | |---|---|---| | Généralité | 5 | Tous les builders ; Gitrust retire un overlay ; COOP/CORP partagés. | | Stabilité API | 5 | Ajout pur + `Default` bit-identical 1.0.1 ; pas de breaking Phase 1. | | Performance | 5 | Clone `Arc` + `HeaderValue::from_str` une fois par réponse ; négligeable. | | Sécurité | 4 | API de durcissement ; le défaut reste faible jusqu’à Phase 2 (volontaire). | | Complexité d'implémentation | 4 | Un struct + champ builder + closure clone ; piège Extension documenté. | | Charge de maintenance | 4 | Une constante CSP à ne pas faire dériver (`DEFAULT_*` + test d’égalité). | | Documentation | 4 | RFC + rustdoc + PLAN_CSP_HTMX ; CHANGELOG Phase 1 vs 2. | | **Total** | **31/35** | (≥ 25 pour Accepted) |
GitRust