@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,9 +1,15 @@
1
- import { AuthenticationError } from "../../errors/AuthenticationError.js";
2
1
  import { isBoundToAccount } from "../accountBinding.js";
3
- import { UserRealtimeToken } from "../realtime/UserRealtimeToken.js";
2
+ import { AuthenticationError } from "../../errors/AuthenticationError.js";
3
+ import { LOCAL_ACCOUNT, UserRealtimeToken } from "../realtime/UserRealtimeToken.js";
4
+ import { isEphemeralSubject } from "./ExternalSubjectResolver.js";
5
+ import { tokenStatusOf } from "../token/tokenStatus.js";
4
6
  import { Nodefony, RequestContext } from "nodefony";
5
7
  import { authenticatedUser } from "@nodefony/user";
6
8
  // nodefony/src/authenticator/FirewallRealtimeAuthenticator.ts
9
+ /** Le verdict booléen d'un état : seul `active` fait vivre l'identité. */
10
+ function toValidity(status) {
11
+ return async (nowMs) => await status(nowMs) === "active";
12
+ }
7
13
  /**
8
14
  * Authenticator realtime des identités résolues par le **firewall** — équivalent
9
15
  * WS de tout ce que le pipeline HTTP sait authentifier.
@@ -90,9 +96,10 @@ var FirewallRealtimeAuthenticator = class {
90
96
  if (user === null) throw new AuthenticationError("Invalid realtime session");
91
97
  const issued = readFirewallToken();
92
98
  const type = issuedType(issued);
93
- const revalidate = this.#revalidatorFor(user, issued, type);
99
+ const status = type === "session" ? null : this.#buildBearerStatus(issued, user.identifier, type);
100
+ const revalidate = status === null ? buildSessionRevalidator(user.identifier) : toValidity(status);
94
101
  const scopes = issued ? issued.getScopes() : void 0;
95
- return new UserRealtimeToken(user, revalidate, type, scopes, this.#accountConfirmationFor(user, type));
102
+ return new UserRealtimeToken(user, revalidate, type, scopes, this.#accountConfirmationFor(user, type), status, admissionSourceOf(issued, user));
96
103
  }
97
104
  /**
98
105
  * Le revalidateur de l'identité que le firewall a posée sur la requête
@@ -140,46 +147,58 @@ var FirewallRealtimeAuthenticator = class {
140
147
  };
141
148
  }
142
149
  #revalidatorFor(user, issued, type) {
143
- return type === "session" ? buildSessionRevalidator(user.identifier) : this.#buildBearerRevalidator(issued, user.identifier, type);
150
+ return type === "session" ? buildSessionRevalidator(user.identifier) : toValidity(this.#buildBearerStatus(issued, user.identifier, type));
144
151
  }
145
152
  /**
146
- * Re-validation d'une identité portée par un **jeton** (JWT, clé API, OAuth) :
147
- * rejoue les trois conditions qui tuent un jeton, sans jamais parler de session.
153
+ * État d'une identité portée par un **jeton** (JWT, clé API, OAuth) : rejoue les
154
+ * trois conditions qui tuent un jeton, sans jamais parler de session, et en rend
155
+ * la CAUSE — `expired` ferme la socket en 4006, `revoked` en 4001 (ADR-0021 §S11).
156
+ *
157
+ * **Révoqué l'emporte sur expiré** : la liste de refus est interrogée AVANT de
158
+ * conclure à l'expiration, et l'ordre est celui de `tokenStatusOf`, appelé
159
+ * plutôt que recopié. Un jeton révoqué puis arrivé à `exp` ferme donc en 4001.
148
160
  *
149
161
  * `exp` est vérifié **sans personne** (la borne voyage dans le jeton) ; le store
150
162
  * n'ajoute que la révocation *avant terme*. C'est pourquoi un store en panne ne
151
163
  * coupe pas les sockets d'agents : ce serait une panne de disponibilité déguisée
152
164
  * en mesure de sécurité, alors que la borne du jeton tient toujours.
153
165
  */
