@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.
Files changed (104) hide show
  1. package/README.md +24 -0
  2. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/index.js +63 -61
  4. package/dist/nodefony/command/security-mcp-token.js +43 -5
  5. package/dist/nodefony/config/config.js +30 -11
  6. package/dist/nodefony/config/defineModuleConfig.js +52 -1
  7. package/dist/nodefony/contracts/IWebhookPayload.js +1 -0
  8. package/dist/nodefony/errors/FetchPublicError.js +18 -0
  9. package/dist/nodefony/errors/SsrfError.js +9 -1
  10. package/dist/nodefony/errors/StaleSessionError.js +20 -0
  11. package/dist/nodefony/errors/index.js +7 -5
  12. package/dist/nodefony/keycloak/keycloakAdmin.js +3 -3
  13. package/dist/nodefony/keycloak/scaffold.js +31 -14
  14. package/dist/nodefony/keycloak/security-keycloak-realm.js +1 -1
  15. package/dist/nodefony/service/accessTokenVerifier.js +6 -6
  16. package/dist/nodefony/service/apiKeys.js +8 -4
  17. package/dist/nodefony/service/auditService.js +48 -3
  18. package/dist/nodefony/service/authFlow.js +9 -6
  19. package/dist/nodefony/service/authorization.js +1 -1
  20. package/dist/nodefony/service/firewall.js +281 -44
  21. package/dist/nodefony/service/keycloak.js +1 -1
  22. package/dist/nodefony/service/oauth2.js +8 -4
  23. package/dist/nodefony/service/securityHeaders.js +340 -47
  24. package/dist/nodefony/service/tokenService.js +16 -11
  25. package/dist/nodefony/service/totp.js +10 -5
  26. package/dist/nodefony/service/webAuthn.js +46 -7
  27. package/dist/nodefony/service/webhooks.js +11 -4
  28. package/dist/nodefony/src/admin/SecurityAdminApi.js +19 -0
  29. package/dist/nodefony/src/audit/AuditRefusalAggregator.js +139 -0
  30. package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +1 -1
  31. package/dist/nodefony/src/authenticator/ExternalSubjectResolver.js +31 -7
  32. package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +62 -20
  33. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +29 -6
  34. package/dist/nodefony/src/authenticator/SessionAuthenticator.js +22 -9
  35. package/dist/nodefony/src/authenticator/authenticatorRegistry.js +2 -2
  36. package/dist/nodefony/src/csp.js +265 -10
  37. package/dist/nodefony/src/cspReports.js +307 -0
  38. package/dist/nodefony/src/net/fetchPublic.js +244 -0
  39. package/dist/nodefony/src/net/ssrfGuard.js +2 -2
  40. package/dist/nodefony/src/oauth/clientCredentials.js +1 -1
  41. package/dist/nodefony/src/oauth/metadata.js +1 -1
  42. package/dist/nodefony/src/oauth/oauthProviderRegistry.js +17 -4
  43. package/dist/nodefony/src/oauth/providers/github.js +1 -1
  44. package/dist/nodefony/src/realtime/UserRealtimeToken.js +59 -2
  45. package/dist/nodefony/src/realtime/frameAuthorizer.js +136 -44
  46. package/dist/nodefony/src/token/RemoteJwtVerifier.js +8 -2
  47. package/dist/nodefony/src/token/jwtRuntime.js +22 -2
  48. package/dist/nodefony/src/token/tokenTimeClaims.js +34 -0
  49. package/dist/nodefony/src/totp/totpOperations.js +1 -1
  50. package/dist/nodefony/src/voter/RoleVoter.js +1 -1
  51. package/dist/nodefony/src/webhook/WebhookDispatcher.js +71 -17
  52. package/dist/nodefony/src/webhook/webhookDelivery.js +37 -86
  53. package/dist/nodefony/testing/index.js +26 -36
  54. package/dist/types/index.d.ts +5 -1
  55. package/dist/types/nodefony/command/security-mcp-token.d.ts +12 -0
  56. package/dist/types/nodefony/config/config.d.ts +8 -0
  57. package/dist/types/nodefony/contracts/IApiKey.d.ts +8 -2
  58. package/dist/types/nodefony/contracts/IFirewall.d.ts +15 -2
  59. package/dist/types/nodefony/contracts/ITokenStore.d.ts +7 -3
  60. package/dist/types/nodefony/contracts/IWebhookEndpoint.d.ts +13 -3
  61. package/dist/types/nodefony/contracts/IWebhookPayload.d.ts +24 -0
  62. package/dist/types/nodefony/errors/FetchPublicError.d.ts +15 -0
  63. package/dist/types/nodefony/errors/SsrfError.d.ts +8 -1
  64. package/dist/types/nodefony/errors/StaleSessionError.d.ts +17 -0
  65. package/dist/types/nodefony/errors/index.d.ts +3 -0
  66. package/dist/types/nodefony/keycloak/scaffold.d.ts +12 -6
  67. package/dist/types/nodefony/service/apiKeys.d.ts +3 -0
  68. package/dist/types/nodefony/service/authFlow.d.ts +2 -2
  69. package/dist/types/nodefony/service/firewall.d.ts +28 -7
  70. package/dist/types/nodefony/service/securityHeaders.d.ts +82 -11
  71. package/dist/types/nodefony/service/webAuthn.d.ts +13 -0
  72. package/dist/types/nodefony/service/webhooks.d.ts +7 -1
  73. package/dist/types/nodefony/src/admin/SecurityAdminApi.d.ts +3 -0
  74. package/dist/types/nodefony/src/audit/AuditRefusalAggregator.d.ts +55 -0
  75. package/dist/types/nodefony/src/authenticator/ExternalSubjectResolver.d.ts +11 -0
  76. package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +17 -3
  77. package/dist/types/nodefony/src/authenticator/SessionAuthenticator.d.ts +11 -4
  78. package/dist/types/nodefony/src/csp.d.ts +142 -0
  79. package/dist/types/nodefony/src/cspReports.d.ts +73 -0
  80. package/dist/types/nodefony/src/net/fetchPublic.d.ts +73 -0
  81. package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +15 -0
  82. package/dist/types/nodefony/src/realtime/UserRealtimeToken.d.ts +35 -1
  83. package/dist/types/nodefony/src/realtime/frameAuthorizer.d.ts +10 -2
  84. package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +42 -18
  85. package/dist/types/nodefony/src/token/jwtRuntime.d.ts +21 -2
  86. package/dist/types/nodefony/src/token/tokenTimeClaims.d.ts +32 -0
  87. package/dist/types/nodefony/src/webhook/WebhookDispatcher.d.ts +36 -1
  88. package/dist/types/nodefony/src/webhook/webhookDelivery.d.ts +5 -2
  89. package/dist/types/nodefony/testing/index.d.ts +6 -0
  90. package/docs/api-keys.md +3 -1
  91. package/docs/audit.md +112 -28
  92. package/docs/authenticators.md +6 -4
  93. package/docs/csrf.md +3 -2
  94. package/docs/external-jwt.md +5 -1
  95. package/docs/firewall.md +3 -2
  96. package/docs/headers.md +427 -71
  97. package/docs/keycloak.md +4 -4
  98. package/docs/login-page.md +4 -3
  99. package/docs/obtenir-un-jeton.md +17 -10
  100. package/docs/tokens.md +1 -1
  101. package/docs/totp.md +2 -2
  102. package/docs/webauthn.md +2 -2
  103. package/docs/webhooks.md +63 -18
  104. package/package.json +17 -17
