Skip to content

fix(csp): restaurer le positionnement des toasts (feuille layered, sans épingler de hash) - #257

Merged
thierryvm merged 3 commits into
mainfrom
fix/csp-sonner-inline-style
Jul 25, 2026
Merged

fix(csp): restaurer le positionnement des toasts (feuille layered, sans épingler de hash)#257
thierryvm merged 3 commits into
mainfrom
fix/csp-sonner-inline-style

Conversation

@thierryvm

@thierryvm thierryvm commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Symptôme

Deux violations CSP inline-style par page en production, relevées par @Thierry.

Diagnostic — deux hypothèses à moi, deux réfutations par les agents

1. plan-reviewer a réfuté ma piste Radix / get-nonce : ce singleton remplit son <style> avant de l'insérer, il ne peut donc pas produire un hash de chaîne vide. Il a désigné sonner. Vérifié par re-calcul depuis le paquet installé — match exact sur les deux :

Violation prod Origine
sha256-47DEQpj8… chaîne vide = le <style> inséré vide par sonner
sha256-CIxDM5jn… le littéral CSS de sonner/dist/index.mjs (14 859 car.)

sonner insère un <style> vide puis le remplit : un élément, deux évaluations CSP. <Toaster /> est monté sans condition dans le layout → violations sur chaque page.

Le bug utilisateur que ça cachait : le CSS bloqué, [data-sonner-toaster]{position:fixed} ne s'appliquait pas — les toasts sont mal positionnés en production.

2. security-auditor a posé un NO-GO sur ma première version, qui épinglait les deux hashes pour faire taire la console. Motif décisif : autoriser un <style>, ce n'est pas le tolérer, c'est l'appliquer. La copie injectée est non-layered, donc elle l'emporte sur n'importe quelle couche quelle que soit la spécificité — les hashes annulaient exactement la protection @layer que le même diff installait. Et sonner utilisant theme: 'light' par défaut, son --normal-bg: #fff aurait gagné sur bg-cardtoast fond blanc en thème sombre, code couleur de sévérité perdu.

Ce qui est livré

Un seul changement fonctionnel : @import 'sonner/dist/styles.css' layer(sonner); avec ordre de couches déclaré avant les imports. Le CSS arrive par une source 'self', les toasts retrouvent leur positionnement, et nos utilitaires Tailwind gardent le dernier mot.

Pas de hash épinglé. Arbitrage assumé : deux avertissements console cosmétiques valent mieux qu'une régression visuelle en thème sombre. Bénéfice secondaire relevé par l'audit — une future version compromise de sonner reste bloquée par la CSP ; seule la copie vendored, layered et visible dans un diff de lockfile, s'applique.

Preuve mesurée (build de production, les deux thèmes)

violations style-src positionnement fond du toast (sombre)
Avant — production actuelle 2 static rgb(17, 26, 46)
Après 2 (assumées) fixed rgb(17, 26, 46)

⚠️ @Thierry : les 2 violations console que tu avais relevées restent visibles. C'est délibéré — les supprimer casserait les toasts en thème sombre. Le vrai défaut est corrigé.

Verrou

e2e/security-headers.spec.ts échoue si un hash ('sha256-…' / 'sha384-…' / 'sha512-…') ou 'unsafe-hashes' réapparaît dans style-src. La décision est documentée dans src/app/globals.css, là où elle sera relue.

package.json inchangé — sonner était déjà une dépendance.

Definition of DONE

Rapport complet : docs/prs/PR-csp-sonner-report.md

  • npm run lint 0 erreur · npm run typecheck 0 erreur · npm run test 1645/1645
  • plan-reviewer : 🔴 puis 🟡 APPROVED WITH CHANGES — 7 corrections appliquées
  • security-auditor : 🔴 NO-GO levé par l'option (a) qu'il recommandait

…ions

