@nodefony/security 10.0.0-beta.5 → 10.0.0-beta.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -0
- package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
- package/dist/index.js +63 -61
- package/dist/nodefony/command/security-mcp-token.js +43 -5
- package/dist/nodefony/config/config.js +30 -11
- package/dist/nodefony/config/defineModuleConfig.js +52 -1
- package/dist/nodefony/contracts/IWebhookPayload.js +1 -0
- package/dist/nodefony/errors/FetchPublicError.js +18 -0
- package/dist/nodefony/errors/SsrfError.js +9 -1
- package/dist/nodefony/errors/StaleSessionError.js +20 -0
- package/dist/nodefony/errors/index.js +7 -5
- package/dist/nodefony/keycloak/keycloakAdmin.js +3 -3
- package/dist/nodefony/keycloak/scaffold.js +31 -14
- package/dist/nodefony/keycloak/security-keycloak-realm.js +1 -1
- package/dist/nodefony/service/accessTokenVerifier.js +6 -6
- package/dist/nodefony/service/apiKeys.js +8 -4
- package/dist/nodefony/service/auditService.js +48 -3
- package/dist/nodefony/service/authFlow.js +9 -6
- package/dist/nodefony/service/authorization.js +1 -1
- package/dist/nodefony/service/firewall.js +281 -44
- package/dist/nodefony/service/keycloak.js +1 -1
- package/dist/nodefony/service/oauth2.js +8 -4
- package/dist/nodefony/service/securityHeaders.js +340 -47
- package/dist/nodefony/service/tokenService.js +16 -11
- package/dist/nodefony/service/totp.js +10 -5
- package/dist/nodefony/service/webAuthn.js +46 -7
- package/dist/nodefony/service/webhooks.js +11 -4
- package/dist/nodefony/src/admin/SecurityAdminApi.js +19 -0
- package/dist/nodefony/src/audit/AuditRefusalAggregator.js +139 -0
- package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +1 -1
- package/dist/nodefony/src/authenticator/ExternalSubjectResolver.js +31 -7
- package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +62 -20
- package/dist/nodefony/src/authenticator/JwtAuthenticator.js +29 -6
- package/dist/nodefony/src/authenticator/SessionAuthenticator.js +22 -9
- package/dist/nodefony/src/authenticator/authenticatorRegistry.js +2 -2
- package/dist/nodefony/src/csp.js +265 -10
- package/dist/nodefony/src/cspReports.js +307 -0
- package/dist/nodefony/src/net/fetchPublic.js +244 -0
- package/dist/nodefony/src/net/ssrfGuard.js +2 -2
- package/dist/nodefony/src/oauth/clientCredentials.js +1 -1
- package/dist/nodefony/src/oauth/metadata.js +1 -1
- package/dist/nodefony/src/oauth/oauthProviderRegistry.js +17 -4
- package/dist/nodefony/src/oauth/providers/github.js +1 -1
- package/dist/nodefony/src/realtime/UserRealtimeToken.js +59 -2
- package/dist/nodefony/src/realtime/frameAuthorizer.js +136 -44
- package/dist/nodefony/src/token/RemoteJwtVerifier.js +8 -2
- package/dist/nodefony/src/token/jwtRuntime.js +22 -2
- package/dist/nodefony/src/token/tokenTimeClaims.js +34 -0
- package/dist/nodefony/src/totp/totpOperations.js +1 -1
- package/dist/nodefony/src/voter/RoleVoter.js +1 -1
- package/dist/nodefony/src/webhook/WebhookDispatcher.js +71 -17
- package/dist/nodefony/src/webhook/webhookDelivery.js +37 -86
- package/dist/nodefony/testing/index.js +26 -36
- package/dist/types/index.d.ts +5 -1
- package/dist/types/nodefony/command/security-mcp-token.d.ts +12 -0
- package/dist/types/nodefony/config/config.d.ts +8 -0
- package/dist/types/nodefony/contracts/IApiKey.d.ts +8 -2
- package/dist/types/nodefony/contracts/IFirewall.d.ts +15 -2
- package/dist/types/nodefony/contracts/ITokenStore.d.ts +7 -3
- package/dist/types/nodefony/contracts/IWebhookEndpoint.d.ts +13 -3
- package/dist/types/nodefony/contracts/IWebhookPayload.d.ts +24 -0
- package/dist/types/nodefony/errors/FetchPublicError.d.ts +15 -0
- package/dist/types/nodefony/errors/SsrfError.d.ts +8 -1
- package/dist/types/nodefony/errors/StaleSessionError.d.ts +17 -0
- package/dist/types/nodefony/errors/index.d.ts +3 -0
- package/dist/types/nodefony/keycloak/scaffold.d.ts +12 -6
- package/dist/types/nodefony/service/apiKeys.d.ts +3 -0
- package/dist/types/nodefony/service/authFlow.d.ts +2 -2
- package/dist/types/nodefony/service/firewall.d.ts +28 -7
- package/dist/types/nodefony/service/securityHeaders.d.ts +82 -11
- package/dist/types/nodefony/service/webAuthn.d.ts +13 -0
- package/dist/types/nodefony/service/webhooks.d.ts +7 -1
- package/dist/types/nodefony/src/admin/SecurityAdminApi.d.ts +3 -0
- package/dist/types/nodefony/src/audit/AuditRefusalAggregator.d.ts +55 -0
- package/dist/types/nodefony/src/authenticator/ExternalSubjectResolver.d.ts +11 -0
- package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +17 -3
- package/dist/types/nodefony/src/authenticator/SessionAuthenticator.d.ts +11 -4
- package/dist/types/nodefony/src/csp.d.ts +142 -0
- package/dist/types/nodefony/src/cspReports.d.ts +73 -0
- package/dist/types/nodefony/src/net/fetchPublic.d.ts +73 -0
- package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +15 -0
- package/dist/types/nodefony/src/realtime/UserRealtimeToken.d.ts +35 -1
- package/dist/types/nodefony/src/realtime/frameAuthorizer.d.ts +10 -2
- package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +42 -18
- package/dist/types/nodefony/src/token/jwtRuntime.d.ts +21 -2
- package/dist/types/nodefony/src/token/tokenTimeClaims.d.ts +32 -0
- package/dist/types/nodefony/src/webhook/WebhookDispatcher.d.ts +36 -1
- package/dist/types/nodefony/src/webhook/webhookDelivery.d.ts +5 -2
- package/dist/types/nodefony/testing/index.d.ts +6 -0
- package/docs/api-keys.md +3 -1
- package/docs/audit.md +112 -28
- package/docs/authenticators.md +6 -4
- package/docs/csrf.md +3 -2
- package/docs/external-jwt.md +5 -1
- package/docs/firewall.md +3 -2
- package/docs/headers.md +427 -71
- package/docs/keycloak.md +4 -4
- package/docs/login-page.md +4 -3
- package/docs/obtenir-un-jeton.md +17 -10
- package/docs/tokens.md +1 -1
- package/docs/totp.md +2 -2
- package/docs/webauthn.md +2 -2
- package/docs/webhooks.md +63 -18
- package/package.json +17 -17
package/docs/headers.md
CHANGED
|
@@ -51,18 +51,16 @@ contrôleur, absent sur un fichier statique ou une page d'erreur 404, c'est-à-d
|
|
|
51
51
|
un attaquant dépose son contenu.
|
|
52
52
|
|
|
53
53
|
D'où le découpage : ce qui doit couvrir **tout ce qui sort du process** est posé au plus tôt ; ce
|
|
54
|
-
qui dépend de la **réponse applicative** (le CSP, qui doit connaître le nonce
|
|
55
|
-
|
|
54
|
+
qui dépend de la **réponse applicative** (le CSP, qui doit connaître le nonce, la route et le
|
|
55
|
+
**type** de la réponse) est posé au moment où la réponse écrit ses en-têtes : le pare-feu, armé au
|
|
56
|
+
routage, choisit alors sa politique sur le `Content-Type`.
|
|
56
57
|
|
|
57
58
|
```mermaid
|
|
58
59
|
flowchart TD
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
A --> CSPQ{"le CSP porte-t-il<br/>un nonce ?"}
|
|
64
|
-
CSPQ -->|non| STAT["CSP figé au boot — 0 allocation par requête"]
|
|
65
|
-
CSPQ -->|oui| NONCE["cspFor(nonce) — 1 join par requête<br/>nonce généré paresseusement sur le Context"]
|
|
60
|
+
T["transport<br/>(toute réponse)"] --> W{"writeHead :<br/>quel type ?"}
|
|
61
|
+
W -->|document| DOC["CSP complète + nonce<br/>tous les en-têtes"]
|
|
62
|
+
W -->|JavaScript| JS["CSP sans nonce<br/>Referrer · COEP · CORP"]
|
|
63
|
+
W -->|autre, 204| OTH["default-src 'none'<br/>Referrer · CORP"]
|
|
66
64
|
```
|
|
67
65
|
|
|
68
66
|
**Une seule source par en-tête** : `@nodefony/security` ne ré-émet **jamais** les trois en-têtes
|
|
@@ -72,20 +70,23 @@ contradictoires sur la même réponse.
|
|
|
72
70
|
|
|
73
71
|
## 📖 Lexique
|
|
74
72
|
|
|
75
|
-
| Terme
|
|
76
|
-
|
|
|
77
|
-
| CSP
|
|
78
|
-
| XSS
|
|
79
|
-
| Nonce
|
|
80
|
-
| Clickjacking
|
|
81
|
-
| MIME sniffing
|
|
82
|
-
| HSTS
|
|
83
|
-
| Referrer
|
|
84
|
-
| COOP
|
|
85
|
-
|
|
|
86
|
-
|
|
|
87
|
-
|
|
|
88
|
-
|
|
|
73
|
+
| Terme | Développé — et ce que ça veut dire |
|
|
74
|
+
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
75
|
+
| CSP | _Content-Security-Policy_ : liste blanche des sources autorisées (scripts, styles, images…). Première barrière anti-XSS. |
|
|
76
|
+
| XSS | _Cross-Site Scripting_ : un attaquant fait exécuter **son** JavaScript dans la page de ta victime, avec ses cookies. |
|
|
77
|
+
| Nonce | _number used once_ : jeton aléatoire régénéré à **chaque requête**, qui autorise un `<script>` inline **précis** et lui seul. |
|
|
78
|
+
| Clickjacking | Ton site est chargé en `<iframe>` transparente au-dessus d'un piège : la victime croit cliquer ailleurs, elle clique chez toi. |
|
|
79
|
+
| MIME sniffing | Le navigateur ignore le `Content-Type` et **devine** le type d'un fichier — un `.txt` uploadé peut finir exécuté comme script. |
|
|
80
|
+
| HSTS | _HTTP Strict-Transport-Security_ (RFC 6797) : le navigateur mémorise « ce domaine, c'est HTTPS uniquement ». |
|
|
81
|
+
| Referrer | En-tête que le navigateur envoie au site suivant pour dire d'où l'on vient — donc une **fuite d'URL** potentielle. |
|
|
82
|
+
| COOP | _Cross-Origin-Opener-Policy_ : coupe le lien entre ta **page** et les fenêtres d'une autre origine qu'elle ouvre ou qui l'ont ouverte (`window.opener`). Lu sur un document seulement. |
|
|
83
|
+
| COEP | _Cross-Origin-Embedder-Policy_ : ta **page** (ou ton worker) refuse toute ressource tierce qui n'a pas dit « tu peux m'inclure ». Lu sur un document ou un script de worker. |
|
|
84
|
+
| CORP | _Cross-Origin-Resource-Policy_ : posé sur une **ressource** (image, script, JSON), dit quelles origines ont le droit de l'inclure. C'est la réponse que COEP attend. |
|
|
85
|
+
| Isolation cross-origin | COOP `same-origin` + COEP `require-corp` : la page est seule dans son processus (`crossOriginIsolated`), condition de `SharedArrayBuffer` et des horloges fines. |
|
|
86
|
+
| OAC | _Origin-Agent-Cluster_ : demande au navigateur d'isoler l'origine dans son propre processus/heap. |
|
|
87
|
+
| Permissions-Policy | Coupe l'accès aux API sensibles du navigateur (caméra, micro, géolocalisation) pour la page **et ses iframes**. |
|
|
88
|
+
| Fragment CSP | Directives additionnelles déclarées par un module (`directive → sources`), fusionnées dans le CSP de base. |
|
|
89
|
+
| Downgrade | Un attaquant réseau force la connexion en HTTP clair pour la lire ou la modifier. |
|
|
89
90
|
|
|
90
91
|
## Qu'est-ce que c'est ? — un panneau d'instructions collé sur chaque réponse
|
|
91
92
|
|
|
@@ -156,8 +157,9 @@ export default defineConfig(() => ({
|
|
|
156
157
|
headers: {
|
|
157
158
|
// `{{nonce}}` est substitué par un jeton FRAIS à chaque requête (cf plus bas).
|
|
158
159
|
csp:
|
|
159
|
-
"default-src 'self'; script-src '
|
|
160
|
-
"style-src 'self' 'unsafe-inline';
|
|
160
|
+
"default-src 'self'; script-src 'nonce-{{nonce}}' 'strict-dynamic' 'self'; " +
|
|
161
|
+
"style-src 'self' 'unsafe-inline' 'nonce-{{nonce}}'; style-src-attr 'unsafe-inline'; " +
|
|
162
|
+
"img-src 'self' data:; " +
|
|
161
163
|
"object-src 'none'; base-uri 'self'; form-action 'self'",
|
|
162
164
|
cspNonces: true,
|
|
163
165
|
// Ne fuiter que l'origine, et rien vers un site en clair.
|
|
@@ -179,23 +181,32 @@ d'enum (`referrerPolicy`, `coop`, `corp`…). Une valeur hors enum casse le boot
|
|
|
179
181
|
### 2. Ce qu'on observe
|
|
180
182
|
|
|
181
183
|
```bash
|
|
182
|
-
#
|
|
183
|
-
curl -sI http://localhost:5151/nodefony/test/
|
|
184
|
+
# Une PAGE : socle transport + couche applicative complète, sur la MÊME réponse.
|
|
185
|
+
curl -sI http://localhost:5151/nodefony/test/html
|
|
184
186
|
|
|
185
187
|
# HTTP/1.1 200 OK
|
|
186
188
|
# X-Content-Type-Options: nosniff ← transport (@nodefony/http)
|
|
187
189
|
# X-Frame-Options: DENY ← transport (@nodefony/http)
|
|
188
|
-
# Content-
|
|
190
|
+
# Content-Type: text/html; charset=utf-8
|
|
191
|
+
# Content-Security-Policy: default-src 'self'; script-src 'nonce-vZ9…' 'strict-dynamic' 'self'; …
|
|
189
192
|
# Referrer-Policy: strict-origin-when-cross-origin
|
|
190
193
|
# Cross-Origin-Opener-Policy: same-origin
|
|
191
194
|
# Cross-Origin-Resource-Policy: same-origin
|
|
192
195
|
# Permissions-Policy: camera=(), microphone=(), geolocation=()
|
|
196
|
+
|
|
197
|
+
# Une réponse d'API : la politique courte, et seulement ce qu'un JSON fait lire.
|
|
198
|
+
curl -sI http://localhost:5151/nodefony/test/index
|
|
199
|
+
|
|
200
|
+
# Content-Type: application/json
|
|
201
|
+
# Content-Security-Policy: default-src 'none'; frame-ancestors 'none'
|
|
202
|
+
# Referrer-Policy: strict-origin-when-cross-origin
|
|
203
|
+
# Cross-Origin-Resource-Policy: same-origin
|
|
193
204
|
```
|
|
194
205
|
|
|
195
206
|
```bash
|
|
196
207
|
# Le nonce change à CHAQUE requête — deux appels, deux jetons.
|
|
197
|
-
curl -sI http://localhost:5151/nodefony/test/
|
|
198
|
-
curl -sI http://localhost:5151/nodefony/test/
|
|
208
|
+
curl -sI http://localhost:5151/nodefony/test/html | grep -o "nonce-[^']*"
|
|
209
|
+
curl -sI http://localhost:5151/nodefony/test/html | grep -o "nonce-[^']*"
|
|
199
210
|
# nonce-2Qk1r0h8… (≠)
|
|
200
211
|
# nonce-Xa7pLd3f…
|
|
201
212
|
|
|
@@ -255,19 +266,222 @@ Choisir en cinq secondes — puis le détail dans les cartes.
|
|
|
255
266
|
**La menace** : n'importe quelle entrée non échappée (commentaire, nom d'utilisateur, paramètre
|
|
256
267
|
réfléchi) devient un vecteur d'exécution de code. Le CSP est le filet quand l'échappement a raté.
|
|
257
268
|
|
|
258
|
-
**Le défaut Nodefony** est délibérément « secure-but-usable » (`config.ts#headersSchema`) :
|
|
259
|
-
est **strict** — `'
|
|
260
|
-
|
|
261
|
-
`data:`/`blob:`, workers, fetch/WS same-origin) et ajoute les durcissements
|
|
262
|
-
|
|
269
|
+
**Le défaut Nodefony** est délibérément « secure-but-usable » (`config.ts#headersSchema`) : `script-src`
|
|
270
|
+
est **strict** — `'nonce-{{nonce}}' 'strict-dynamic' 'self'`, ce qui est la vraie défense XSS ;
|
|
271
|
+
`style-src` exige le même nonce des `<style>` (voir plus bas). Le reste couvre les besoins réels
|
|
272
|
+
d'une app moderne (images `data:`/`blob:`, workers, fetch/WS same-origin) et ajoute les durcissements
|
|
273
|
+
gratuits :
|
|
274
|
+
`object-src 'none'`, `base-uri 'self'`, `form-action 'self'`, `upgrade-insecure-requests`.
|
|
275
|
+
|
|
276
|
+
**Aucun hôte tiers dans le défaut.** `img-src` vaut `'self' data: blob:` : les photos de profil
|
|
277
|
+
d'un fournisseur de connexion ne sont autorisées que s'il est **configuré** — chaque fournisseur
|
|
278
|
+
porte l'hôte de ses avatars dans son enregistrement (`oauthProviderRegistry.ts#registerOAuthProvider`,
|
|
279
|
+
option `imageSources`), et la connexion sociale le déclare au démarrage. Studio autorise
|
|
280
|
+
`https://www.gravatar.com` sur **ses seules pages** (`@Csp` de `StudioController.ts#StudioController`).
|
|
281
|
+
Une application qui affiche d'autres images tierces les ajoute par `headers.cspSources` (ci-dessous),
|
|
282
|
+
sans réécrire la politique.
|
|
283
|
+
|
|
284
|
+
**`'strict-dynamic'` : seul le nonce autorise une balise.** Un navigateur CSP3 ignore alors
|
|
285
|
+
`'self'` et les hôtes (`'self'` ne reste que pour les navigateurs CSP2). Sans lui, tout fichier
|
|
286
|
+
JavaScript posé sur ton origine — un téléversement, une réponse mal typée — s'exécutait dès qu'une
|
|
287
|
+
injection écrivait `<script src="/uploads/x.js">`. En retour, un `<script>` sans nonce — en ligne
|
|
288
|
+
ou `src`, même servi par ton origine — ne s'exécute plus. Dans une vue rendue par `renderView`, le
|
|
289
|
+
framework le signe pour toi (ci-dessous) ; dans du HTML écrit à la main, écris
|
|
290
|
+
`<script nonce="${this.context?.cspNonce}" src="…">`. Ce que ce script charge ensuite (un
|
|
291
|
+
`import()`, un script qu'il crée) hérite de sa confiance : un chargeur tiers signé (paiement,
|
|
292
|
+
carte) charge ses propres scripts sans que tu déclares leurs hôtes.
|
|
293
|
+
|
|
294
|
+
Deux cas où il n'y a pas de nonce, et où `'strict-dynamic'` part avec lui pour ne pas bloquer
|
|
295
|
+
toute la page :
|
|
296
|
+
|
|
297
|
+
- **une page servie telle quelle** — un `.html` de `public/`, une réponse relayée par le proxy
|
|
298
|
+
inverse : aucun gabarit ne l'a écrite. Elle reçoit la politique sans nonce ni `'strict-dynamic'`,
|
|
299
|
+
et `'self'` décide, comme avant. C'est sans perte : un fichier sert les mêmes octets à tous, il
|
|
300
|
+
n'a pas de point d'injection côté serveur ;
|
|
301
|
+
- **`cspNonces: false`** : le nonce est retiré de la politique, `'strict-dynamic'` aussi — sauf si
|
|
302
|
+
la directive garde un hash (`'sha256-…'`), qui continue d'autoriser ses scripts.
|
|
303
|
+
|
|
304
|
+
Une politique que tu écris toi-même sans `{{nonce}}` n'est jamais retouchée.
|
|
305
|
+
|
|
306
|
+
**Styles : le même nonce, et rien à faire dans une application générée.** Le défaut écrit
|
|
307
|
+
`style-src 'self' 'unsafe-inline' 'nonce-{{nonce}}'; style-src-attr 'unsafe-inline'`. En présence
|
|
308
|
+
d'un nonce, le navigateur ignore `'unsafe-inline'` : un `<style>` injecté dans ta page — qui peut
|
|
309
|
+
exfiltrer un jeton par des sélecteurs d'attribut, sans une ligne de JavaScript — est bloqué. Un
|
|
310
|
+
attribut `style="…"` reste permis : il ne porte pas de sélecteur. Ce qui est déjà en place :
|
|
311
|
+
|
|
312
|
+
- les feuilles en `<link rel="stylesheet">` de ton origine passent par `'self'` ; les styles que
|
|
313
|
+
React ou Vue écrivent par le CSSOM (`element.style`) ne sont pas gouvernés par la politique ;
|
|
314
|
+
- chaque document rendu par le framework annonce son nonce par
|
|
315
|
+
`<meta property="csp-nonce" nonce="…">` (`cspNonce.ts#cspNonceMetaTag`). Le client de Vite
|
|
316
|
+
y signe les styles du rechargement à chaud ; l'entrée Angular générée le donne à `CSP_NONCE` ;
|
|
317
|
+
- un `<style>` que **ton** code crée à l'exécution : `adoptStyles(css)` (`nodefony/client`) le pose
|
|
318
|
+
en feuille construite, que la politique ne gouverne pas ; une bibliothèque qui veut un nonce le
|
|
319
|
+
lit par `readCspNonce()` (Mantine : `getStyleNonce`, Emotion : `createCache({ nonce })`) ;
|
|
320
|
+
- dans une vue Eta rendue par `renderView` : **rien à faire** — le framework signe chaque
|
|
321
|
+
`<script>` et `<style>` écrit dans le gabarit (`viewNonce.ts#signTemplateTags`), jamais ce
|
|
322
|
+
que les données y insèrent ; le nonce reste lisible ailleurs par `<%= it.cspNonce %>`. Un
|
|
323
|
+
`<script>` dont un attribut vient des données (`src="<%= it.url %>"`) n'est pas signé : le
|
|
324
|
+
signer exécuterait l'URL qu'elles apportent. Si tu la tiens pour sûre, signe-le toi-même
|
|
325
|
+
(`<script nonce="<%= it.cspNonce %>" src="<%= it.url %>">`).
|
|
326
|
+
|
|
327
|
+
**Une bibliothèque CSS-in-JS**, dans cet ordre de préférence :
|
|
328
|
+
|
|
329
|
+
1. **« zéro runtime »** (vanilla-extract, Linaria, Panda CSS…) : elle écrit des feuilles au build,
|
|
330
|
+
servies en `<link>` par `'self'` — rien à signer, rien à régler ;
|
|
331
|
+
2. **qui accepte un nonce** : donne-lui `readCspNonce()` (`nodefony/client`) — Emotion :
|
|
332
|
+
`createCache({ key: "css", nonce: readCspNonce() })`, Mantine : `getStyleNonce`, goober :
|
|
333
|
+
`window.__nonce__ = readCspNonce()` avant son premier appel. Cherche ce crochet avant de
|
|
334
|
+
conclure qu'il n'existe pas : il porte souvent un nom de variable globale ;
|
|
335
|
+
3. **sans option de nonce** : elle crée un `<style>` conteneur que la politique refuse, et ses
|
|
336
|
+
styles ne s'appliquent pas, même si elle y écrit ensuite par le CSSOM. Signe toi-même les
|
|
337
|
+
`<style>` que les scripts créent, AVANT d'importer la bibliothèque (premier import de ton
|
|
338
|
+
entrée) :
|
|
339
|
+
|
|
340
|
+
```ts
|
|
341
|
+
import { readCspNonce } from "nodefony/client";
|
|
342
|
+
|
|
343
|
+
const nonce = readCspNonce();
|
|
344
|
+
if (nonce) {
|
|
345
|
+
// Une bibliothèque qui réécrit `nonce` avec une valeur vide (`undefined`) écraserait la
|
|
346
|
+
// signature sans un mot : l'affectation d'une valeur vide rend le nonce de la page.
|
|
347
|
+
const base = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "nonce");
|
|
348
|
+
if (base?.get && base.set) {
|
|
349
|
+
const { get, set } = base;
|
|
350
|
+
Object.defineProperty(HTMLStyleElement.prototype, "nonce", {
|
|
351
|
+
configurable: true,
|
|
352
|
+
get() {
|
|
353
|
+
return get.call(this);
|
|
354
|
+
},
|
|
355
|
+
set(value: unknown) {
|
|
356
|
+
const usable =
|
|
357
|
+
typeof value === "string" && value !== "" && value !== "undefined";
|
|
358
|
+
set.call(this, usable ? value : nonce);
|
|
359
|
+
},
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
Document.prototype.createElement = new Proxy(
|
|
363
|
+
Document.prototype.createElement,
|
|
364
|
+
{
|
|
365
|
+
apply(target, thisArg, args) {
|
|
366
|
+
const element: unknown = Reflect.apply(target, thisArg, args);
|
|
367
|
+
if (element instanceof HTMLStyleElement) element.nonce = nonce;
|
|
368
|
+
return element;
|
|
369
|
+
},
|
|
370
|
+
},
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
Le geste ne signe que ce qu'un script CRÉE : un `<style>` glissé dans la page par une injection
|
|
376
|
+
HTML (`innerHTML`, gabarit) reste refusé. Il modifie deux méthodes natives pour toute la page —
|
|
377
|
+
c'est ce qui le réserve à ce cas. Éprouvé sous Chrome, Firefox et WebKit, écriture par nœud
|
|
378
|
+
texte comme par `insertRule`.
|
|
379
|
+
|
|
380
|
+
Dernier recours, et global : retirer le nonce des seuls styles en réécrivant la politique — la
|
|
381
|
+
défense des scripts reste entière, seule celle contre l'exfiltration par CSS injecté tombe.
|
|
382
|
+
|
|
383
|
+
```ts
|
|
384
|
+
use("@nodefony/security", {
|
|
385
|
+
headers: {
|
|
386
|
+
// Le défaut, sauf `style-src` : plus de nonce pour les styles, scripts inchangés.
|
|
387
|
+
csp:
|
|
388
|
+
"default-src 'self'; script-src 'nonce-{{nonce}}' 'strict-dynamic' 'self'; " +
|
|
389
|
+
"style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; " +
|
|
390
|
+
"connect-src 'self'; worker-src 'self' blob:; object-src 'none'; base-uri 'self'; " +
|
|
391
|
+
"form-action 'self'; upgrade-insecure-requests",
|
|
392
|
+
},
|
|
393
|
+
});
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
Un `@Csp` de route ne peut pas le faire à sa place : il AJOUTE des sources, et un navigateur
|
|
397
|
+
ignore `'unsafe-inline'` tant qu'un nonce figure dans la directive (CSP3 § « Does a source list
|
|
398
|
+
allow all inline behavior »).
|
|
399
|
+
|
|
400
|
+
Là où le nonce est retiré (page servie telle quelle, `cspNonces: false`), `'unsafe-inline'`
|
|
401
|
+
reprend la main : la page garde ses styles.
|
|
402
|
+
|
|
403
|
+
**Derrière un proxy inverse (nginx, haproxy, CDN) : la politique à nonce vient de
|
|
404
|
+
l'application, jamais du proxy.** Le nonce change à chaque réponse et n'existe que dans
|
|
405
|
+
l'application, qui l'écrit à la fois dans l'en-tête et dans la page. Un proxy relaie l'en-tête
|
|
406
|
+
tel quel — éprouvé derrière de vrais nginx et haproxy (`reverse-proxy.test.ts`) : une seule
|
|
407
|
+
politique, le même nonce que la page, `upgrade-insecure-requests` posé seulement quand le client
|
|
408
|
+
arrive en `https`. Trois configurations de proxy cassent ce contrat :
|
|
409
|
+
|
|
410
|
+
- **le proxy AJOUTE sa propre CSP** (`add_header Content-Security-Policy …`,
|
|
411
|
+
`http-response add-header …`) : le navigateur applique alors les DEUX politiques, et une
|
|
412
|
+
ressource doit passer chacune (CSP3 § « The effect of multiple policies »). Celle du proxy n'a
|
|
413
|
+
pas le nonce : tes scripts et tes styles signés sont bloqués. N'y mets rien qui touche aux
|
|
414
|
+
scripts ou aux styles — ou rien du tout ;
|
|
415
|
+
- **le proxy REMPLACE la CSP** (`proxy_hide_header` puis `add_header`) : le nonce disparaît de la
|
|
416
|
+
politique, il ne protège plus rien. Si l'exploitation tient à écrire la politique au proxy,
|
|
417
|
+
coupe celle de l'application (`csp: ""`) : c'est alors une politique sans nonce, en
|
|
418
|
+
connaissance de cause ;
|
|
419
|
+
- **le proxy ou le CDN MET EN CACHE une page signée** : il sert le même nonce à tous les
|
|
420
|
+
visiteurs, et un nonce connu d'avance n'autorise plus rien de façon sûre. Nodefony envoie
|
|
421
|
+
`Cache-Control: private, no-cache` sur toute page à nonce (sauf si ton contrôleur en écrit un
|
|
422
|
+
autre) : un cache qui respecte la norme ne la garde pas. Une règle qui l'ignore
|
|
423
|
+
(`proxy_ignore_headers Cache-Control`, « tout mettre en cache » d'un CDN) la remet en cache :
|
|
424
|
+
n'en pose jamais sur une page à nonce. Les fichiers JS, CSS et images gardent leur cache long.
|
|
425
|
+
|
|
426
|
+
#### Poser la CSP au proxy (nginx, ingress)
|
|
427
|
+
|
|
428
|
+
Tu préfères écrire la politique dans nginx ou l'ingress ? C'est permis, en un réglage :
|
|
429
|
+
l'application se retire au lieu de doubler la politique.
|
|
430
|
+
|
|
431
|
+
```ts
|
|
432
|
+
// nodefony/config/security.ts
|
|
433
|
+
export default (ctx) =>
|
|
434
|
+
({
|
|
435
|
+
headers: { csp: "" }, // l'application n'envoie plus de CSP : le proxy décide seul
|
|
436
|
+
}) satisfies ISecurityConfigInput;
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
- **Au proxy, une politique sans nonce** : il ne connaît pas celui que l'application écrit dans la
|
|
440
|
+
page. Point de départ sûr : la politique par défaut (`npx nodefony inspect config`,
|
|
441
|
+
clé `headers.csp` de `@nodefony/security`) privée de ses `'nonce-{{nonce}}'` et de
|
|
442
|
+
`'strict-dynamic'` — celle que Nodefony pose déjà sur une page servie telle quelle.
|
|
443
|
+
- **Ce que tu perds** : la politique ne juge plus que l'origine. Un script injecté qui pointe vers
|
|
444
|
+
un fichier de ton propre domaine (fichier téléversé, JSONP) passe, là où le nonce le bloquait.
|
|
445
|
+
- **Ce qui ne change pas** : `Referrer-Policy`, COOP et les autres en-têtes restent posés par
|
|
446
|
+
l'application. Aucun `Cache-Control` n'est imposé : sans nonce, la page redevient cachable.
|
|
447
|
+
- **Rejoue tes pages derrière le proxy avant de conclure** : un script écrit en ligne dans la page,
|
|
448
|
+
sans nonce, y est bloqué.
|
|
449
|
+
- **Jamais les deux à la fois** : CSP de l'application ET CSP du proxy, le navigateur applique les
|
|
450
|
+
deux (première puce ci-dessus).
|
|
451
|
+
|
|
452
|
+
**`upgrade-insecure-requests` ne part que sur une page servie en `https`.** Le navigateur réécrit
|
|
453
|
+
alors en `https` chaque sous-ressource écrite en `http://` (image, script, socket) au lieu de la
|
|
454
|
+
charger en clair. Sur une page servie en `http` — le développement sur `http://localhost` — la
|
|
455
|
+
même réécriture viserait un `https` que le serveur ne sert pas, et la page perdrait ses
|
|
456
|
+
ressources : la directive y est donc retirée. Les deux variantes de la politique sont composées au
|
|
457
|
+
démarrage (`securityHeaders.ts#SecurityHeaders`) ; par réponse, Nodefony lit seulement le schéma
|
|
458
|
+
de la requête, qui tient compte du proxy de confiance. Derrière un proxy qui termine le TLS,
|
|
459
|
+
régler `trustProxy` du module http, sinon la page est vue en clair et la directive ne part pas.
|
|
460
|
+
Pour la couper partout, la retirer de `csp` ; pour l'avoir dans une politique écrite à la main,
|
|
461
|
+
l'y ajouter.
|
|
462
|
+
|
|
463
|
+
`base-uri` reste à `'self'` plutôt qu'à `'none'` : `'self'` empêche déjà une balise `<base>`
|
|
464
|
+
injectée de détourner les scripts vers un hôte tiers, et `'none'` casserait les applications qui
|
|
465
|
+
posent `<base href="/">` ou vivent sous un sous-chemin.
|
|
263
466
|
|
|
264
467
|
**Pourquoi ce compromis** : un CSP qui casse l'application est désactivé par le premier développeur
|
|
265
468
|
pressé. Un CSP strict là où ça compte (`script-src`) et permissif là où ça ne coûte rien (styles,
|
|
266
469
|
images) survit en production — c'est celui-là qui protège vraiment.
|
|
267
470
|
|
|
268
471
|
Le CSP couvre aussi le clickjacking, via `frame-ancestors`, plus finement que `X-Frame-Options`
|
|
269
|
-
(liste d'origines plutôt que tout-ou-rien). Les deux cohabitent :
|
|
270
|
-
|
|
472
|
+
(liste d'origines plutôt que tout-ou-rien). Les deux cohabitent : un navigateur CSP ignore
|
|
473
|
+
`X-Frame-Options` dès que `frame-ancestors` est présent, l'ancien en-tête reste le filet des
|
|
474
|
+
autres. **Un seul réglage pilote les deux** — `frameOptions` du module http (section suivante) :
|
|
475
|
+
security en dérive `frame-ancestors` (`csp.ts#withFrameAncestors`), sauf si ta politique déclare
|
|
476
|
+
déjà la directive. C'est ainsi qu'on autorise un portail tiers à t'encadrer :
|
|
477
|
+
|
|
478
|
+
```ts
|
|
479
|
+
use("@nodefony/security", {
|
|
480
|
+
headers: {
|
|
481
|
+
csp: "default-src 'self'; script-src 'nonce-{{nonce}}' 'self'; frame-ancestors https://portail.example",
|
|
482
|
+
},
|
|
483
|
+
});
|
|
484
|
+
```
|
|
271
485
|
|
|
272
486
|
### `X-Frame-Options` — non, tu ne m'encadres pas
|
|
273
487
|
|
|
@@ -280,6 +494,17 @@ Posé par le **transport** depuis un cache calculé au boot — `secFrameOptions
|
|
|
280
494
|
s'auto-encadre. C'est un des trois en-têtes que security **ne ré-émet pas** : il doit valoir aussi
|
|
281
495
|
pour un HTML statique servi directement depuis `public/`.
|
|
282
496
|
|
|
497
|
+
Le même réglage fixe `frame-ancestors` dans la politique de contenu :
|
|
498
|
+
|
|
499
|
+
| `frameOptions` | `X-Frame-Options` | `frame-ancestors` |
|
|
500
|
+
| --------------- | ----------------- | ----------------- |
|
|
501
|
+
| `DENY` (défaut) | `DENY` | `'none'` |
|
|
502
|
+
| `SAMEORIGIN` | `SAMEORIGIN` | `'self'` |
|
|
503
|
+
| `null` | absent | absent |
|
|
504
|
+
|
|
505
|
+
Une édition à chaud de `frameOptions` prévient le pare-feu
|
|
506
|
+
(`firewall.ts#Firewall.onTransportHeadersChanged`) : les deux en-têtes ne divergent jamais.
|
|
507
|
+
|
|
283
508
|
### `X-Content-Type-Options` — arrête de deviner
|
|
284
509
|
|
|
285
510
|
**La menace** : le MIME sniffing. Un fichier téléversé, servi avec un `Content-Type` imprécis, est
|
|
@@ -332,6 +557,15 @@ _tabnabbing_ et pour les canaux auxiliaires type Spectre.
|
|
|
332
557
|
conditions de l'**isolation cross-origin**, indispensable si tu veux `SharedArrayBuffer` ou des
|
|
333
558
|
timers haute résolution.
|
|
334
559
|
|
|
560
|
+
**Lu sur** : un document (page HTML) ouvert dans une fenêtre. Sur un JSON, une image, un script,
|
|
561
|
+
le navigateur l'ignore — aucune fenêtre n'y est ouverte.
|
|
562
|
+
|
|
563
|
+
**Exemple** : ta page de connexion ouvre la fenêtre d'un fournisseur OAuth. Sans COOP, ce site
|
|
564
|
+
garde `window.opener` et peut, s'il est compromis, rediriger ton onglet vers une copie de ta page
|
|
565
|
+
(_tabnabbing_). Avec `same-origin`, la référence vaut `null`. ⚠️ Un flux de connexion par fenêtre
|
|
566
|
+
surgissante qui renvoie son résultat par `window.opener.postMessage` cesse alors de fonctionner :
|
|
567
|
+
`same-origin-allow-popups` garde le lien vers les fenêtres que TA page ouvre.
|
|
568
|
+
|
|
335
569
|
### `Cross-Origin-Embedder-Policy` — je n'embarque que du consenti
|
|
336
570
|
|
|
337
571
|
**La menace** : ta page embarque des ressources tierces qui n'ont jamais donné leur accord, et les
|
|
@@ -340,6 +574,14 @@ place dans ton processus.
|
|
|
340
574
|
`require-corp` (`securityHeaders.ts#SecurityHeaders.constructor`) exige que **chaque** ressource tierce s'annonce comme
|
|
341
575
|
partageable (CORP ou CORS). C'est le complément de COOP pour l'isolation complète.
|
|
342
576
|
|
|
577
|
+
**Lu sur** : un document, et le script d'un worker (un worker charge lui aussi des ressources).
|
|
578
|
+
Sur une image ou un JSON, il ne contrôle rien.
|
|
579
|
+
|
|
580
|
+
**Exemple** : ta page affiche `<img src="https://cdn.tiers.example/logo.png">`. Avec
|
|
581
|
+
`require-corp`, l'image ne s'affiche que si le CDN répond `Cross-Origin-Resource-Policy:
|
|
582
|
+
cross-origin` (ou sert l'image en CORS). `credentialless` est le compromis : la ressource tierce
|
|
583
|
+
est chargée SANS cookies, et n'a plus besoin de consentir.
|
|
584
|
+
|
|
343
585
|
> [!WARNING]
|
|
344
586
|
> `coep: "require-corp"` **casse toutes les ressources tierces non conformes** — polices Google,
|
|
345
587
|
> images de CDN, iframes de paiement. C'est pour cette raison qu'il est absent des défauts, et
|
|
@@ -354,6 +596,14 @@ mesurer, les mettre en cache, ou monter une attaque par inclusion.
|
|
|
354
596
|
`same-origin` (`securityHeaders.ts#SecurityHeaders.constructor`) interdit toute inclusion externe ; `same-site` autorise tes
|
|
355
597
|
propres sous-domaines ; `cross-origin` ouvre — c'est ce qu'il faut sur une CDN publique assumée.
|
|
356
598
|
|
|
599
|
+
**Lu sur** : toute ressource incluse sans CORS — image, script, média, JSON appelé par une balise.
|
|
600
|
+
C'est l'en-tête des **ressources**, là où COOP et COEP sont ceux des **pages**.
|
|
601
|
+
|
|
602
|
+
**Exemple** : `https://evil.example` écrit `<img src="https://ton-app/api/me">`. Le navigateur
|
|
603
|
+
y joint les cookies de sa victime, et la réponse — son profil — atterrit dans le processus de la
|
|
604
|
+
page hostile, où une attaque de type Spectre peut la lire. Avec `same-origin`, le navigateur
|
|
605
|
+
bloque la réponse AVANT qu'elle n'entre dans ce processus.
|
|
606
|
+
|
|
357
607
|
### `Origin-Agent-Cluster` — un bac à sable par origine
|
|
358
608
|
|
|
359
609
|
**La menace** : plusieurs origines partageant heap et processus, donc des canaux auxiliaires
|
|
@@ -400,21 +650,21 @@ Dérivé de `securityHeadersSchema` (`http/nodefony/config/config.ts#securityHea
|
|
|
400
650
|
**éditables à chaud** (`runtimeMutable`) : `HttpKernel.onConfigChanged()` (`http-kernel.ts#HttpKernel.onConfigChanged`)
|
|
401
651
|
recalcule les caches, donc la valeur suivante s'applique sans redémarrage.
|
|
402
652
|
|
|
403
|
-
| Option | Type | Défaut | Effet
|
|
404
|
-
| ------------------------------------------- | ----------------- | ---------- |
|
|
405
|
-
| `contentTypeOptions` | chaîne ou `null` | `nosniff` | `X-Content-Type-Options` ; `null` = ne pas émettre.
|
|
406
|
-
| `frameOptions` | chaîne ou `null` | `DENY` | `X-Frame-Options` ; `SAMEORIGIN` si auto-encadrement.
|
|
407
|
-
| `strictTransportSecurity` | objet ou `null` | activé | `null` = pas de HSTS du tout.
|
|
408
|
-
| `strictTransportSecurity.maxAge` | entier (secondes) | `31536000` | Durée mémorisée par le navigateur (un an, recommandé OWASP).
|
|
409
|
-
| `strictTransportSecurity.includeSubDomains` | booléen | `true` | Étend la contrainte à tous les sous-domaines.
|
|
410
|
-
| `strictTransportSecurity.preload` | booléen | `false` | Inscription à la liste pré-chargée — **irréversible en pratique**.
|
|
653
|
+
| Option | Type | Défaut | Effet |
|
|
654
|
+
| ------------------------------------------- | ----------------- | ---------- | -------------------------------------------------------------------------- |
|
|
655
|
+
| `contentTypeOptions` | chaîne ou `null` | `nosniff` | `X-Content-Type-Options` ; `null` = ne pas émettre. |
|
|
656
|
+
| `frameOptions` | chaîne ou `null` | `DENY` | `X-Frame-Options` ET `frame-ancestors` ; `SAMEORIGIN` si auto-encadrement. |
|
|
657
|
+
| `strictTransportSecurity` | objet ou `null` | activé | `null` = pas de HSTS du tout. |
|
|
658
|
+
| `strictTransportSecurity.maxAge` | entier (secondes) | `31536000` | Durée mémorisée par le navigateur (un an, recommandé OWASP). |
|
|
659
|
+
| `strictTransportSecurity.includeSubDomains` | booléen | `true` | Étend la contrainte à tous les sous-domaines. |
|
|
660
|
+
| `strictTransportSecurity.preload` | booléen | `false` | Inscription à la liste pré-chargée — **irréversible en pratique**. |
|
|
411
661
|
|
|
412
662
|
> [!WARNING]
|
|
413
663
|
> Les clés `hsts`, `hstsMaxAgeS`, `frameguard` et `noSniff` **existent** dans la config security
|
|
414
664
|
> (`config.ts#headersSchema`) mais **ne pilotent rien** : la couche
|
|
415
|
-
> applicative ne les lit pas (`securityHeaders.ts#ISecurityHeadersOptions`)
|
|
416
|
-
>
|
|
417
|
-
> **du module http**. Même remarque pour `hidePoweredBy` (`config.ts#headersSchema`) : Nodefony n'émet aucun
|
|
665
|
+
> applicative ne les lit pas (`securityHeaders.ts#ISecurityHeadersOptions`). `frameguard` est
|
|
666
|
+
> **dépréciée** (retrait à la prochaine version majeure). Pour changer réellement `X-Frame-Options`
|
|
667
|
+
> et `frame-ancestors`, c'est `securityHeaders.frameOptions` **du module http**. Même remarque pour `hidePoweredBy` (`config.ts#headersSchema`) : Nodefony n'émet aucun
|
|
418
668
|
> `X-Powered-By`, l'option est un no-op documenté.
|
|
419
669
|
|
|
420
670
|
## 🏗️ Le CSP en détail — deux régimes, trois façons de l'étendre
|
|
@@ -428,7 +678,8 @@ quelle dans la table gelée du boot (`securityHeaders.ts#SecurityHeaders.constru
|
|
|
428
678
|
Détail de robustesse : si tu désactives `cspNonces` en laissant le placeholder dans la chaîne, le
|
|
429
679
|
token résiduel `'nonce-{{nonce}}'` est **purgé** (`securityHeaders.ts#SecurityHeaders.constructor`). Sans ça, tu servirais un
|
|
430
680
|
CSP contenant un nonce littéral jamais émis — donc un `script-src` qui bloque **tout**, y compris tes
|
|
431
|
-
propres scripts.
|
|
681
|
+
propres scripts. `'strict-dynamic'` part avec lui quand la directive n'a plus ni nonce ni hash : seul,
|
|
682
|
+
il ferait ignorer `'self'` et bloquerait aussi tout. Le code refuse de produire un CSP cassé.
|
|
432
683
|
|
|
433
684
|
### Régime 2 — nonce par requête (la vraie défense anti-XSS)
|
|
434
685
|
|
|
@@ -456,15 +707,74 @@ le repli statique et le `writeHead` (`http-kernel.ts#HttpKernel.runHttpPipeline`
|
|
|
456
707
|
faut que le routeur ait posé les directives `@Csp` de la route pour pouvoir les fusionner, et il faut
|
|
457
708
|
être avant l'écriture des en-têtes pour pouvoir en poser.
|
|
458
709
|
|
|
459
|
-
###
|
|
710
|
+
### Quelle politique sur quelle réponse — le type décide
|
|
711
|
+
|
|
712
|
+
Une politique de contenu ne protège que ce que le navigateur **rend** : une page, un SVG, le
|
|
713
|
+
script d'un worker. Sur un JSON ou une image, elle ne gouverne rien — sauf l'encadrement. Le
|
|
714
|
+
pare-feu choisit donc la politique au moment où la réponse écrit ses en-têtes, d'après son
|
|
715
|
+
`Content-Type` (`securityHeaders.ts#responseKindOf`, ADR-0024 §C11) :
|
|
716
|
+
|
|
717
|
+
| La réponse est… | Content-Security-Policy | Autres en-têtes applicatifs |
|
|
718
|
+
| -------------------------------------------------------------------- | ----------------------------------------------------------------------- | ----------------------------- |
|
|
719
|
+
| un document : HTML, ou tout type XML (`text/xml`, `*+xml`, dont SVG) | complète, avec nonce | tous |
|
|
720
|
+
| un document servi tel quel (fichier de `public/`, relais) | complète, sans nonce ni `'strict-dynamic'` (styles : `'unsafe-inline'`) | tous |
|
|
721
|
+
| du JavaScript (`text/javascript`…) | complète, **sans** nonce (celle qu'un worker prend) | `Referrer-Policy`, COEP, CORP |
|
|
722
|
+
| autre chose : JSON, CSS, image, police, média, 204 | `default-src 'none'`, plus le `frame-ancestors` de la complète | `Referrer-Policy`, CORP |
|
|
723
|
+
| un `304 Not Modified` du pipeline (contrôleur) | **aucune** | aucun |
|
|
724
|
+
|
|
725
|
+
Pourquoi chaque ligne :
|
|
726
|
+
|
|
727
|
+
- **Le nonce n'est tiré que pour un document.** Une réponse d'API ne paie plus le générateur
|
|
728
|
+
aléatoire, et sa politique passe de ~360 octets à ~40.
|
|
729
|
+
- **L'encadrement suit la politique complète** : avec `http.securityHeaders.frameOptions:
|
|
730
|
+
"SAMEORIGIN"`, un PDF ou une image que ta page encadre le reste.
|
|
731
|
+
- **Une page XML est un document** : le navigateur la rend. Sous la politique courte, un formulaire
|
|
732
|
+
injecté n'y relèverait d'aucun `form-action` — `default-src` ne couvre ni `form-action` ni
|
|
733
|
+
`base-uri`.
|
|
734
|
+
- **Un `304` du pipeline ne porte rien** : ses en-têtes REMPLACENT ceux de la page gardée en cache
|
|
735
|
+
(RFC 9111 §4.3.4). Une politique courte y réduirait la page, un nonce neuf la désaccorderait de
|
|
736
|
+
ses balises. Un fichier statique en `304` porte en revanche la politique de son `200` : le serveur
|
|
737
|
+
statique écrit ses en-têtes avant de savoir la copie fraîche. C'est sans effet : c'est
|
|
738
|
+
exactement la politique de son `200`, qui ne porte pas de nonce.
|
|
739
|
+
- **Une réponse sans contenu** (204, corps vide) prend la courte, même si un `Content-Type` y a été
|
|
740
|
+
posé : rien n'y sera rendu.
|
|
741
|
+
- **Un fichier statique** est rangé d'après son extension. Sans extension connue — l'`index.html`
|
|
742
|
+
d'un dossier, ou toute adresse qui finit par `/` —, il est traité en document : jamais une
|
|
743
|
+
politique plus faible par défaut. Un document servi ainsi reçoit la politique sans nonce (voir
|
|
744
|
+
`'strict-dynamic'` plus haut).
|
|
745
|
+
|
|
746
|
+
Ce que l'application écrit n'est **jamais réduit** :
|
|
747
|
+
|
|
748
|
+
- une route `@Csp` sert SA politique quel que soit son type (un `frame-ancestors` posé sur un PDF) ;
|
|
749
|
+
- un en-tête posé à la main par le contrôleur (`this.response.setHeader(…)`) gagne sur celui du
|
|
750
|
+
pare-feu ;
|
|
751
|
+
- `headers.csp: ""` coupe toute politique, courte comprise.
|
|
752
|
+
|
|
753
|
+
### Étendre le CSP — quatre portées, une seule mécanique
|
|
460
754
|
|
|
461
755
|
| Portée | Outil | Pour quoi | Recalculé |
|
|
462
756
|
| ------------------ | ------------------------------- | ----------------------------------------------- | ------------------- |
|
|
463
757
|
| Application | `headers.csp` en config | ta politique de base | au boot |
|
|
758
|
+
| Application | `headers.cspSources` en config | **ajouter** des sources sans réécrire la base | au boot |
|
|
464
759
|
| Module | `Firewall.registerCspOrigins()` | besoin **permanent** d'un module (ex. Vite) | à l'enregistrement |
|
|
465
760
|
| Route / contrôleur | `@Csp({ … })` | besoin **ponctuel** d'une réponse (iframe, CDN) | par requête décorée |
|
|
466
761
|
|
|
467
|
-
|
|
762
|
+
**`headers.cspSources` — ajouter sans réécrire.** Recopier toute la politique pour y ajouter un
|
|
763
|
+
hôte la figerait : elle ne suivrait plus le défaut. On déclare plutôt des sources **par contribution
|
|
764
|
+
nommée** :
|
|
765
|
+
|
|
766
|
+
```ts
|
|
767
|
+
use("@nodefony/security", {
|
|
768
|
+
headers: { cspSources: { cdn: { "img-src": ["https://cdn.example.com"] } } },
|
|
769
|
+
});
|
|
770
|
+
```
|
|
771
|
+
|
|
772
|
+
Indexée par NOM, et non par directive, parce que la fusion de configuration remplace un tableau case
|
|
773
|
+
par case : un module qui complète la section par `module-security` sous un autre nom s'ajoute,
|
|
774
|
+
il n'écrase pas. Une contribution malformée est ignorée et journalisée, l'application démarre ; une
|
|
775
|
+
source ouverte à tout hôte est gardée (c'est ton choix) et signalée. Sans effet quand `csp` vaut `""`.
|
|
776
|
+
|
|
777
|
+
Les quatre convergent vers `mergeCspFragments()` (`csp.ts#mergeCspFragments`), et c'est un **merge structuré**, pas
|
|
468
778
|
une concaténation. Pourquoi c'est vital : en CSP, une directive **répétée est ignorée** après sa
|
|
469
779
|
première occurrence (W3C CSP Level 3 §3, rationnel documenté `csp.ts#CspFragment`). Concaténer
|
|
470
780
|
`"script-src 'self'"` et `"script-src https://cdn"` produirait un en-tête où la seconde est purement
|
|
@@ -480,6 +790,46 @@ les tests fiables.
|
|
|
480
790
|
`@Csp` est payé **uniquement sur les routes décorées** (`SecurityHeaders.cspForExtra()`,
|
|
481
791
|
`securityHeaders.ts#SecurityHeaders.cspForExtra`) ; le cas courant reste le simple `join`.
|
|
482
792
|
|
|
793
|
+
### Rapports de violation — voir ce que la politique bloque
|
|
794
|
+
|
|
795
|
+
Une politique stricte casse sans bruit : le navigateur bloque une image ou un script, et le serveur
|
|
796
|
+
n'en sait rien. Par défaut, la politique **demande donc ses rapports** — `report-uri` (lu par tous
|
|
797
|
+
les navigateurs) et `report-to nodefony-csp` (Reporting API, honorée seulement vers une origine
|
|
798
|
+
sûre : https ou localhost), avec l'en-tête `Reporting-Endpoints` qui nomme le groupe :
|
|
799
|
+
|
|
800
|
+
```http
|
|
801
|
+
Content-Security-Policy: …; report-uri /nodefony/security/api/csp-report; report-to nodefony-csp
|
|
802
|
+
Reporting-Endpoints: nodefony-csp="/nodefony/security/api/csp-report"
|
|
803
|
+
```
|
|
804
|
+
|
|
805
|
+
Le navigateur poste ses rapports sur `POST /nodefony/security/api/csp-report`
|
|
806
|
+
(`CspReportController.ts#CspReportController`). Cette porte est publique — le navigateur n'envoie
|
|
807
|
+
aucun identifiant — donc tout ce qu'elle reçoit est traité comme écrit par un attaquant
|
|
808
|
+
(`cspReports.ts#CspReportCollector`) :
|
|
809
|
+
|
|
810
|
+
- corps lu en flux et **borné à 8 Ko** (`CSP_REPORT_MAX_BYTES`), 413 au-delà ;
|
|
811
|
+
- deux types de contenu seulement, `application/csp-report` et `application/reports+json` ;
|
|
812
|
+
- **quota par adresse** (120 rapports par minute) ;
|
|
813
|
+
- URL **expurgées** de leur requête, de leur fragment et de leurs identifiants — une requête peut
|
|
814
|
+
porter un jeton ; une URL qui n'est pas HTTP(S) se réduit à son schéma ;
|
|
815
|
+
- l'**extrait de code n'est jamais gardé** : c'est une chaîne de l'attaquant ;
|
|
816
|
+
- stockage **en mémoire seule**, dédoublonné (une répétition incrémente un compteur), 200 violations
|
|
817
|
+
distinctes au plus ; le journal dit chaque violation neuve une fois, 20 lignes par minute au plus.
|
|
818
|
+
|
|
819
|
+
La réponse est toujours 204 : l'émetteur n'apprend rien de ce qui a été gardé. L'administrateur lit
|
|
820
|
+
les violations sur `GET /nodefony/security/api/csp-reports` (rôle `ROLE_NODEFONY_ADMIN`), la plus
|
|
821
|
+
récente d'abord — champs à afficher comme du **texte**.
|
|
822
|
+
|
|
823
|
+
| `headers.cspReport` | Effet |
|
|
824
|
+
| --------------------- | ------------------------------------------------------------------------------- |
|
|
825
|
+
| `true` (défaut) | rapports vers la route du framework, collectés et lisibles par l'administrateur |
|
|
826
|
+
| `"https://…"`, `"/…"` | rapports vers **ton** collecteur ; aucune route de réception côté application |
|
|
827
|
+
| `false` | aucun rapport demandé |
|
|
828
|
+
|
|
829
|
+
Une politique que tu écris avec son propre `report-uri` ou `report-to` est servie telle quelle. Une
|
|
830
|
+
URL qu'un en-tête ne peut pas porter est retirée et journalisée en `ERROR` — l'application démarre
|
|
831
|
+
sans rapports plutôt que de ne pas démarrer.
|
|
832
|
+
|
|
483
833
|
## 🧩 Extension — déclarer un fragment CSP depuis son module
|
|
484
834
|
|
|
485
835
|
Un module qui a besoin d'origines supplémentaires ne doit **jamais** poser l'en-tête lui-même : il en
|
|
@@ -487,19 +837,23 @@ Un module qui a besoin d'origines supplémentaires ne doit **jamais** poser l'en
|
|
|
487
837
|
inefficace, au pire il casse la page. Il **déclare** son besoin, security fusionne.
|
|
488
838
|
|
|
489
839
|
```typescript
|
|
490
|
-
// Dans le service d'un module — le firewall est résolu PAR NOM
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
| undefined;
|
|
840
|
+
// Dans le service d'un module — le firewall est résolu PAR NOM et typé par le
|
|
841
|
+
// contrat de @nodefony/http (aucun import de security).
|
|
842
|
+
import type { IFirewallGate } from "@nodefony/http";
|
|
494
843
|
|
|
495
|
-
firewall?.registerCspOrigins?.("mon-module", {
|
|
844
|
+
this.get<IFirewallGate>("firewall")?.registerCspOrigins?.("mon-module", {
|
|
496
845
|
"connect-src": ["https://api.partenaire.tld"],
|
|
497
846
|
"img-src": ["https://cdn.partenaire.tld"],
|
|
498
847
|
});
|
|
499
848
|
```
|
|
500
849
|
|
|
501
|
-
|
|
850
|
+
Quatre propriétés à retenir :
|
|
502
851
|
|
|
852
|
+
- **À tout moment** : une contribution reçue avant que le firewall ait composé sa politique
|
|
853
|
+
(`onBoot`) est gardée, puis fusionnée à la composition (`firewall.ts#Firewall.registerCspOrigins`) — l'ordre
|
|
854
|
+
des modules ne décide de rien. Seul le service `firewall` doit déjà exister : un module qui
|
|
855
|
+
déclare au démarrage le fait donc à `onReady`, comme `@nodefony/realtime` pour une socket servie
|
|
856
|
+
sur une autre origine (`realtime.publicOrigin`, `RealtimeService.ts#RealtimeService.init`).
|
|
503
857
|
- **Aucun couplage** : la résolution par nom de service évite un cycle de dépendances, et
|
|
504
858
|
`registerCspOrigins` est optionnel — un module fonctionne dans une app **sans** security.
|
|
505
859
|
- **Réversible** : `Firewall.unregisterCspOrigins()` (`firewall.ts#Firewall.unregisterCspOrigins`) retire le fragment et
|
|
@@ -557,23 +911,25 @@ L'onglet **Configuration** de Studio rend les mêmes options depuis le schéma Z
|
|
|
557
911
|
porte sa description, ce qui en fait la référence toujours à jour des défauts.
|
|
558
912
|
|
|
559
913
|
> [!NOTE]
|
|
560
|
-
> La ligne « frameguard » de cet écran reflète la
|
|
561
|
-
>
|
|
562
|
-
> `curl -I` tranche en une seconde.
|
|
914
|
+
> La ligne « frameguard » de cet écran reflète la valeur réellement ÉMISE par le transport, lue
|
|
915
|
+
> chez le noyau HTTP (`firewall.ts#Firewall.describe`), pas la clé inerte de la config security.
|
|
916
|
+
> La réponse HTTP elle-même reste la preuve : `curl -I` tranche en une seconde.
|
|
563
917
|
|
|
564
918
|
## ⚠️ Pièges (symptôme → cause → correction)
|
|
565
919
|
|
|
566
|
-
| Symptôme | Cause dans le code
|
|
567
|
-
| -------------------------------------------------------- |
|
|
568
|
-
| `frameguard: "sameorigin"` en config security sans effet | Clé **inerte** — non lue par la couche applicative (`securityHeaders.ts#ISecurityHeadersOptions`)
|
|
569
|
-
| `<script>` inline bloqué | Le template ne reprend pas le nonce
|
|
570
|
-
| CSP contenant `'nonce-{{nonce}}'` littéral | `cspNonces` désactivé, placeholder laissé
|
|
571
|
-
| Une extension `script-src` de module sans effet | Second en-tête / directive dupliquée (ignorée, W3C CSP3 §3)
|
|
572
|
-
| Assets tiers cassés après activation de l'isolation | `coep: "require-corp"` exige CORP/CORS sur **chaque** ressource
|
|
573
|
-
| HSTS absent en développement | Posé sur TLS uniquement (`http-kernel.ts#HttpKernel.getErrorRenderer`)
|
|
574
|
-
| Le CSP est plus large en dev qu'en prod | `@nodefony/frontend` déclare les origines Vite (`FrontendService.ts#FrontendService.#buildEntries`)
|
|
575
|
-
|
|
|
576
|
-
|
|
|
920
|
+
| Symptôme | Cause dans le code | Correction |
|
|
921
|
+
| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ |
|
|
922
|
+
| `frameguard: "sameorigin"` en config security sans effet | Clé **inerte** — non lue par la couche applicative (`securityHeaders.ts#ISecurityHeadersOptions`) | Régler `securityHeaders.frameOptions` du module **http** |
|
|
923
|
+
| `<script>` inline bloqué | Le template ne reprend pas le nonce | Rendre `<script nonce="…">` depuis `context.cspNonce` |
|
|
924
|
+
| CSP contenant `'nonce-{{nonce}}'` littéral | `cspNonces` désactivé, placeholder laissé | Nodefony purge le résiduel (`securityHeaders.ts#ISecurityHeadersOptions.cspNonces`) ; retirer le placeholder |
|
|
925
|
+
| Une extension `script-src` de module sans effet | Second en-tête / directive dupliquée (ignorée, W3C CSP3 §3) | Déclarer un fragment via `registerCspOrigins()`, jamais un `setHeader` |
|
|
926
|
+
| Assets tiers cassés après activation de l'isolation | `coep: "require-corp"` exige CORP/CORS sur **chaque** ressource | Retirer `coep` ou faire annoncer les ressources |
|
|
927
|
+
| HSTS absent en développement | Posé sur TLS uniquement (`http-kernel.ts#HttpKernel.getErrorRenderer`) | Comportement conforme RFC 6797 — vérifier sur le port HTTPS |
|
|
928
|
+
| Le CSP est plus large en dev qu'en prod | `@nodefony/frontend` déclare les origines Vite (`FrontendService.ts#FrontendService.#buildEntries`) | Attendu : le fragment n'existe pas en production |
|
|
929
|
+
| Violation en console, mais aucun rapport reçu | Le navigateur n'envoie pas de rapports — Brave les bloque (vie privée), en `report-uri` comme en `report-to` | Éprouver sous Chrome, Edge ou Firefox ; l'onglet Réseau doit montrer `POST csp-report` → 204 |
|
|
930
|
+
| Aucun rapport reçu en `report-to` en développement | La Reporting API n'envoie que vers une origine sûre (https, localhost) | Attendu : `report-uri` porte les rapports en http ; tester sur le port HTTPS |
|
|
931
|
+
| Aucun en-tête applicatif | Module security absent ou `headers.enabled: false` | Le socle transport reste actif ; réactiver security pour CSP/Referrer/isolation |
|
|
932
|
+
| Domaine injoignable après activation de `preload` | Inscription à la liste pré-chargée, sortie très lente | Ne jamais activer sans plan HTTPS sur **tous** les sous-domaines |
|
|
577
933
|
|
|
578
934
|
## 🧪 Tests & couverture
|
|
579
935
|
|