@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,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
@@ -47,9 +47,14 @@ var TokenService = class extends Service {
47
47
  constructor(module) {
48
48
  super(serviceName, module.container, module.notificationsCenter, module.options);
49
49
  this.module = module;
50
- const tokenServiceBoot = () => this.#build();
50
+ const tokenServiceBoot = () => {
51
+ try {
52
+ this.#build();
53
+ } finally {
54
+ this.kernel?.once("onTerminate", () => this.#shutdown());
55
+ }
56
+ };
51
57
  this.kernel?.once("onBoot", tokenServiceBoot);
52
- this.kernel?.once("onTerminate", () => this.#shutdown());
53
58
  }
54
59
  /**
55
60
  * Refuse de SERVIR en production sans clé de signature partagée.
@@ -130,7 +135,7 @@ var TokenService = class extends Service {
130
135
  this.container?.set("tokenStore", this.#store);
131
136
  if (jwtEnabled) {
132
137
  this.#requireSharedSigningKey(config.jwt.keystore);
133
- this.#runtime = resolveJwtRuntime(config.jwt);
138
+ this.#runtime = resolveJwtRuntime(config.jwt, config.areas);
134
139
  this.#keystore = new JwtKeystore(config.jwt.keystore, (m, s) => this.log(m, s));
135
140
  this.container?.set("jwtKeystore", this.#keystore);
136
141
  this.#resolvePublication(config.jwt.jwks, this.#runtime.issuer);
@@ -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
@@ -36,8 +36,13 @@ var TotpService = class extends Service {
36
36
  constructor(module) {
37
37
  super(serviceName, module.container, module.notificationsCenter, module.options);
38
38
  this.module = module;
39
- this.kernel?.once("onBoot", () => this.#build());
40
- this.kernel?.once("onTerminate", () => void this.#shutdown());
39
+ this.kernel?.once("onBoot", () => {
40
+ try {
41
+ this.#build();
42
+ } finally {
43
+ this.kernel?.once("onTerminate", () => this.#shutdown());
44
+ }
45
+ });
41
46
  }
42
47
  #build() {
43
48
  let config;
@@ -1,8 +1,9 @@
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";
6
+ import { domainToASCII } from "node:url";
6
7
  import { createHash } from "node:crypto";
7
8
  import { Buffer } from "node:buffer";
8
9
  // nodefony/service/webAuthn.ts
@@ -59,12 +60,19 @@ var WebAuthnService = class extends Service {
59
60
  #store = null;
60
61
  #rpID = "localhost";
61
62
  #rpName = "Nodefony";
63
+ /** Origines de la config, en ASCII (punycode) — résolues au boot. */
64
+ #origins = [];
62
65
  #ready = false;
63
66
  constructor(module) {
64
67
  super(serviceName, module.container, module.notificationsCenter, module.options);
65
68
  this.module = module;
66
- this.kernel?.once("onBoot", () => this.#build());
67
- this.kernel?.once("onTerminate", () => void this.#shutdown());
69
+ this.kernel?.once("onBoot", () => {
70
+ try {
71
+ this.#build();
72
+ } finally {
73
+ this.kernel?.once("onTerminate", () => this.#shutdown());
74
+ }
75
+ });
68
76
  }
69
77
  #build() {
70
78
  let config;
@@ -79,7 +87,11 @@ var WebAuthnService = class extends Service {
79
87
  }
80
88
  this.#config = config;
81
89
  const rawDomain = config.passkeys.rpId ?? (this.kernel?.domain || "localhost");
82
- this.#rpID = /^(\d{1,3}\.){3}\d{1,3}$|:/.test(rawDomain) ? "localhost" : rawDomain;
90
+ const isIp = /^(\d{1,3}\.){3}\d{1,3}$|:/.test(rawDomain);
91
+ const asciiDomain = isIp ? "" : domainToASCII(rawDomain);
92
+ if (!isIp && asciiDomain === "") this.log(`passkeys : domaine « ${rawDomain} » invalide comme RP ID — repli sur « localhost »`, "ERROR");
93
+ this.#rpID = asciiDomain === "" ? "localhost" : asciiDomain;
94
+ this.#origins = asciiOrigins(config.passkeys.origins, (bad) => this.log(`passkeys.origins : « ${bad} » n'est pas une origine (scheme://hôte[:port]) — écartée`, "ERROR"));
83
95
  this.#rpName = config.passkeys.rpName ?? "Nodefony";
84
96
  const existing = this.get("webAuthnCredentialStore");
85
97
  let resolved;
@@ -195,7 +207,7 @@ var WebAuthnService = class extends Service {
195
207
  verification = await lib.verifyRegistrationResponse({
196
208
  response,
197
209
  expectedChallenge,
198
- expectedOrigin: this.#expectedOrigin(pk.origins, requestOrigin),
210
+ expectedOrigin: this.#expectedOrigin(this.#origins, requestOrigin),
199
211
  expectedRPID: this.#rpID,
200
212
  requireUserVerification: pk.userVerification === "required"
201
213
  });
@@ -268,7 +280,7 @@ var WebAuthnService = class extends Service {
268
280
  verification = await lib.verifyAuthenticationResponse({
269
281
  response,
270
282
  expectedChallenge,
271
- expectedOrigin: this.#expectedOrigin(pk.origins, requestOrigin),
283
+ expectedOrigin: this.#expectedOrigin(this.#origins, requestOrigin),
272
284
  expectedRPID: this.#rpID,
273
285
  credential: {
274
286
  id: stored.id,
@@ -365,4 +377,31 @@ var WebAuthnService = class extends Service {
365
377
  };
366
378
  }
367
379
  };
368
- export { WebAuthnService, WebAuthnService as default, userHandleBytes };
380
+ /**
381
+ * Origines WebAuthn rendues en ASCII (punycode), forme sous laquelle le
382
+ * navigateur les présente — `https://café.example` → `https://xn--caf-dma.example`.
383
+ *
384
+ * Une entrée qui n'est pas une origine (`scheme://hôte[:port]`) est écartée et
385
+ * signalée : la garder ferait échouer CHAQUE cérémonie, sans que personne ne
386
+ * sache pourquoi.
387
+ *
388
+ * @param origins - origines telles qu'écrites dans la config.
389
+ * @param onInvalid - appelé pour chaque entrée écartée.
390
+ * @returns les origines valides, sérialisées en ASCII, dans l'ordre reçu.
391
+ */
392
+ function asciiOrigins(origins, onInvalid) {
393
+ const out = [];
394
+ for (const origin of origins) {
395
+ let ascii;
396
+ try {
397
+ const serialized = new URL(origin).origin;
398
+ ascii = serialized === "null" ? null : serialized;
399
+ } catch {
400
+ ascii = null;
401
+ }
402
+ if (ascii === null) onInvalid(origin);
403
+ else out.push(ascii);
404
+ }
405
+ return out;
406
+ }
407
+ export { WebAuthnService, WebAuthnService as default, asciiOrigins, userHandleBytes };
@@ -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;