154
- #buildBearerRevalidator(issued, identifier, type) {
155
- const claims = issued.getAttribute("claims") ?? EMPTY_CLAIMS;
166
+ #buildBearerStatus(issued, identifier, type) {
167
+ const claims = issued?.getAttribute("claims") ?? EMPTY_CLAIMS;
156
168
  const expMs = secondsToMs(claims.exp);
157
169
  const iatMs = secondsToMs(claims.iat);
158
- const jti = issued.getAttribute("jti") ?? (typeof claims.jti === "string" ? claims.jti : null);
170
+ const jti = issued?.getAttribute("jti") ?? (typeof claims.jti === "string" ? claims.jti : null);
159
171
  const resolveStore = this.#resolveStore;
160
172
  if (expMs === null && (resolveStore === null || jti === null && iatMs === null)) {
161
173
  Nodefony.getKernel()?.log(`Realtime ${type} token "${identifier}" carries neither an expiry nor any revocation handle ("jti"/"iat") — connection will be revoked (fail-closed). Ensure the authenticator forwards the token claims.`, "WARNING");
162
- return () => Promise.resolve(false);
174
+ return () => Promise.resolve("revoked");
163
175
  }
164
- return async (nowMs = Date.now()) => {
165
- if (expMs !== null && nowMs >= expMs) return false;
166
- if (!resolveStore) return true;
176
+ /** `true` révoqué, `false` non, `null` = le store n'a pas pu répondre. */
177
+ const isRevoked = async () => {
178
+ if (!resolveStore) return false;
167
179
  let store;
168
180
  try {
169
181
  store = resolveStore();
170
182
  } catch {
171
- return expMs !== null;
183
+ return null;
172
184
  }
173
- if (!store) return expMs !== null;
185
+ if (!store) return null;
174
186
  try {
175
- if (jti !== null && await store.isJtiDenied(jti)) return false;
187
+ if (jti !== null && await store.isJtiDenied(jti)) return true;
176
188
  const invalidBefore = await store.getInvalidBefore(identifier);
177
- if (invalidBefore !== null && iatMs !== null && iatMs < invalidBefore) return false;
178
- return true;
189
+ return invalidBefore !== null && iatMs !== null && iatMs < invalidBefore;
179
190
  } catch {
180
- return expMs !== null;
191
+ return null;
181
192
  }
182
193
  };
194
+ return async (nowMs = Date.now()) => {
195
+ const revoked = await isRevoked();
196
+ if (revoked === null && expMs === null) return "revoked";
197
+ return tokenStatusOf({
198
+ revokedAt: revoked === true ? nowMs : null,
199
+ expiresAt: expMs
200
+ }, nowMs);
201
+ };
183
202
  }
184
203
  };
185
204
  const EMPTY_CLAIMS = Object.freeze({});