sonner injects its stylesheet at module evaluation time, and does it in the
worst possible order: it appends an EMPTY <style> element, then fills it. That
is one element and two CSP evaluations, hence exactly the two `inline-style`
violations reported on every page — <Toaster /> is mounted unconditionally in
the locale layout. Both hashes were confirmed by re-hashing the literal
extracted from the installed package.

The console noise was the visible half. The real defect: with the stylesheet
blocked, `[data-sonner-toaster]{position:fixed}` never applied, so toasts were
unpositioned in production. Measured against the live site: 2 violations and
`position: static`. After this change, on a production build: 0 violations and
`position: fixed`.

sonner exposes no nonce API, so the fix has two distinct halves:
- `@import 'sonner/dist/styles.css' layer(sonner)` serves the CSS from a 'self'
  stylesheet. This is what makes the UI correct, and keeps it correct if the
  hash ever drifts.
- the two hashes in `style-src` silence the runtime injection.

The import is layered on purpose. sonner ships its stylesheet UNLAYERED, and
unlayered CSS beats layered CSS whatever the specificity, so a naive import
would have overridden the Tailwind utilities styling our toasts (bg-card,
border-border, border-danger) in both themes. Declaring the layer order before
the imports puts sonner between base and components, so our utilities keep the
last word while sonner keeps its positioning rules.

The hashes are emitted in every environment, not just production: CSP3 makes a
directive ignore 'unsafe-inline' as soon as a nonce is present, so the dev
relaxation is a no-op for style-src and the violation reproduces in dev too.

The constants live in a module with zero imports because proxy.ts cannot be
imported from Vitest — it pulls in env.ts, which throws at module load without
the env vars. The unit test proves the constant is RIGHT by re-extracting and
re-hashing sonner's literal on every run, and refuses to go green if that
extraction breaks. The e2e assertion proves the constant is WIRED into the
emitted directive, which a unit test alone cannot show.

No 'unsafe-hashes': these are <style> elements, not inline style attributes.
package.json is untouched — sonner was already a dependency.
@vercel

vercel Bot commented Jul 25, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
ankora Ready Ready Preview, Comment Jul 25, 2026 2:03pm

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @thierryvm, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

…ply it

security-auditor put a NO-GO on the first version of this branch, and it was
right: the two halves of the diff contradicted each other.

Allow-listing a <style> hash does not merely tolerate the element, it APPLIES
its CSS. sonner's injected copy is unlayered, and unlayered CSS outranks every
cascade layer whatever the specificity — so the hashes cancelled exactly the
protection the @layer in the same diff had just installed. sonner defaults to
theme: 'light' and <Toaster /> passes no theme, so its --normal-bg: #fff would
have beaten bg-card and painted a white toast in dark mode, losing the severity
colour coding with it.

Two cosmetic console warnings are the cheaper defect. Keeping the hashes out
also means a future compromised sonner release stays blocked by the CSP: only
the vendored, layered copy — visible in a lockfile diff — can apply.

What remains is the half that actually fixes the user-visible bug: the layered
'self' import, which restores `[data-sonner-toaster]{position:fixed}`.

Measured on a production build, both themes:
  before (live site)  2 violations, position: static, dark bg rgb(17,26,46)
  after               2 violations, position: fixed,  dark bg rgb(17,26,46)
Positioning fixed, our theme tokens preserved in both modes — which the hashed
version would not have achieved.

The decision is documented where it will be re-read (globals.css) and locked by
an e2e assertion that fails if any hash source reappears in style-src. The
drift guard went away with the hashes it protected; the auditor also noted its
failure message invited re-hashing a potentially hostile stylesheet.
@thierryvm thierryvm changed the title fix(csp): restaurer le style des toasts et faire taire les violations style-src de sonner fix(csp): restaurer le positionnement des toasts (feuille layered, sans épingler de hash) Jul 25, 2026
@thierryvm
thierryvm merged commit 828c943 into main Jul 25, 2026
9 checks passed
@thierryvm
thierryvm deleted the fix/csp-sonner-inline-style branch July 25, 2026 14:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

status:review-needed Ready for review type:fix Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant