@nodefony/security 10.0.0-beta.4 → 10.0.0-beta.6

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 (74) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.157.0}/helpers/esm/decorate.js +1 -1
  2. package/dist/index.js +60 -60
  3. package/dist/nodefony/config/config.js +13 -5
  4. package/dist/nodefony/contracts/IWebhookPayload.js +1 -0
  5. package/dist/nodefony/errors/index.js +4 -4
  6. package/dist/nodefony/keycloak/keycloakAdmin.js +3 -3
  7. package/dist/nodefony/keycloak/security-keycloak-realm.js +1 -1
  8. package/dist/nodefony/service/accessTokenVerifier.js +5 -5
  9. package/dist/nodefony/service/apiKeys.js +8 -4
  10. package/dist/nodefony/service/authFlow.js +3 -3
  11. package/dist/nodefony/service/authorization.js +1 -1
  12. package/dist/nodefony/service/firewall.js +233 -41
  13. package/dist/nodefony/service/keycloak.js +1 -1
  14. package/dist/nodefony/service/oauth2.js +8 -4
  15. package/dist/nodefony/service/securityHeaders.js +340 -47
  16. package/dist/nodefony/service/tokenService.js +8 -8
  17. package/dist/nodefony/service/totp.js +3 -3
  18. package/dist/nodefony/service/webAuthn.js +1 -1
  19. package/dist/nodefony/service/webhooks.js +11 -4
  20. package/dist/nodefony/src/admin/SecurityAdminApi.js +19 -0
  21. package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +1 -1
  22. package/dist/nodefony/src/authenticator/ExternalSubjectResolver.js +31 -7
  23. package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +62 -20
  24. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +5 -2
  25. package/dist/nodefony/src/authenticator/SessionAuthenticator.js +1 -1
  26. package/dist/nodefony/src/authenticator/authenticatorRegistry.js +1 -1
  27. package/dist/nodefony/src/csp.js +265 -10
  28. package/dist/nodefony/src/cspReports.js +307 -0
  29. package/dist/nodefony/src/oauth/clientCredentials.js +1 -1
  30. package/dist/nodefony/src/oauth/metadata.js +1 -1
  31. package/dist/nodefony/src/oauth/oauthProviderRegistry.js +17 -4
  32. package/dist/nodefony/src/oauth/providers/github.js +1 -1
  33. package/dist/nodefony/src/realtime/UserRealtimeToken.js +59 -2
  34. package/dist/nodefony/src/realtime/frameAuthorizer.js +7 -6
  35. package/dist/nodefony/src/token/RemoteJwtVerifier.js +8 -2
  36. package/dist/nodefony/src/token/tokenTimeClaims.js +34 -0
  37. package/dist/nodefony/src/totp/totpOperations.js +1 -1
  38. package/dist/nodefony/src/voter/RoleVoter.js +1 -1
  39. package/dist/nodefony/src/webhook/WebhookDispatcher.js +71 -17
  40. package/dist/nodefony/src/webhook/webhookDelivery.js +3 -3
  41. package/dist/nodefony/testing/index.js +98 -0
  42. package/dist/types/index.d.ts +1 -0
  43. package/dist/types/nodefony/config/config.d.ts +2 -0
  44. package/dist/types/nodefony/contracts/IApiKey.d.ts +8 -2
  45. package/dist/types/nodefony/contracts/IFirewall.d.ts +15 -2
  46. package/dist/types/nodefony/contracts/ITokenStore.d.ts +7 -3
  47. package/dist/types/nodefony/contracts/IWebhookEndpoint.d.ts +13 -3
  48. package/dist/types/nodefony/contracts/IWebhookPayload.d.ts +24 -0
  49. package/dist/types/nodefony/service/apiKeys.d.ts +3 -0
  50. package/dist/types/nodefony/service/firewall.d.ts +28 -7
  51. package/dist/types/nodefony/service/securityHeaders.d.ts +82 -11
  52. package/dist/types/nodefony/service/webhooks.d.ts +7 -1
  53. package/dist/types/nodefony/src/admin/SecurityAdminApi.d.ts +3 -0
  54. package/dist/types/nodefony/src/authenticator/ExternalSubjectResolver.d.ts +11 -0
  55. package/dist/types/nodefony/src/csp.d.ts +142 -0
  56. package/dist/types/nodefony/src/cspReports.d.ts +73 -0
  57. package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +15 -0
  58. package/dist/types/nodefony/src/realtime/UserRealtimeToken.d.ts +35 -1
  59. package/dist/types/nodefony/src/realtime/frameAuthorizer.d.ts +2 -2
  60. package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +25 -17
  61. package/dist/types/nodefony/src/token/tokenTimeClaims.d.ts +32 -0
  62. package/dist/types/nodefony/src/webhook/WebhookDispatcher.d.ts +36 -1
  63. package/dist/types/nodefony/testing/index.d.ts +57 -0
  64. package/docs/api-keys.md +3 -1
  65. package/docs/authenticators.md +6 -4
  66. package/docs/csrf.md +3 -2
  67. package/docs/external-jwt.md +5 -1
  68. package/docs/firewall.md +3 -2
  69. package/docs/headers.md +427 -71
  70. package/docs/login-page.md +4 -3
  71. package/docs/totp.md +2 -2
  72. package/docs/webauthn.md +2 -2
  73. package/docs/webhooks.md +57 -11
  74. package/package.json +20 -14