@@ -200,6 +219,29 @@ function secondsToMs(value) {
200
219
  function issuedType(issued) {
201
220
  return typeof issued?.type === "string" && issued.type ? issued.type : "session";
202
221
  }
222
+ /**
223
+ * Émetteur et compte local de l'identité, pour l'identité d'admission temps réel (ADR-0021 §S10).
224
+ *
225
+ * L'émetteur est celui que l'authentificateur a VÉRIFIÉ et posé sur le jeton
226
+ * (attribut `issuer` : JWT de l'application, JWT tiers) ; `null` sans jeton
227
+ * porteur (session, clé d'API). Le compte est absent pour le seul appelant
228
+ * machine fabriqué en mode `ephemeral` — constaté sur l'objet, jamais déduit
229
+ * de la forme de son `id`.
230
+ */
231
+ function admissionSourceOf(issued, user) {
232
+ const issuer = issued?.getAttribute("issuer");
233
+ const account = !isEphemeralSubject(user);
234
+ if (typeof issuer !== "string" || issuer === "") return account ? LOCAL_ACCOUNT : EPHEMERAL_WITHOUT_ISSUER;
235
+ return {
236
+ issuer,
237
+ account
238
+ };
239
+ }
240
+ /** Filet : un sujet éphémère sans émetteur posé (ne devrait pas exister). */
241
+ const EPHEMERAL_WITHOUT_ISSUER = Object.freeze({
242
+ issuer: null,
243
+ account: false
244
+ });
203
245
  /** Le jeton posé dans l'ALS par `firewall.handleSecurity`, s'il y en a un. */
204
246
  function readFirewallToken() {
205
247
  return RequestContext.get()?.token;
@@ -1,8 +1,9 @@
1
1
  import { AuthenticationError } from "../../errors/AuthenticationError.js";
2
- import { loadBoundAccount } from "../accountBinding.js";
3
2
  import { UserToken } from "../token/UserToken.js";
3
+ import { REQUIRED_TIME_CLAIMS, isIssuedInFuture } from "../token/tokenTimeClaims.js";
4
4
  import { bearerToken } from "./bearer.js";
5
5
  import peekIssuer from "./peekIssuer.js";
6
+ import { loadBoundAccount } from "../accountBinding.js";
6
7
  import { canonicalIssuer } from "nodefony";
7
8
  // nodefony/src/authenticator/JwtAuthenticator.ts
8
9
  const COMPACT_JWS = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*$/;
@@ -143,11 +144,13 @@ var JwtAuthenticator = class {
143
144
  algorithms: ["EdDSA"],
144
145
  issuer: this.#runtime.issuer,
145
146
  audience,
146
- typ: "at+jwt"
147
+ typ: "at+jwt",
148
+ requiredClaims: [...REQUIRED_TIME_CLAIMS]
147
149
  })).payload;
148
150
  } catch {
149
151
  throw new AuthenticationError(INVALID_TOKEN);
150
152
  }
153
+ if (isIssuedInFuture(payload, Math.floor(Date.now() / 1e3), 5)) throw new AuthenticationError(INVALID_TOKEN);
151
154
  const sub = payload.sub;
152
155
  const jti = payload.jti;
153
156
  if (typeof sub !== "string" || typeof jti !== "string") throw new AuthenticationError(INVALID_TOKEN);
@@ -1,6 +1,6 @@
1
1
  import { AuthenticationError } from "../../errors/AuthenticationError.js";
2
- import { ACCOUNT_ID_KEY, CREDENTIALS_VERSION_KEY } from "../accountBinding.js";
3
2
  import { resolveBoundSessionIdentity } from "../sessionIdentity.js";
3
+ import { ACCOUNT_ID_KEY, CREDENTIALS_VERSION_KEY } from "../accountBinding.js";
4
4
  import { UserToken } from "../token/UserToken.js";
5
5
  // nodefony/src/authenticator/SessionAuthenticator.ts
6
6
  /**
@@ -3,8 +3,8 @@ import { SessionAuthenticator } from "./SessionAuthenticator.js";
3
3
  import { UserPasswordAuthenticator } from "./UserPasswordAuthenticator.js";
4
4
  import { JwtAuthenticator } from "./JwtAuthenticator.js";
5
5
  import { ApiKeyAuthenticator } from "./ApiKeyAuthenticator.js";
6
- import { externalSubjectOptions } from "./ExternalSubjectResolver.js";
7
6
  import { ExternalJwtAuthenticator } from "./ExternalJwtAuthenticator.js";
7
+ import { externalSubjectOptions } from "./ExternalSubjectResolver.js";
8
8
  import { resolveJwtRuntime } from "../token/jwtRuntime.js";
9
9
  // nodefony/src/authenticator/authenticatorRegistry.ts
10
10
  const factories = /* @__PURE__ */ new Map();
@@ -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 };