@@ -1,5 +1,138 @@
1
+ import { BootConfigurationError } from "nodefony";
1
2
  // nodefony/src/csp.ts
2
3
  /**
4
+ * Fusion de directives Content-Security-Policy — logique PURE (aucune dépendance
5
+ * à Vite, au frontend ni au transport). Permet à un module de DÉCLARER ses besoins
6
+ * CSP (ex. `@nodefony/frontend` en dev : origines Vite + `'unsafe-eval'` pour le
7
+ * Fast Refresh) sans que `@nodefony/security` connaisse leur sémantique : security
8
+ * se contente de MERGER des fragments génériques `directive → sources`.
9
+ *
10
+ * Pourquoi un merge structuré (et pas une simple concaténation) : en CSP une
11
+ * directive RÉPÉTÉE est ignorée après sa 1ʳᵉ occurrence (W3C CSP3 §3) → concaténer
12
+ * deux `script-src` perdrait le second. Il faut fusionner les sources dans UNE
13
+ * directive. Le token `'nonce-{{nonce}}'` est opaque (ni `;` ni espace) → préservé
14
+ * tel quel par parse/serialize.
15
+ */
16
+ const DIRECTIVE_NAME = /^[A-Za-z0-9-]+$/;
17
+ const SOURCE_TOKEN = /^[\x21-\x2B\x2D-\x3A\x3C-\x7E]+$/;
18
+ const SCHEME_SOURCE = /^[A-Za-z][A-Za-z0-9+.-]*:$/;
19
+ const HOST_OF_SOURCE = /^(?:[A-Za-z][A-Za-z0-9+.-]*:\/\/)?([^/:]+)/;
20
+ const LOCAL_SCHEMES = /* @__PURE__ */ new Set([
21
+ "data:",
22
+ "blob:",
23
+ "filesystem:",
24
+ "mediastream:"
25
+ ]);
26
+ const SCRIPT_DIRECTIVES = /* @__PURE__ */ new Set([
27
+ "script-src",
28
+ "script-src-elem",
29
+ "script-src-attr"
30
+ ]);
31
+ /**
32
+ * Dit si une source CSP ouvre la directive à n'importe quel contenu.
33
+ *
34
+ * Trop large : `*`, un schéma réseau nu (`https:`, `wss:`…), un hôte joker seul
35
+ * (`https://*`, `*:443`). Restent acceptés : une origine exacte, un joker de
36
+ * sous-domaine borné (`https://*.example.com`), un mot-clé (`'self'`) et les
37
+ * schémas locaux (`blob:`, `data:`) — SAUF dans une directive de scripts :
38
+ * `data:` y exécute le code porté par l'URL, `blob:` celui qu'un script a
39
+ * fabriqué, et sans nonce c'est une injection de script.
40
+ *
41
+ * @param source - un jeton de source CSP.
42
+ * @param directive - la directive qui porte la source ; sans elle, les schémas
43
+ * locaux sont acceptés.
44
+ * @returns `true` si la source autorise n'importe quel hôte ou contenu.
45
+ */
46
+ function isOverbroadSource(source, directive) {
47
+ if (source.startsWith("'")) return false;
48
+ if (SCHEME_SOURCE.test(source)) {
49
+ if (directive !== void 0 && SCRIPT_DIRECTIVES.has(directive.toLowerCase())) return true;
50
+ return !LOCAL_SCHEMES.has(source.toLowerCase());
51
+ }
52
+ return HOST_OF_SOURCE.exec(source)?.[1] === "*";
53
+ }
54
+ /**
55
+ * Retire d'un fragment de MODULE ses sources trop larges (cf {@link isOverbroadSource}).
56
+ *
57
+ * Moindre privilège : une contribution GLOBALE (`Firewall.registerCspOrigins`)
58
+ * ne déclare que SON origine, et n'ouvre pas la politique de toute l'application.
59
+ * Retirer plutôt que lever : la sécurité ne bloque pas le démarrage. Ne
60
+ * s'applique PAS à ce que l'application écrit elle-même (sa config, un `@Csp` de
61
+ * route) : un choix délibéré de son auteur n'est jamais contourné en silence.
62
+ *
63
+ * @param fragment - directives déclarées par un module.
64
+ * @returns le fragment sans les sources trop larges, et les paires retirées.
65
+ */
66
+ function withoutOverbroadSources(fragment) {
67
+ const dropped = findOverbroadSources(Object.entries(fragment));
68
+ if (dropped.length === 0) return {
69
+ fragment,
70
+ dropped
71
+ };
72
+ const kept = {};
73
+ for (const name in fragment) {
74
+ const sources = (fragment[name] ?? []).filter((s) => !isOverbroadSource(s, name));
75
+ if (sources.length > 0) kept[name] = sources;
76
+ }
77
+ return {
78
+ fragment: kept,
79
+ dropped
80
+ };
81
+ }
82
+ /**
83
+ * Relève les sources trop larges d'un jeu de directives (cf {@link isOverbroadSource}).
84
+ *
85
+ * @param directives - paires `[directive, sources]` (`parseCsp(csp)` ou `Object.entries(fragment)`).
86
+ * @returns les paires `[directive, source]` trop larges, dans l'ordre de lecture.
87
+ */
88
+ function findOverbroadSources(directives) {
89
+ const out = [];
90
+ for (const [name, sources] of directives) for (const source of sources) if (isOverbroadSource(source, name)) out.push([name, source]);
91
+ return out;
92
+ }
93
+ /**
94
+ * Relève les sources de scripts d'un fragment que `'strict-dynamic'` rend inertes.
95
+ *
96
+ * Sous `'strict-dynamic'` dans `script-src`, un navigateur CSP3 ignore les hôtes
97
+ * et les schémas : un script n'est admis que signé, ou chargé par un script
98
+ * signé (W3C CSP3 § « strict-dynamic »). La source reste utile à un navigateur
99
+ * CSP2 ; on la garde, mais son auteur doit savoir qu'elle n'ouvre rien.
100
+ *
101
+ * @param fragment - directives déclarées par un module.
102
+ * @param policy - politique à laquelle le fragment s'ajoute.
103
+ * @returns les paires `[directive, source]` inertes ; vide sans `'strict-dynamic'`.
104
+ */
105
+ function findInertScriptSources(fragment, policy) {
106
+ const out = [];
107
+ let strictDynamic = null;
108
+ for (const name in fragment) {
109
+ if (!SCRIPT_DIRECTIVES.has(name.toLowerCase())) continue;
110
+ strictDynamic ??= parseCsp(policy).some(([n, sources]) => n.toLowerCase() === "script-src" && sources.includes("'strict-dynamic'"));
111
+ if (!strictDynamic) return out;
112
+ for (const source of fragment[name] ?? []) if (!source.startsWith("'")) out.push([name, source]);
113
+ }
114
+ return out;
115
+ }
116
+ /**
117
+ * Refuse un fragment CSP qui sortirait de la grammaire CSP3 — seule garde contre
118
+ * l'injection de directives par une valeur de configuration.
119
+ *
120
+ * Sans elle, une source comme `https://a.example; frame-src *` ajoute une
121
+ * directive que personne n'a demandée, et `https://a.example, frame-src *` coupe
122
+ * l'en-tête en deux politiques. Appelée là où un fragment ENTRE (enregistrement
123
+ * d'un module, fusion d'une route `@Csp`), jamais par requête.
124
+ *
125
+ * @param fragment - directives additionnelles (`directive → sources`).
126
+ * @param origin - qui déclare le fragment, pour nommer la faute.
127
+ * @throws BootConfigurationError si un nom ou une source sort de la grammaire.
128
+ */
129
+ function assertCspFragment(fragment, origin) {
130
+ for (const name in fragment) {
131
+ if (!DIRECTIVE_NAME.test(name)) throw new BootConfigurationError(`[@nodefony/security] CSP refusée (${origin}) — nom de directive invalide ${JSON.stringify(name)} : lettres, chiffres et « - » seulement (W3C CSP3 §2.2).`);
132
+ for (const source of fragment[name] ?? []) if (!SOURCE_TOKEN.test(source)) throw new BootConfigurationError(`[@nodefony/security] CSP refusée (${origin}) — source invalide ${JSON.stringify(source)} dans « ${name} » : ni espace, ni « ; », ni « , », ni caractère de contrôle (W3C CSP3 §2.2). Une source = une origine ou un mot-clé ; déclarer plusieurs sources = plusieurs entrées du tableau.`);
133
+ }
134
+ }
135
+ /**
3
136
  * Parse une chaîne CSP en directives ordonnées `[nom, sources[]]`. L'ordre des
4
137
  * directives et des sources est préservé (déterminisme : header stable, tests
5
138
  * fiables). Les séparateurs multiples / espaces superflus sont normalisés.
@@ -28,25 +161,147 @@ function serializeCsp(directives) {
28
161
  * origines (jamais par requête). Le résultat repart dans `SecurityHeaders`, qui
29
162
  * re-split autour de `{{nonce}}` au boot → 1 `join` par requête (hot-path inchangé).
30
163
  *
164
+ * Chaque fragment passe {@link assertCspFragment} : une source hors grammaire
165
+ * lève au lieu d'injecter une directive. Aucune source n'est retirée ici : le
166
+ * filtre des sources trop larges ne vise que les contributions globales des
167
+ * modules ({@link withoutOverbroadSources}, appliqué à l'enregistrement).
168
+ *
31
169
  * @param base - CSP de configuration (peut contenir `'nonce-{{nonce}}'`).
32
170
  * @param fragments - fragments additifs (un par module enregistré).
33
171
  * @returns la chaîne CSP fusionnée.
172
+ * @throws BootConfigurationError si un fragment sort de la grammaire CSP3.
34
173
  */