@@ -1,5 +1,57 @@
1
- import { mergeCspFragments } from "../src/csp.js";
1
+ import { UPGRADE_INSECURE_REQUESTS, mergeCspFragments, parseCsp, serializeCsp, withoutDirective } from "../src/csp.js";
2
+ import { BootConfigurationError } from "nodefony";
3
+ import { validateHeaderValue } from "node:http";
2
4
  // nodefony/service/securityHeaders.ts
5
+ /**
6
+ * Types qu'un navigateur rend comme un document, donc qui exécutent la
7
+ * politique : HTML, et tout type XML (WHATWG MIME Sniffing §4.6, « XML MIME
8
+ * type » — `text/xml`, `application/xml`, sous-type en `+xml`, dont XHTML et
9
+ * SVG ; ces derniers sont lus par {@link responseKindOf}).
10
+ */
11
+ const DOCUMENT_TYPES = /* @__PURE__ */ new Set([
12
+ "text/html",
13
+ "text/xml",
14
+ "application/xml"
15
+ ]);
16
+ /** Essences d'un type MIME JavaScript (WHATWG MIME Sniffing § 4.6). */
17
+ const SCRIPT_TYPES = /* @__PURE__ */ new Set([
18
+ "application/ecmascript",
19
+ "application/javascript",
20
+ "application/x-ecmascript",
21
+ "application/x-javascript",
22
+ "text/ecmascript",
23
+ "text/javascript",
24
+ "text/javascript1.0",
25
+ "text/javascript1.1",
26
+ "text/javascript1.2",
27
+ "text/javascript1.3",
28
+ "text/javascript1.4",
29
+ "text/javascript1.5",
30
+ "text/jscript",
31
+ "text/livescript",
32
+ "text/x-ecmascript",
33
+ "text/x-javascript"
34
+ ]);
35
+ /**
36
+ * Range une réponse dans sa famille d'après son `Content-Type`.
37
+ *
38
+ * @param contentType - valeur de `Content-Type` (paramètres admis) ; `null` =
39
+ * réponse sans contenu ; `undefined` = type INCONNU de l'appelant, traité en
40
+ * document — la politique la plus complète, jamais une plus faible par défaut.
41
+ * @returns la famille dont la réponse reçoit la politique.
42
+ */
43
+ function responseKindOf(contentType) {
44
+ if (contentType === void 0) return "document";
45
+ if (contentType === null) return "other";
46
+ const semi = contentType.indexOf(";");
47
+ const essence = (semi === -1 ? contentType : contentType.slice(0, semi)).trim().toLowerCase();
48
+ if (DOCUMENT_TYPES.has(essence)) return "document";
49
+ if (SCRIPT_TYPES.has(essence)) return "script";
50
+ if (essence.endsWith("+xml")) return "document";
51
+ return "other";
52
+ }
53
+ /** Politique des réponses qui ne sont ni document ni script (ADR-0024 §C11). */
54
+ const NARROW_CSP = "default-src 'none'";
3
55
  /** Marqueur substitué par le nonce CSP de la requête (cf `Context.cspNonce`). */
4
56
  const NONCE_PLACEHOLDER = "{{nonce}}";
5
57
  /**
@@ -9,54 +61,121 @@ const NONCE_PLACEHOLDER = "{{nonce}}";
9
61
  * cross-origin COOP/COEP/CORP, Origin-Agent-Cluster, Permissions-Policy) → zéro
10
62
  * alloc, zéro concat par requête (le firewall la pose telle quelle).
11
63
  *
12
- * **Étape A+B** : en-têtes constants pré-calculés au boot (Referrer/COOP/…). Pour le
13
- * CSP, deux régimes mutuellement exclusifs : **statique** (posé tel quel via `headers`,
14
- * 0 alloc/req) OU **nonce par requête** (`cspNonces` + placeholder `{{nonce}}` dans le
15
- * CSP) → segments pré-split au boot, recomposés par requête via `cspFor(nonce)` (1
16
- * `join`, aucun parse/regex dans le hot-path). Le nonce lui-même vit sur `Context`.
64
+ * Pour le CSP, deux régimes mutuellement exclusifs : **statique** (posé tel quel via
65
+ * `headers`, 0 alloc/req) OU **nonce par requête** (`cspNonces` + placeholder
66
+ * `{{nonce}}`) → segments pré-split au boot, recomposés par requête via
67
+ * `cspFor(nonce)` (1 concaténation, aucun parse/regex dans le hot-path).
68
+ *
69
+ * **Deux variantes, composées au boot** (ADR-0024 §C6) : la politique telle
70
+ * qu'écrite, servie en TLS, et la même SANS `upgrade-insecure-requests`, servie
71
+ * en clair — sur une page `http`, la directive réécrirait les sous-ressources en
72
+ * `https`, que le serveur ne sert pas. Sans la directive, les deux variantes sont
73
+ * le MÊME objet : par requête, un booléen choisit, rien de plus.
17
74
  *
18
75
  * @see Fetch Metadata · W3C CSP Level 3 · WHATWG (COOP/COEP/CORP).
19
76
  */
20
77
  var SecurityHeaders = class {
21
- #headers;
22
- #cspParts;
23
- #cspPrefix;
24
- #cspSuffix;
25
- #cspRaw;
26
- constructor(o) {
27
- const h = {};
28
- let cspParts = null;
29
- if (o.csp) {
30
- if (o.cspNonces && o.csp.includes(NONCE_PLACEHOLDER)) cspParts = o.csp.split(NONCE_PLACEHOLDER);
31
- else {
32
- const csp = o.cspNonces ? o.csp : o.csp.replace(/\s*'nonce-\{\{nonce\}\}'/g, "");
33
- if (csp) h["Content-Security-Policy"] = csp;
34
- }
35
- }
36
- if (o.referrerPolicy) h["Referrer-Policy"] = o.referrerPolicy;
37
- if (o.coop) h["Cross-Origin-Opener-Policy"] = o.coop;
38
- if (o.coep) h["Cross-Origin-Embedder-Policy"] = o.coep;
39
- if (o.corp) h["Cross-Origin-Resource-Policy"] = o.corp;
40
- if (o.originAgentCluster) h["Origin-Agent-Cluster"] = "?1";
41
- if (o.permissionsPolicy) h["Permissions-Policy"] = o.permissionsPolicy;
42
- this.#headers = Object.freeze(h);
43
- this.#cspParts = cspParts ? Object.freeze(cspParts) : null;
44
- if (cspParts?.length === 2) {
45
- this.#cspPrefix = cspParts[0] ?? "";
46
- this.#cspSuffix = cspParts[1] ?? "";
47
- } else {
48
- this.#cspPrefix = null;
49
- this.#cspSuffix = "";
50
- }
51
- this.#cspRaw = o.csp;
78
+ #tls;
79
+ #plain;
80
+ #hasNonce;
81
+ #scriptTls;
82
+ #scriptPlain;
83
+ #other;
84
+ #fileTls;
85
+ #filePlain;
86
+ #extraComposed = null;
87
+ /** Prévenu (une fois par jeu de directives) quand un `@Csp` de route est ignoré. */
88
+ #onRejectedExtra;
89
+ /**
90
+ * @param o - options des en-têtes (config `security.headers`, CSP déjà fusionnée).
91
+ * @param onRejectedExtra - appelé quand les directives `@Csp` d'une route sortent
92
+ * de la grammaire CSP3 : la route sert alors la politique de base.
93
+ */
94
+ constructor(o, onRejectedExtra) {
95
+ this.#onRejectedExtra = onRejectedExtra ?? null;
96
+ const common = {};
97
+ if (o.referrerPolicy) common["Referrer-Policy"] = o.referrerPolicy;
98
+ if (o.coop) common["Cross-Origin-Opener-Policy"] = o.coop;
99
+ if (o.coep) common["Cross-Origin-Embedder-Policy"] = o.coep;
100
+ if (o.corp) common["Cross-Origin-Resource-Policy"] = o.corp;
101
+ if (o.originAgentCluster) common["Origin-Agent-Cluster"] = "?1";
102
+ if (o.permissionsPolicy) common["Permissions-Policy"] = o.permissionsPolicy;
103
+ if (o.reportingEndpoints) common["Reporting-Endpoints"] = o.reportingEndpoints;
104
+ for (const name in common) assertHeaderValue(name, common[name] ?? "");
105
+ const nonced = Boolean(o.csp) && o.cspNonces && o.csp.includes(NONCE_PLACEHOLDER);
106
+ this.#hasNonce = nonced;
107
+ this.#tls = buildVariant(o.csp, o.cspNonces, nonced, common);
108
+ const plainCsp = withoutDirective(o.csp, UPGRADE_INSECURE_REQUESTS);
109
+ this.#plain = plainCsp === o.csp ? this.#tls : buildVariant(plainCsp, o.cspNonces, nonced, common);
110
+ const other = {};
111
+ if (o.referrerPolicy) other["Referrer-Policy"] = o.referrerPolicy;
112
+ if (o.corp) other["Cross-Origin-Resource-Policy"] = o.corp;
113
+ const script = { ...other };
114
+ if (o.coep) script["Cross-Origin-Embedder-Policy"] = o.coep;
115
+ if (o.reportingEndpoints) script["Reporting-Endpoints"] = o.reportingEndpoints;
116
+ const scriptCsp = purgeNoncePlaceholder(o.csp);
117
+ this.#scriptTls = freezeTable(scriptCsp, script);
118
+ const scriptPlainCsp = withoutDirective(scriptCsp, UPGRADE_INSECURE_REQUESTS);
119
+ this.#scriptPlain = scriptPlainCsp === scriptCsp ? this.#scriptTls : freezeTable(scriptPlainCsp, script);
120
+ this.#other = freezeTable(narrowCspOf(o.csp), other);
121
+ const fileCsp = purgeNonceForDocument(o.csp);
122
+ this.#fileTls = freezeTable(fileCsp, common);
123
+ const filePlainCsp = withoutDirective(fileCsp, UPGRADE_INSECURE_REQUESTS);
124
+ this.#filePlain = filePlainCsp === fileCsp ? this.#fileTls : freezeTable(filePlainCsp, common);
125
+ }
126
+ /**
127
+ * Table d'en-têtes figée d'un DOCUMENT servi tel quel — fichier statique,
128
+ * réponse relayée — dont le corps ne passe par aucun gabarit et ne peut donc
129
+ * pas porter de nonce (ADR-0024 §C1) : politique complète sans nonce ni
130
+ * `'strict-dynamic'` orphelin, et tous les en-têtes d'un document.
131
+ *
132
+ * @param tls - la page est servie en `https` (garde `upgrade-insecure-requests`).
133
+ * @returns la table à poser telle quelle.
134
+ */
135
+ headersForServedFile(tls) {
136
+ return tls ? this.#fileTls : this.#filePlain;
52
137
  }
53
- /** Table d'en-têtes applicatifs CONSTANTS (figée), posée telle quelle par le firewall. */
138
+ /**
139
+ * Table d'en-têtes figée d'une réponse qui n'est pas un document (ADR-0024
140
+ * §C11) : JavaScript (politique complète sans nonce, Referrer-Policy, COEP,
141
+ * CORP) ou reste (politique courte `default-src 'none'`, Referrer-Policy,
142
+ * CORP). Ni l'une ni l'autre ne porte de nonce ; COOP, Origin-Agent-Cluster
143
+ * et Permissions-Policy, lus sur un document seulement, n'y figurent pas.
144
+ *
145
+ * @param kind - famille de la réponse (cf {@link responseKindOf}).
146
+ * @param tls - la page est servie en `https` (garde `upgrade-insecure-requests`).
147
+ * @returns la table à poser telle quelle.
148
+ */
149
+ headersForResource(kind, tls) {
150
+ if (kind === "other") return this.#other;
151
+ return tls ? this.#scriptTls : this.#scriptPlain;
152
+ }
153
+ /** Table d'en-têtes applicatifs CONSTANTS (figée) d'une réponse en clair. */
54
154
  get headers() {
55
- return this.#headers;
155
+ return this.#plain.headers;
156
+ }
157
+ /**
158
+ * Table constante SANS la CSP statique d'une réponse en clair — pour une
159
+ * réponse qui pose sa propre politique (route `@Csp`), afin que la CSP ne
160
+ * soit écrite qu'une fois.
161
+ */
162
+ get headersWithoutCsp() {
163
+ return this.#plain.headersWithoutCsp;
164
+ }
165
+ /**
166
+ * Table d'en-têtes constants selon le transport de la réponse.
167
+ *
168
+ * @param tls - la page est servie en `https` (proxy de confiance compris).
169
+ * @param withoutCsp - omettre la CSP statique (route `@Csp` qui pose la sienne).
170
+ * @returns la table figée à poser telle quelle.
171
+ */
172
+ headersFor(tls, withoutCsp = false) {
173
+ const variant = tls ? this.#tls : this.#plain;
174
+ return withoutCsp ? variant.headersWithoutCsp : variant.headers;
56
175
  }
57
176
  /** `true` si le CSP est recomposé par requête avec un nonce (→ `cspFor`). */
58
177
  get hasNonce() {
59
- return this.#cspParts !== null;
178
+ return this.#hasNonce;
60
179
  }
61
180
  /**
62
181
  * Recompose le CSP en injectant le `nonce` de la requête aux emplacements
@@ -65,11 +184,14 @@ var SecurityHeaders = class {
65
184
  * `'`/`;`/espace) → aucune évasion possible du token CSP.
66
185
  *
67
186
  * @param nonce - nonce base64 de la requête (`Context.cspNonce`).
187
+ * @param tls - la page est servie en `https` : la politique garde
188
+ * `upgrade-insecure-requests`. Défaut : en clair.
68
189
  * @returns la valeur `Content-Security-Policy` à poser sur la réponse.
69
190
  */
70
- cspFor(nonce) {
71
- if (this.#cspPrefix !== null) return this.#cspPrefix + nonce + this.#cspSuffix;
72
- return this.#cspParts.join(nonce);
191
+ cspFor(nonce, tls = false) {
192
+ const variant = tls ? this.#tls : this.#plain;
193
+ if (variant.prefix !== null) return variant.prefix + nonce + variant.suffix;
194
+ return variant.parts.join(nonce);
73
195
  }
74
196
  /**
75
197
  * Recompose le CSP en fusionnant ADDITIVEMENT les directives `extra` d'une
@@ -80,11 +202,182 @@ var SecurityHeaders = class {
80
202
  *
81
203
  * @param nonce - nonce base64 de la requête (ignoré si le CSP n'a pas de nonce).
82
204
  * @param extra - directives additionnelles de la route (`directive → sources`).
205
+ * @param tls - la page est servie en `https`. Défaut : en clair.
83
206
  * @returns la valeur `Content-Security-Policy` à poser sur la réponse.
84
207
  */
85
- cspForExtra(nonce, extra) {
86
- const merged = mergeCspFragments(this.#cspRaw, [extra]);
87
- return this.#cspParts !== null ? merged.split(NONCE_PLACEHOLDER).join(nonce) : merged.replace(/\s*'nonce-\{\{nonce\}\}'/g, "");
208
+ cspForExtra(nonce, extra, tls = false) {
209
+ let pair = this.#extraComposed?.get(extra);
210
+ if (pair === void 0) {
211
+ pair = this.#composeExtra(extra);
212
+ (this.#extraComposed ??= /* @__PURE__ */ new WeakMap()).set(extra, pair);
213
+ }
214
+ const composed = tls ? pair.tls : pair.plain;
215
+ if (composed.tail !== null) return composed.head + nonce + composed.tail;
216
+ return composed.parts !== null ? composed.parts.join(nonce) : composed.head;
217
+ }
218
+ /** Compose les deux variantes d'une politique `@Csp` (une fois par route). */
219
+ #composeExtra(extra) {
220
+ let merged;
221
+ try {
222
+ merged = mergeCspFragments(this.#tls.raw, [extra]);
223
+ } catch (error) {
224
+ merged = this.#tls.raw;
225
+ this.#onRejectedExtra?.(error);
226
+ }
227
+ const tls = this.#composeAround(merged);
228
+ const plainCsp = withoutDirective(merged, UPGRADE_INSECURE_REQUESTS);
229
+ return {
230
+ tls,
231
+ plain: plainCsp === merged ? tls : this.#composeAround(plainCsp)
232
+ };
233
+ }
234
+ /**
235
+ * Nonce actif → découpe autour de `{{nonce}}` ; sinon purge un résiduel
236
+ * `'nonce-{{nonce}}'` (placeholder non substitué = CSP cassé).
237
+ */
238
+ #composeAround(csp) {
239
+ return this.#hasNonce ? composeAroundNonce(csp.split(NONCE_PLACEHOLDER)) : {
240
+ head: purgeNonceForDocument(csp),
241
+ tail: null,
242
+ parts: null
243
+ };
88
244
  }
89
245
  };
90
- export { SecurityHeaders, SecurityHeaders as default };
246
+ /**
247
+ * Compose une variante : table d'en-têtes figée (CSP statique comprise si elle
248
+ * n'a pas de nonce) et segments de la CSP autour du nonce.
249
+ *
250
+ * @throws BootConfigurationError si la CSP ne peut pas être portée par un en-tête.
251
+ */
252
+ function buildVariant(csp, cspNonces, nonced, common) {
253
+ let parts = null;
254
+ let staticCsp = "";
255
+ if (csp) {
256
+ if (nonced) {
257
+ parts = csp.split(NONCE_PLACEHOLDER);
258
+ assertHeaderValue("Content-Security-Policy", parts.join(NONCE_SAMPLE));
259
+ } else {
260
+ staticCsp = cspNonces ? csp : purgeNonceForDocument(csp);
261
+ if (staticCsp) assertHeaderValue("Content-Security-Policy", staticCsp);
262
+ }
263
+ }
264
+ const headersWithoutCsp = Object.freeze({ ...common });
265
+ const headers = staticCsp ? Object.freeze({
266
+ "Content-Security-Policy": staticCsp,
267
+ ...common
268
+ }) : headersWithoutCsp;
269
+ const two = parts?.length === 2;
270
+ return {
271
+ headers,
272
+ headersWithoutCsp,
273
+ parts: parts ? Object.freeze(parts) : null,
274
+ prefix: two ? parts?.[0] ?? "" : null,
275
+ suffix: two ? parts?.[1] ?? "" : "",
276
+ raw: csp
277
+ };
278
+ }
279
+ /**
280
+ * Table figée : la politique (validée) en tête, puis les en-têtes communs.
281
+ *
282
+ * @throws BootConfigurationError si la politique ne peut pas être portée par un en-tête.
283
+ */
284
+ function freezeTable(csp, common) {
285
+ if (!csp) return Object.freeze({ ...common });
286
+ assertHeaderValue("Content-Security-Policy", csp);
287
+ return Object.freeze({
288
+ "Content-Security-Policy": csp,
289
+ ...common
290
+ });
291
+ }
292
+ /**
293
+ * Politique courte d'une réponse qui n'est ni document ni script :
294
+ * `default-src 'none'`, plus le `frame-ancestors` de la politique complète. Une
295
+ * politique complète vide (CSP coupée par l'application) n'en donne aucune.
296
+ */
297
+ function narrowCspOf(csp) {
298
+ if (!csp.trim()) return "";
299
+ for (const [name, sources] of parseCsp(csp)) if (name.toLowerCase() === "frame-ancestors") {
300
+ const list = sources.length > 0 ? sources.join(" ") : "'none'";
301
+ return `${NARROW_CSP}; frame-ancestors ${list}`;
302
+ }
303
+ return NARROW_CSP;
304
+ }
305
+ const STRICT_DYNAMIC = "'strict-dynamic'";
306
+ /** Source qui désigne un script par nonce ou par empreinte (W3C CSP3 § source-list). */
307
+ const NONCE_OR_HASH = /^'(?:nonce|sha256|sha384|sha512)-/i;
308
+ /**
309
+ * Retire le nonce d'une politique de DOCUMENT : `'nonce-{{nonce}}'` part, et
310
+ * `'strict-dynamic'` avec lui dans toute directive restée sans nonce ni hash.
311
+ * Sous `'strict-dynamic'`, un navigateur CSP3 ignore `'self'` et les hôtes
312
+ * (`w3c-csp3.bs` § « Authoring Considerations ») : la page ne pourrait plus
313
+ * exécuter un seul `<script>` (ADR-0024 §C1). Une politique sans marqueur —
314
+ * écrite ainsi par l'application — est rendue telle quelle.
315
+ *
316
+ * La politique d'un script (worker) n'y passe pas : ce qu'un worker charge
317
+ * n'est pas « inséré par l'analyseur », et `'strict-dynamic'` l'autorise.
318
+ */
319
+ function purgeNonceForDocument(csp) {
320
+ const purged = purgeNoncePlaceholder(csp);
321
+ if (purged === csp || !purged.includes(STRICT_DYNAMIC)) return purged;
322
+ const directives = parseCsp(purged);
323
+ let changed = false;
324
+ for (const entry of directives) {
325
+ const sources = entry[1];
326
+ if (!sources.includes(STRICT_DYNAMIC)) continue;
327
+ if (sources.some((s) => NONCE_OR_HASH.test(s))) continue;
328
+ entry[1] = sources.filter((s) => s !== STRICT_DYNAMIC);
329
+ changed = true;
330
+ }
331
+ return changed ? serializeCsp(directives) : purged;
332
+ }
333
+ /** Jeton CSP qui porte le marqueur, retiré tel quel d'une CSP sans nonce. */
334
+ const NONCE_TOKEN = `'nonce-${NONCE_PLACEHOLDER}'`;
335
+ /**
336
+ * Retire chaque token `'nonce-{{nonce}}'` d'une CSP sans nonce, avec les blancs
337
+ * qui le précèdent. Découpe + `trimEnd` plutôt qu'une regex `\s*…` : celle-ci
338
+ * coûte O(n²) sur une longue suite de blancs (CodeQL `js/polynomial-redos`).
339
+ */
340
+ function purgeNoncePlaceholder(csp) {
341
+ const parts = csp.split(NONCE_TOKEN);
342
+ if (parts.length === 1) return csp;
343
+ const last = parts.length - 1;
344
+ let out = "";
345
+ for (let i = 0; i < last; i++) out += (parts[i] ?? "").trimEnd();
346
+ return out + (parts[last] ?? "");
347
+ }
348
+ /** Nonce témoin pour valider le gabarit : même alphabet que `nextCspNonce` (base64). */
349
+ const NONCE_SAMPLE = "AAAAAAAAAAAAAAAAAAAAAA==";
350
+ /**
351
+ * Refuse au démarrage une valeur qu'un en-tête HTTP ne peut pas porter.
352
+ *
353
+ * @throws BootConfigurationError en nommant l'en-tête et la raison de Node.
354
+ */
355
+ function assertHeaderValue(name, value) {
356
+ try {
357
+ validateHeaderValue(name, value);
358
+ } catch (error) {
359
+ throw new BootConfigurationError(`[@nodefony/security] en-tête « ${name} » refusé : ${error instanceof Error ? error.message : String(error)}`);
360
+ }
361
+ }
362
+ /** Range les segments d'une politique découpée autour de `{{nonce}}`. */
363
+ function composeAroundNonce(parts) {
364
+ if (parts.length === 2) {
365
+ const [head = "", tail = ""] = parts;
366
+ return {
367
+ head,
368
+ tail,
369
+ parts: null
370
+ };
371
+ }
372
+ if (parts.length === 1) return {
373
+ head: parts[0] ?? "",
374
+ tail: null,
375
+ parts: null
376
+ };
377
+ return {
378
+ head: "",
379
+ tail: null,
380
+ parts: Object.freeze(parts)
381
+ };
382
+ }
383
+ export { SecurityHeaders, SecurityHeaders as default, responseKindOf };
@@ -1,15 +1,15 @@
1
- import { JwtKeystore } from "../src/token/JwtKeystore.js";
2
- import { ScopeGrantPolicy, refusedScopes } from "../src/token/scopeGrants.js";
3
- import { InsecureTransportError } from "../errors/InsecureTransportError.js";
1
+ import { defineSecurityConfig } from "../config/defineModuleConfig.js";
4
2
  import { AuthenticationError } from "../errors/AuthenticationError.js";
5
3
  import { ThrottledError } from "../errors/ThrottledError.js";
6
- import { defineSecurityConfig } from "../config/defineModuleConfig.js";
7
- import { ACCOUNT_ID_KEY, CREDENTIALS_VERSION_KEY, credentialsVersionOf, isBoundToAccount } from "../src/accountBinding.js";
8
- import { defaultAudience, resolveJwtRuntime } from "../src/token/jwtRuntime.js";
9
- import { recordAudit } from "../src/audit/recordAudit.js";
10
- import { readAuditContext } from "../src/audit/readAuditContext.js";
11
4
  import { InvalidTargetError } from "../errors/InvalidTargetError.js";
5
+ import { InsecureTransportError } from "../errors/InsecureTransportError.js";
6
+ import { readAuditContext } from "../src/audit/readAuditContext.js";
12
7
  import { getTokenStoreFactory, listTokenStores } from "../src/token/tokenStoreRegistry.js";
8
+ import { recordAudit } from "../src/audit/recordAudit.js";
9
+ import { ACCOUNT_ID_KEY, CREDENTIALS_VERSION_KEY, credentialsVersionOf, isBoundToAccount } from "../src/accountBinding.js";
10
+ import { JwtKeystore } from "../src/token/JwtKeystore.js";
11
+ import { ScopeGrantPolicy, refusedScopes } from "../src/token/scopeGrants.js";
12
+ import { defaultAudience, resolveJwtRuntime } from "../src/token/jwtRuntime.js";
13
13
  import { AUTO_STORE, BootConfigurationError, EMPTY_INFRA, GcScheduler, Service, canonicalIssuer, durableStoreRemedy, readStoreConnector, readStoreLocation, resolveAutoStore, storeConnectivity } from "nodefony";
14
14
  import { createHash, randomBytes, randomUUID } from "node:crypto";
15
15
  // nodefony/service/tokenService.ts
@@ -1,9 +1,9 @@
1
1
  import { defineSecurityConfig } from "../config/defineModuleConfig.js";
2
- import { recordAudit } from "../src/audit/recordAudit.js";
3
- import { readAuditContext } from "../src/audit/readAuditContext.js";
4
2
  import { getTotpStoreFactory, listTotpStores } from "../src/totp/totpSecretStoreRegistry.js";
5
- import { generateEphemeralKey } from "../src/crypto/secretCipher.js";
6
3
  import { deriveTotpKey } from "../src/totp/totpCipher.js";
4
+ import { recordAudit } from "../src/audit/recordAudit.js";
5
+ import { generateEphemeralKey } from "../src/crypto/secretCipher.js";
6
+ import { readAuditContext } from "../src/audit/readAuditContext.js";
7
7
  import { beginTotpEnrollment, confirmTotpEnrollment, disableTotp, totpStatus, verifyTotpLogin } from "../src/totp/totpOperations.js";
8
8
  import { AUTO_STORE, EMPTY_INFRA, Service, deriveStoreBackend, durableStoreRemedy, readStoreConnector, readStoreLocation, resolveAutoStore, storeConnectivity } from "nodefony";
9
9
  // nodefony/service/totp.ts
@@ -1,5 +1,5 @@
1
- import { AuthenticationError } from "../errors/AuthenticationError.js";
2
1
  import { defineSecurityConfig } from "../config/defineModuleConfig.js";
2
+ import { AuthenticationError } from "../errors/AuthenticationError.js";
3
3
  import { WebAuthnError } from "../errors/WebAuthnError.js";
4
4
  import { getWebAuthnStoreFactory, listWebAuthnStores } from "../src/webauthn/webAuthnCredentialStoreRegistry.js";
5
5
  import { AUTO_STORE, EMPTY_INFRA, Service, deriveStoreBackend, durableStoreRemedy, readStoreConnector, readStoreLocation, resolveAutoStore, storeConnectivity } from "nodefony";
@@ -1,15 +1,15 @@
1
- import { defineSecurityConfig } from "../config/defineModuleConfig.js";
2
- import { decryptSecret, encryptSecret, generateEphemeralKey } from "../src/crypto/secretCipher.js";
3
1
  import { WEBHOOK_FACETS } from "../src/webhook/webhookFilters.js";
2
+ import { decryptSecret, encryptSecret, generateEphemeralKey } from "../src/crypto/secretCipher.js";
3
+ import { defineSecurityConfig } from "../config/defineModuleConfig.js";
4
4
  import { getWebhookStoreFactory, listWebhookStores } from "../src/webhook/webhookStoreRegistry.js";
5
5
  import { deriveWebhookKey } from "../src/webhook/webhookCipher.js";
6
6
  import { assertPublicUrl } from "../src/net/ssrfGuard.js";
7
- import { WebhookDispatcher } from "../src/webhook/WebhookDispatcher.js";
7
+ import { WebhookDispatcher, assertSubscriptionPatterns } from "../src/webhook/WebhookDispatcher.js";
8
8
  import { deliverWebhook } from "../src/webhook/webhookDelivery.js";
9
9
  import { AUTO_STORE, EMPTY_INFRA, Service, countFacets, deriveStoreBackend, durableStoreRemedy, readStoreConnector, readStoreLocation, resolveAutoStore, storeConnectivity } from "nodefony";
10
10
  import { randomBytes } from "node:crypto";
11
- import { Buffer } from "node:buffer";
12
11
  import { schemaMismatchOf } from "@nodefony/http";
12
+ import { Buffer } from "node:buffer";
13
13
  // nodefony/service/webhooks.ts
14
14
  const serviceName = "webhooks";
15
15
  /** Historique de livraisons gardé PAR endpoint (ring borné, RAM, par pod). */
@@ -107,6 +107,7 @@ var WebhookService = class extends Service {
107
107
  const dispatcher = new WebhookDispatcher({
108
108
  endpointCount: () => this.endpointCount(),
109
109
  getSnapshot: () => this.getSnapshot(),
110
+ currentEndpoint: (id) => this.#endpoints?.get(id) ?? null,
110
111
  secretOf: (ep) => this.decryptEndpointSecret(ep).toString("utf8"),
111
112
  policy: this.getDeliveryPolicy(),
112
113
  resolveTarget: (url) => this.#resolveTarget(url),
@@ -261,8 +262,13 @@ var WebhookService = class extends Service {
261
262
  * (la seule occasion de le lire pour le copier).
262
263
  *
263
264
  * @throws SsrfError si l'URL est invalide / cible non publique.
265
+ * @throws Error si un motif de `events` porte plus de trois `*` (ADR-0023 W1).
266
+ * @throws Error si `tenantId` est rempli : le périmètre d'un locataire n'est pas
267
+ * encore appliqué à la livraison (ADR-0023 W2) — l'accepter le rendrait trompeur.
264
268
  */
265
269
  async register(input) {
270
+ if (input.tenantId != null) throw new Error("webhooks: tenantId n'est pas encore pris en charge — le multi-tenant n'existe pas, et un abonnement rattaché à un locataire recevrait tout. Laisser tenantId absent ou null.");
271
+ assertSubscriptionPatterns(input.events);
266
272
  const { store, key, config } = this.#assertReady();
267
273
  await this.#assertSafeUrl(config, input.url);
268
274
  const secret = generateSecret();
@@ -342,6 +348,7 @@ var WebhookService = class extends Service {
342
348
  * ou `null` si absent.
343
349
  */
344
350
  async update(id, patch) {
351
+ if (patch.events !== void 0) assertSubscriptionPatterns(patch.events);
345
352
  const { store, config } = this.#assertReady();
346
353
  const current = await store.findById(id);
347
354
  if (!current) return null;
@@ -155,6 +155,9 @@ function toCredentialView(c) {
155
155
  * - `GET /nodefony/security/api/firewall` — introspection du firewall (zones,
156
156
  * authenticators montés, défenses) — état RUNTIME, secrets exclus.
157
157
  * - `GET /nodefony/security/api/roleHierarchy` — hiérarchie de rôles + résolution.
158
+ * - `GET /nodefony/security/api/csp-reports` — violations CSP reçues (ADR-0024
159
+ * §C7), dédoublonnées, la plus récente d’abord ; champs écrits par le
160
+ * navigateur, donc à afficher comme du texte.
158
161
  * - `GET /nodefony/security/api/apikeys` — toutes les clés API (gouvernance),
159
162
  * `GET …/apikeys/status` — backend du token store (« où on écrit »),
160
163
  * `POST …/apikeys/{id}/revoke` — révocation par id (réponse à incident).
@@ -222,6 +225,22 @@ function createSecurityAdminApi(container) {
222
225
  };
223
226
  }
224
227
  },
228
+ {
229
+ path: "csp-reports",
230
+ method: "GET",
231
+ role: "ROLE_NODEFONY_ADMIN",
232
+ summary: "Violations de la politique CSP rapportées par les navigateurs (dédoublonnées, URL expurgées, extrait jamais gardé). Champs écrits par le navigateur — à afficher comme du texte.",
233
+ handler: () => {
234
+ return container.get("cspReports")?.snapshot() ?? {
235
+ enabled: false,
236
+ endpoint: null,
237
+ capacity: 0,
238
+ received: 0,
239
+ rejected: 0,
240
+ items: []
241
+ };
242
+ }
243
+ },
225
244
  {
226
245
  path: "roleHierarchy",
227
246
  method: "GET",
@@ -1,8 +1,8 @@
1
1
  import { AuthenticationError } from "../../errors/AuthenticationError.js";
2
2
  import { isBoundToAccount } from "../accountBinding.js";
3
3
  import { UserToken } from "../token/UserToken.js";
4
- import { bearerToken } from "./bearer.js";
5
4
  import { looksLikeApiKey, parseApiKey } from "../apikey/apiKeyFormat.js";
5
+ import { bearerToken } from "./bearer.js";
6
6
  // nodefony/src/authenticator/ApiKeyAuthenticator.ts
7
7
  const INVALID_TOKEN = "Invalid token";
8
8
  /**
@@ -1,7 +1,7 @@
1
- import { compileProviderRoleMapping, mapProviderRoles } from "../oauth/providerRoles.js";
2
1
  import { AuthenticationError } from "../../errors/AuthenticationError.js";
3
2
  import { UnverifiableTokenError } from "../../errors/UnverifiableTokenError.js";
4
3
  import { localIdentifierFor } from "./externalSubject.js";
4
+ import { compileProviderRoleMapping, mapProviderRoles } from "../oauth/providerRoles.js";
5
5
  import { canonicalIssuer } from "nodefony";
6
6
  import { BaseUser } from "@nodefony/user";
7
7
  // nodefony/src/authenticator/ExternalSubjectResolver.ts
@@ -11,6 +11,25 @@ import { BaseUser } from "@nodefony/user";
11
11
  */
12
12
  const INVALID_TOKEN = "Invalid token";
13
13
  /**
14
+ * Les utilisateurs FABRIQUÉS en mode `ephemeral` — aucun compte local derrière.
15
+ * Faible : l'entrée meurt avec l'utilisateur, à la fin de sa requête ou de sa
16
+ * socket ; rien n'est à nettoyer.
17
+ */
18
+ const EPHEMERAL_SUBJECTS = /* @__PURE__ */ new WeakSet();
19
+ /**
20
+ * Cet utilisateur a-t-il été fabriqué pour un appelant machine SANS compte local (mode `ephemeral`) ?
21
+ *
22
+ * L'identité d'admission temps réel en tire `account: false` : rien ne
23
+ * pourrait relire cet appelant par son `id` (`<émetteur>#<sujet>`), et un
24
+ * abonnement durable ne doit pas s'y adosser (ADR-0021 §S10 point 6). Lu sur
25
+ * le FAIT (l'objet vient du résolveur), jamais déduit de la forme de `id`.
26
+ *
27
+ * @param user - utilisateur résolu par le pare-feu.
28
+ */
29
+ function isEphemeralSubject(user) {
30
+ return EPHEMERAL_SUBJECTS.has(user);
31
+ }
32
+ /**
14
33
  * Les options de rattachement des sujets externes, lues dans la configuration
15
34
  * de sécurité — UNE lecture, pour la zone `external-jwt` comme pour le
16
35
  * résolveur de compte d'une ressource protégée.
@@ -105,12 +124,17 @@ var ExternalSubjectResolver = class {
105
124
  async resolve(issuer, subject, claims, area) {
106
125
  const mapping = this.#issuers.get(issuer);
107
126
  if (mapping === void 0) throw new UnverifiableTokenError(`émetteur « ${issuer} » vérifié mais absent de la table de rattachement des sujets — configuration incohérente.`);
127
+ if ((area?.subjectPolicy ?? this.#policy) === "ephemeral") {
128
+ const identifier = localIdentifierFor(issuer, subject, "prefixed");
129
+ const ephemeral = new BaseUser({
130
+ id: identifier,
131
+ identifier,
132
+ roles: [...area?.ephemeralRoles ?? this.#ephemeralRoles]
133
+ });
134
+ EPHEMERAL_SUBJECTS.add(ephemeral);
135
+ return ephemeral;
136
+ }
108
137
  const identifier = localIdentifierFor(issuer, subject, mapping);
109
- if ((area?.subjectPolicy ?? this.#policy) === "ephemeral") return new BaseUser({
110
- id: identifier,
111
- identifier,
112
- roles: [...area?.ephemeralRoles ?? this.#ephemeralRoles]
113
- });
114
138
  const provider = this.#resolveUserProvider();
115
139
  let user = null;
116
140
  const linked = this.#linkedProviders?.get(issuer);
@@ -177,4 +201,4 @@ function linkProviders(trusted, providers) {
177
201
  for (const issuer of ambiguous) links.delete(issuer);
178
202
  return links.size > 0 ? links : null;
179
203
  }
180
- export { ExternalSubjectResolver, externalSubjectOptions };
204
+ export { ExternalSubjectResolver, externalSubjectOptions, isEphemeralSubject };