0039-security-headers-config.md 18327 octets

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 de RustwardenApp::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::overriding une 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 dans Default.
  • 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à CorsLayer sé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 actuelle app.rs L881 (test de non-dérive).
  • Erreur 1 — CSP non-ASCII / CR-LF : from_str fail → réponse sert DEFAULT_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 CORP same-origin ; sans appel, les deux absents.

Tests d’intégration (middleware / client axum fake) :

  • Router de test avec le from_fn + Arc clone : GET / → content-security-policy défaut ; pas de cross-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: nosniff toujours 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 (Default du builder).
  • cargo test UI 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

  1. Bump rustwarden-core à la version qui livre RFC 0039 (Phase 1 API).
  2. Gitrust : passer la CSP actuelle au builder ; retirer SetResponseHeaderLayer::overriding(CONTENT_SECURITY_POLICY, …).
  3. Apps UI vanilla : ne rien faire (défaut inchangé).
  4. Ne pas retirer 'unsafe-eval' du défaut tant que PLAN_CSP_HTMX.md Phase 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) |