35
174
  function mergeCspFragments(base, fragments) {
36
175
  const directives = parseCsp(base);
37
176
  const index = /* @__PURE__ */ new Map();
38
177
  for (const [name, src] of directives) index.set(name, src);
39
- for (const fragment of fragments) for (const name in fragment) {
40
- const additions = fragment[name];
41
- if (additions === void 0 || additions.length === 0) continue;
42
- let sources = index.get(name);
43
- if (!sources) {
44
- sources = [];
45
- index.set(name, sources);
46
- directives.push([name, sources]);
178
+ for (const fragment of fragments) {
179
+ assertCspFragment(fragment, "fusion");
180
+ for (const name in fragment) {
181
+ const additions = fragment[name];
182
+ if (additions === void 0 || additions.length === 0) continue;
183
+ let sources = index.get(name);
184
+ if (!sources) {
185
+ sources = [];
186
+ index.set(name, sources);
187
+ directives.push([name, sources]);
188
+ }
189
+ for (const src of additions) if (!sources.includes(src)) sources.push(src);
47
190
  }
48
- for (const src of additions) if (!sources.includes(src)) sources.push(src);
49
191
  }
50
192
  return serializeCsp(directives);
51
193
  }
52
- export { mergeCspFragments, parseCsp, serializeCsp };
194
+ /** Directive réservée aux réponses servies en TLS (ADR-0024 §C6). */
195
+ const UPGRADE_INSECURE_REQUESTS = "upgrade-insecure-requests";
196
+ /**
197
+ * Retire une directive d'une politique, sans toucher au reste.
198
+ *
199
+ * Sert à composer, au démarrage, la variante EN CLAIR d'une politique qui porte
200
+ * `upgrade-insecure-requests` : sur une page servie en `http`, la directive
201
+ * réécrirait chaque sous-ressource en `https`, que le serveur ne sert pas.
202
+ *
203
+ * @param csp - politique (peut contenir `'nonce-{{nonce}}'`).
204
+ * @param name - nom de la directive, insensible à la casse.
205
+ * @returns la politique sans la directive ; la MÊME chaîne si elle n'y est pas.
206
+ */
207
+ function withoutDirective(csp, name) {
208
+ const wanted = name.toLowerCase();
209
+ const directives = parseCsp(csp);
210
+ const kept = directives.filter(([n]) => n.toLowerCase() !== wanted);
211
+ return kept.length === directives.length ? csp : serializeCsp(kept);
212
+ }
213
+ /**
214
+ * Traduit la valeur de `X-Frame-Options` en source de `frame-ancestors`.
215
+ *
216
+ * Un seul réglage, `http.securityHeaders.frameOptions`, pilote les DEUX en-têtes :
217
+ * un navigateur CSP ignore `X-Frame-Options` dès que `frame-ancestors` est
218
+ * présent (W3C CSP3 § « `frame-ancestors` »), et l'inverse vaut pour un ancien
219
+ * navigateur. Deux réglages distincts finiraient par dire deux choses.
220
+ *
221
+ * @param frameOptions - valeur émise de `X-Frame-Options` (`null` = non émis).
222
+ * @returns `'none'` pour `DENY`, `'self'` pour `SAMEORIGIN`, `null` sinon
223
+ * (en-tête coupé, ou valeur que `frame-ancestors` ne sait pas traduire).
224
+ */
225
+ function frameAncestorsFor(frameOptions) {
226
+ const value = frameOptions?.trim().toUpperCase();
227
+ if (value === "DENY") return "'none'";
228
+ if (value === "SAMEORIGIN") return "'self'";
229
+ return null;
230
+ }
231
+ /**
232
+ * Ajoute à une politique la directive `frame-ancestors` dérivée de
233
+ * `X-Frame-Options` (cf {@link frameAncestorsFor}).
234
+ *
235
+ * L'application garde la main : une politique qui déclare DÉJÀ
236
+ * `frame-ancestors` est rendue telle quelle — c'est le geste qui autorise
237
+ * l'encadrement par une origine tierce. Une politique vide le reste (pas de CSP
238
+ * voulue). Appelée à la composition de la politique de base, jamais par requête.
239
+ *
240
+ * @param csp - politique de base (config `security.headers.csp`).
241
+ * @param frameOptions - valeur émise de `X-Frame-Options` (`null` = non émis).
242
+ * @returns la politique, complétée de `frame-ancestors` si besoin.
243
+ */
244
+ function withFrameAncestors(csp, frameOptions) {
245
+ const source = frameAncestorsFor(frameOptions);
246
+ if (source === null || csp.trim() === "") return csp;
247
+ for (const [name] of parseCsp(csp)) if (name.toLowerCase() === "frame-ancestors") return csp;
248
+ let end = csp.length;
249
+ while (end > 0 && (csp[end - 1] === ";" || /\s/.test(csp[end - 1] ?? ""))) end--;
250
+ return `${csp.slice(0, end)}; frame-ancestors ${source}`;
251
+ }
252
+ /**
253
+ * Demande à une politique ses rapports de violation : `report-uri` (lu par
254
+ * tous les navigateurs, en http comme en https) ET `report-to` (Reporting API,
255
+ * que le navigateur n'honore que vers une origine sûre — https ou localhost).
256
+ * ADR-0024 §C7.
257
+ *
258
+ * L'application garde la main : une politique qui déclare DÉJÀ `report-uri` ou
259
+ * `report-to` est rendue telle quelle — elle a son collecteur. Une politique
260
+ * vide le reste.
261
+ *
262
+ * @param csp - politique de base.
263
+ * @param url - où poster les rapports (chemin absolu ou URL https).
264
+ * @param group - nom du groupe `report-to`, déclaré par `Reporting-Endpoints`.
265
+ * @returns la politique, et `added` vrai si les directives ont été posées ici —
266
+ * c'est alors à l'appelant d'émettre `Reporting-Endpoints`.
267
+ */
268
+ function withViolationReports(csp, url, group) {
269
+ if (csp.trim() === "") return {
270
+ csp,
271
+ added: false
272
+ };
273
+ for (const [name] of parseCsp(csp)) {
274
+ const lower = name.toLowerCase();
275
+ if (lower === "report-uri" || lower === "report-to") return {
276
+ csp,
277
+ added: false
278
+ };
279
+ }
280
+ let end = csp.length;
281
+ while (end > 0 && (csp[end - 1] === ";" || /\s/.test(csp[end - 1] ?? ""))) end--;
282
+ return {
283
+ csp: `${csp.slice(0, end)}; report-uri ${url}; report-to ${group}`,
284
+ added: true
285
+ };
286
+ }
287
+ /**
288
+ * Valide l'URL d'un collecteur de rapports écrite par l'application : un
289
+ * chemin absolu (`/csp`) ou une URL `https:` / `http:`, sans rien qu'un en-tête
290
+ * ou une directive CSP ne puisse porter (espace, `;`, `,`, guillemet).
291
+ *
292
+ * @param url - valeur de `headers.cspReport`.
293
+ * @returns la cause du refus, ou `null` si l'URL est utilisable.
294
+ */
295
+ function reportUrlProblem(url) {
296
+ if (url === "") return "URL vide";
297
+ if (/[^\x21-\x7e]|[;,"\\]/u.test(url)) return "caractère qu’un en-tête ne peut pas porter";
298
+ if (url.startsWith("/")) return url.startsWith("//") ? "URL relative au schéma (//)" : null;
299
+ let parsed;
300
+ try {
301
+ parsed = new URL(url);
302
+ } catch {
303
+ return "ni chemin absolu ni URL";
304
+ }
305
+ return parsed.protocol === "https:" || parsed.protocol === "http:" ? null : `schéma ${parsed.protocol} refusé`;
306
+ }
307
+ export { UPGRADE_INSECURE_REQUESTS, assertCspFragment, findInertScriptSources, findOverbroadSources, frameAncestorsFor, isOverbroadSource, mergeCspFragments, parseCsp, reportUrlProblem, serializeCsp, withFrameAncestors, withViolationReports, withoutDirective, withoutOverbroadSources };
@@ -0,0 +1,307 @@
1
+ import { MemoryRateLimitStore } from "@nodefony/http";
2
+ // nodefony/src/cspReports.ts
3
+ const MINUTE_MS = 6e4;
4
+ const MAX_URL_LENGTH = 512;
5
+ const MAX_USER_AGENT_LENGTH = 200;
6
+ /** Directive ou mot-clé de la norme : minuscules et tirets, court. */
7
+ const TOKEN = /^[a-z][a-z0-9-]{0,47}$/u;
8
+ /** Contrôles C0, DEL et C1 — jamais dans une ligne de journal ni un affichage. */
9
+ const CONTROL = /[\u0000-\u001f\u007f-\u009f]/gu;
10
+ const MAX_INT = 1e7;
11
+ /**
12
+ * Reçoit, expurge, dédoublonne et garde EN MÉMOIRE les rapports de violation
13
+ * CSP (ADR-0024 §C7). La porte qui les livre est publique — le navigateur
14
+ * n'envoie aucun identifiant — donc tout ce qui entre est traité comme écrit
15
+ * par un attaquant :
16
+ *
17
+ * - **quota par adresse** (fenêtre d'une minute, le limiteur de `@nodefony/http`) ;
18
+ * - **deux types de contenu seulement** : `application/csp-report` (`report-uri`)
19
+ * et `application/reports+json` (`report-to`) ;
20
+ * - **URL expurgées** de leur requête, de leur fragment et de leurs identifiants
21
+ * (la norme ne retire pas la requête : elle peut porter un jeton) ; une URL
22
+ * qui n'est pas HTTP(S) se réduit à son schéma ;
23
+ * - **l'extrait de code n'est jamais gardé** — c'est une chaîne de l'attaquant ;
24
+ * - **mémoire bornée** : au plus `capacity` violations distinctes, la moins
25
+ * récemment vue évincée d'abord ; une répétition incrémente un compteur ;
26
+ * - **journal dédoublonné et borné** : une violation neuve y part une fois, et
27
+ * pas plus de `logPerMinute` lignes par minute.
28
+ *
29
+ * Rien n'est écrit sur disque : le journal centralisé voit déjà ce qui compte.
30
+ */
31
+ var CspReportCollector = class {
32
+ /** URL que la politique désigne. */
33
+ endpoint;
34
+ /** Violations distinctes gardées au plus. */
35
+ capacity;
36
+ #violations = /* @__PURE__ */ new Map();
37
+ #limiter;
38
+ #log;
39
+ #now;
40
+ #logPerMinute;
41
+ #logWindowStart = 0;
42
+ #loggedInWindow = 0;
43
+ #silenced = 0;
44
+ #received = 0;
45
+ #rejected = 0;
46
+ /**
47
+ * @param options - URL désignée, bornes, journal, horloge.
48
+ */
49
+ constructor(options) {
50
+ this.endpoint = options.endpoint;
51
+ this.capacity = options.capacity ?? 200;
52
+ this.#now = options.now ?? Date.now;
53
+ this.#log = options.log ?? null;
54
+ this.#logPerMinute = options.logPerMinute ?? 20;
55
+ this.#limiter = new MemoryRateLimitStore({
56
+ windowMs: MINUTE_MS,
57
+ max: options.perMinute ?? 120,
58
+ maxTracked: options.maxTrackedClients ?? 1e4
59
+ }, this.#now);
60
+ }
61
+ /**
62
+ * Traite un corps de rapport déjà lu (et déjà borné par l'appelant).
63
+ *
64
+ * @param contentType - type de contenu de la requête, paramètres compris ou non.
65
+ * @param body - le corps, octets ou texte.
66
+ * @param client - adresse cliente résolue (proxy de confiance compris) ;
67
+ * `null` = inconnue, non comptée (jamais une clé commune à tous).
68
+ * @param userAgent - en-tête `User-Agent`, gardé tronqué.
69
+ * @returns ce que le rapport est devenu — pour le journal et les tests ;
70
+ * l'émetteur reçoit 204 quoi qu'il arrive.
71
+ */
72
+ receive(contentType, body, client, userAgent) {
73
+ this.#received++;
74
+ if (client !== null && this.#limiter.hit(client).limited) {
75
+ this.#rejected++;
76
+ return "limited";
77
+ }
78
+ const type = mediaTypeOf(contentType);
79
+ if (type !== "application/csp-report" && type !== "application/reports+json") {
80
+ this.#rejected++;
81
+ return "unsupported";
82
+ }
83
+ let payload;
84
+ try {
85
+ payload = JSON.parse(typeof body === "string" ? body : Buffer.from(body).toString("utf8"));
86
+ } catch {
87
+ this.#rejected++;
88
+ return "malformed";
89
+ }
90
+ const agent = cleanText(userAgent, MAX_USER_AGENT_LENGTH);
91
+ const violations = violationsOf(payload);
92
+ if (violations.length === 0) {
93
+ this.#rejected++;
94
+ return "malformed";
95
+ }
96
+ let outcome = "duplicate";
97
+ for (const violation of violations) if (this.#store(violation, agent)) outcome = "stored";
98
+ return outcome;
99
+ }
100
+ /**
101
+ * Photo des violations gardées, la plus récemment vue d'abord — ce que rend
102
+ * le plan d'administration.
103
+ *
104
+ * @returns une copie : la modifier ne touche pas le collecteur.
105
+ */
106
+ snapshot() {
107
+ const items = [];
108
+ for (const v of this.#violations.values()) items.push({ ...v });
109
+ items.reverse();
110
+ return {
111
+ enabled: true,
112
+ endpoint: this.endpoint,
113
+ capacity: this.capacity,
114
+ received: this.#received,
115
+ rejected: this.#rejected,
116
+ items
117
+ };
118
+ }
119
+ /** Range une violation ; `true` si elle est neuve. */
120
+ #store(violation, userAgent) {
121
+ const now = this.#now();
122
+ const key = [
123
+ violation.disposition,
124
+ violation.effectiveDirective,
125
+ violation.documentURL,
126
+ violation.blockedURL ?? "",
127
+ violation.sourceFile ?? "",
128
+ violation.lineNumber ?? "",
129
+ violation.columnNumber ?? ""
130
+ ].join("\0");
131
+ const known = this.#violations.get(key);
132
+ if (known !== void 0) {
133
+ known.count++;
134
+ known.lastSeen = now;
135
+ if (userAgent !== null) known.userAgent = userAgent;
136
+ this.#violations.delete(key);
137
+ this.#violations.set(key, known);
138
+ return false;
139
+ }
140
+ if (this.#violations.size >= this.capacity) {
141
+ const oldest = this.#violations.keys().next();
142
+ if (oldest.done !== true) this.#violations.delete(oldest.value);
143
+ }
144
+ this.#violations.set(key, {
145
+ ...violation,
146
+ userAgent,
147
+ count: 1,
148
+ firstSeen: now,
149
+ lastSeen: now
150
+ });
151
+ this.#announce(violation, now);
152
+ return true;
153
+ }
154
+ /** Journalise une violation neuve, dans la limite du budget de la minute. */
155
+ #announce(v, now) {
156
+ if (this.#log === null) return;
157
+ if (now - this.#logWindowStart >= MINUTE_MS) {
158
+ if (this.#silenced > 0) this.#log(`CSP : ${this.#silenced} violation(s) neuve(s) non journalisée(s) la minute précédente — liste complète : GET /nodefony/security/api/csp-reports`, "NOTICE");
159
+ this.#logWindowStart = now;
160
+ this.#loggedInWindow = 0;
161
+ this.#silenced = 0;
162
+ }
163
+ if (this.#loggedInWindow >= this.#logPerMinute) {
164
+ this.#silenced++;
165
+ return;
166
+ }
167
+ this.#loggedInWindow++;
168
+ const where = v.sourceFile !== null ? ` (${v.sourceFile}${v.lineNumber !== null ? `:${v.lineNumber}` : ""})` : "";
169
+ this.#log(`CSP ${v.disposition === "report" ? "signalée" : "bloquée"} : ${v.effectiveDirective} refuse ${v.blockedURL ?? "?"} sur ${v.documentURL}${where}`, "WARNING");
170
+ }
171
+ };
172
+ /** Type de contenu sans paramètres, en minuscules. */
173
+ function mediaTypeOf(value) {
174
+ if (typeof value !== "string") return null;
175
+ const semi = value.indexOf(";");
176
+ return (semi === -1 ? value : value.slice(0, semi)).trim().toLowerCase();
177
+ }
178
+ /** Objet ordinaire, ou `null`. */
179
+ function recordOf(value) {
180
+ return isRecord(value) ? value : null;
181
+ }
182
+ function isRecord(value) {
183
+ return typeof value === "object" && value !== null && !Array.isArray(value);
184
+ }
185
+ /**
186
+ * Lit un corps de rapport selon sa FORME, quel que soit celui des deux types de
187
+ * contenu admis qui l'annonce.
188
+ *
189
+ * Le type ne suffit pas à décider : WebKit (Safari) poste son `report-to` en
190
+ * `application/csp-report` avec UN rapport de la Reporting API, ni enveloppé
191
+ * dans `csp-report` ni mis en tableau (corps relevé au navigateur). Lu sur le
192
+ * seul type, ce rapport était écarté comme malformé — et toutes les violations
193
+ * de Safari avec lui.
194
+ */
195
+ function violationsOf(payload) {
196
+ if (Array.isArray(payload)) return fromReportsJson(payload);
197
+ const record = recordOf(payload);
198
+ if (record === null) return [];
199
+ if ("csp-report" in record) return fromCspReport(record);
200
+ return fromReportsJson([record]);
201
+ }
202
+ /** Corps `{ "csp-report": { … } }` (CSP2, `report-uri`). */
203
+ function fromCspReport(payload) {
204
+ const report = recordOf(payload["csp-report"]);
205
+ if (report === null) return [];
206
+ const one = normalize({
207
+ documentURL: report["document-uri"],
208
+ blockedURL: report["blocked-uri"],
209
+ effectiveDirective: report["effective-directive"],
210
+ violatedDirective: report["violated-directive"],
211
+ disposition: report["disposition"],
212
+ sourceFile: report["source-file"],
213
+ lineNumber: report["line-number"],
214
+ columnNumber: report["column-number"],
215
+ statusCode: report["status-code"]
216
+ });
217
+ return one === null ? [] : [one];
218
+ }
219
+ /**
220
+ * Rapports de la Reporting API (`report-to`), dont seuls ceux de type
221
+ * `csp-violation` sont lus.
222
+ */
223
+ function fromReportsJson(payload) {
224
+ const out = [];
225
+ for (const item of payload) {
226
+ const report = recordOf(item);
227
+ if (report?.["type"] !== "csp-violation") continue;
228
+ const body = recordOf(report["body"]);
229
+ if (body === null) continue;
230
+ const one = normalize({
231
+ documentURL: body["documentURL"] ?? report["url"],
232
+ blockedURL: body["blockedURL"],
233
+ effectiveDirective: body["effectiveDirective"],
234
+ violatedDirective: void 0,
235
+ disposition: body["disposition"],
236
+ sourceFile: body["sourceFile"],
237
+ lineNumber: body["lineNumber"],
238
+ columnNumber: body["columnNumber"],
239
+ statusCode: body["statusCode"]
240
+ });
241
+ if (one !== null) out.push(one);
242
+ }
243
+ return out;
244
+ }
245
+ /**
246
+ * Expurge une violation ; `null` si elle ne désigne ni un document ni une
247
+ * directive — rien d'exploitable, donc rien à garder.
248
+ */
249
+ function normalize(raw) {
250
+ const documentURL = stripUrl(raw.documentURL);
251
+ if (documentURL === null) return null;
252
+ const effectiveDirective = tokenOf(raw.effectiveDirective) ?? firstTokenOf(raw.violatedDirective);
253
+ if (effectiveDirective === null) return null;
254
+ return {
255
+ documentURL,
256
+ blockedURL: stripUrl(raw.blockedURL),
257
+ effectiveDirective,
258
+ disposition: raw.disposition === "report" ? "report" : "enforce",
259
+ sourceFile: stripUrl(raw.sourceFile),
260
+ lineNumber: intOf(raw.lineNumber),
261
+ columnNumber: intOf(raw.columnNumber),
262
+ statusCode: intOf(raw.statusCode)
263
+ };
264
+ }
265
+ /**
266
+ * Une URL de rapport, expurgée : sans identifiants, requête ni fragment ; le
267
+ * seul schéma si elle n'est pas HTTP(S) (`w3c-reporting.bs`, « Strip URL for
268
+ * use in reports », qui garde la requête — pas nous : elle peut porter un
269
+ * jeton). Un mot-clé de la norme (`inline`, `eval`…) passe tel quel.
270
+ */
271
+ function stripUrl(value) {
272
+ if (typeof value !== "string" || value === "") return null;
273
+ if (TOKEN.test(value)) return value;
274
+ let url;
275
+ try {
276
+ url = new URL(value);
277
+ } catch {
278
+ return null;
279
+ }
280
+ if (url.protocol !== "http:" && url.protocol !== "https:") return url.protocol.slice(0, -1);
281
+ url.username = "";
282
+ url.password = "";
283
+ url.search = "";
284
+ url.hash = "";
285
+ const href = url.href;
286
+ return href.length > MAX_URL_LENGTH ? href.slice(0, MAX_URL_LENGTH) : href;
287
+ }
288
+ function tokenOf(value) {
289
+ return typeof value === "string" && TOKEN.test(value) ? value : null;
290
+ }
291
+ /** `violated-directive` (CSP2) porte la directive suivie de ses sources. */
292
+ function firstTokenOf(value) {
293
+ if (typeof value !== "string") return null;
294
+ const space = value.indexOf(" ");
295
+ return tokenOf(space === -1 ? value : value.slice(0, space));
296
+ }
297
+ function intOf(value) {
298
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= MAX_INT ? value : null;
299
+ }
300
+ /** Texte libre de l'émetteur : sans contrôles, tronqué. */
301
+ function cleanText(value, max) {
302
+ if (typeof value !== "string") return null;
303
+ const text = value.replace(CONTROL, "").trim();
304
+ if (text === "") return null;
305
+ return text.length > max ? text.slice(0, max) : text;
306
+ }
307
+ export { CspReportCollector };