@nodefony/security 10.0.0-beta.5 → 10.0.0-beta.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -0
- package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
- package/dist/index.js +63 -61
- package/dist/nodefony/command/security-mcp-token.js +43 -5
- package/dist/nodefony/config/config.js +30 -11
- package/dist/nodefony/config/defineModuleConfig.js +52 -1
- package/dist/nodefony/contracts/IWebhookPayload.js +1 -0
- package/dist/nodefony/errors/FetchPublicError.js +18 -0
- package/dist/nodefony/errors/SsrfError.js +9 -1
- package/dist/nodefony/errors/StaleSessionError.js +20 -0
- package/dist/nodefony/errors/index.js +7 -5
- package/dist/nodefony/keycloak/keycloakAdmin.js +3 -3
- package/dist/nodefony/keycloak/scaffold.js +31 -14
- package/dist/nodefony/keycloak/security-keycloak-realm.js +1 -1
- package/dist/nodefony/service/accessTokenVerifier.js +6 -6
- package/dist/nodefony/service/apiKeys.js +8 -4
- package/dist/nodefony/service/auditService.js +48 -3
- package/dist/nodefony/service/authFlow.js +9 -6
- package/dist/nodefony/service/authorization.js +1 -1
- package/dist/nodefony/service/firewall.js +281 -44
- package/dist/nodefony/service/keycloak.js +1 -1
- package/dist/nodefony/service/oauth2.js +8 -4
- package/dist/nodefony/service/securityHeaders.js +340 -47
- package/dist/nodefony/service/tokenService.js +16 -11
- package/dist/nodefony/service/totp.js +10 -5
- package/dist/nodefony/service/webAuthn.js +46 -7
- package/dist/nodefony/service/webhooks.js +11 -4
- package/dist/nodefony/src/admin/SecurityAdminApi.js +19 -0
- package/dist/nodefony/src/audit/AuditRefusalAggregator.js +139 -0
- package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +1 -1
- package/dist/nodefony/src/authenticator/ExternalSubjectResolver.js +31 -7
- package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +62 -20
- package/dist/nodefony/src/authenticator/JwtAuthenticator.js +29 -6
- package/dist/nodefony/src/authenticator/SessionAuthenticator.js +22 -9
- package/dist/nodefony/src/authenticator/authenticatorRegistry.js +2 -2
- package/dist/nodefony/src/csp.js +265 -10
- package/dist/nodefony/src/cspReports.js +307 -0
- package/dist/nodefony/src/net/fetchPublic.js +244 -0
- package/dist/nodefony/src/net/ssrfGuard.js +2 -2
- package/dist/nodefony/src/oauth/clientCredentials.js +1 -1
- package/dist/nodefony/src/oauth/metadata.js +1 -1
- package/dist/nodefony/src/oauth/oauthProviderRegistry.js +17 -4
- package/dist/nodefony/src/oauth/providers/github.js +1 -1
- package/dist/nodefony/src/realtime/UserRealtimeToken.js +59 -2
- package/dist/nodefony/src/realtime/frameAuthorizer.js +136 -44
- package/dist/nodefony/src/token/RemoteJwtVerifier.js +8 -2
- package/dist/nodefony/src/token/jwtRuntime.js +22 -2
- package/dist/nodefony/src/token/tokenTimeClaims.js +34 -0
- package/dist/nodefony/src/totp/totpOperations.js +1 -1
- package/dist/nodefony/src/voter/RoleVoter.js +1 -1
- package/dist/nodefony/src/webhook/WebhookDispatcher.js +71 -17
- package/dist/nodefony/src/webhook/webhookDelivery.js +37 -86
- package/dist/nodefony/testing/index.js +26 -36
- package/dist/types/index.d.ts +5 -1
- package/dist/types/nodefony/command/security-mcp-token.d.ts +12 -0
- package/dist/types/nodefony/config/config.d.ts +8 -0
- package/dist/types/nodefony/contracts/IApiKey.d.ts +8 -2
- package/dist/types/nodefony/contracts/IFirewall.d.ts +15 -2
- package/dist/types/nodefony/contracts/ITokenStore.d.ts +7 -3
- package/dist/types/nodefony/contracts/IWebhookEndpoint.d.ts +13 -3
- package/dist/types/nodefony/contracts/IWebhookPayload.d.ts +24 -0
- package/dist/types/nodefony/errors/FetchPublicError.d.ts +15 -0
- package/dist/types/nodefony/errors/SsrfError.d.ts +8 -1
- package/dist/types/nodefony/errors/StaleSessionError.d.ts +17 -0
- package/dist/types/nodefony/errors/index.d.ts +3 -0
- package/dist/types/nodefony/keycloak/scaffold.d.ts +12 -6
- package/dist/types/nodefony/service/apiKeys.d.ts +3 -0
- package/dist/types/nodefony/service/authFlow.d.ts +2 -2
- package/dist/types/nodefony/service/firewall.d.ts +28 -7
- package/dist/types/nodefony/service/securityHeaders.d.ts +82 -11
- package/dist/types/nodefony/service/webAuthn.d.ts +13 -0
- package/dist/types/nodefony/service/webhooks.d.ts +7 -1
- package/dist/types/nodefony/src/admin/SecurityAdminApi.d.ts +3 -0
- package/dist/types/nodefony/src/audit/AuditRefusalAggregator.d.ts +55 -0
- package/dist/types/nodefony/src/authenticator/ExternalSubjectResolver.d.ts +11 -0
- package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +17 -3
- package/dist/types/nodefony/src/authenticator/SessionAuthenticator.d.ts +11 -4
- package/dist/types/nodefony/src/csp.d.ts +142 -0
- package/dist/types/nodefony/src/cspReports.d.ts +73 -0
- package/dist/types/nodefony/src/net/fetchPublic.d.ts +73 -0
- package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +15 -0
- package/dist/types/nodefony/src/realtime/UserRealtimeToken.d.ts +35 -1
- package/dist/types/nodefony/src/realtime/frameAuthorizer.d.ts +10 -2
- package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +42 -18
- package/dist/types/nodefony/src/token/jwtRuntime.d.ts +21 -2
- package/dist/types/nodefony/src/token/tokenTimeClaims.d.ts +32 -0
- package/dist/types/nodefony/src/webhook/WebhookDispatcher.d.ts +36 -1
- package/dist/types/nodefony/src/webhook/webhookDelivery.d.ts +5 -2
- package/dist/types/nodefony/testing/index.d.ts +6 -0
- package/docs/api-keys.md +3 -1
- package/docs/audit.md +112 -28
- package/docs/authenticators.md +6 -4
- package/docs/csrf.md +3 -2
- package/docs/external-jwt.md +5 -1
- package/docs/firewall.md +3 -2
- package/docs/headers.md +427 -71
- package/docs/keycloak.md +4 -4
- package/docs/login-page.md +4 -3
- package/docs/obtenir-un-jeton.md +17 -10
- package/docs/tokens.md +1 -1
- package/docs/totp.md +2 -2
- package/docs/webauthn.md +2 -2
- package/docs/webhooks.md +63 -18
- package/package.json +17 -17
|
@@ -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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
if (o.
|
|
41
|
-
if (o.
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
if (
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
/**
|
|
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.#
|
|
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
|
-
|
|
72
|
-
return
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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 {
|
|
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 = () =>
|
|
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", () =>
|
|
40
|
-
|
|
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", () =>
|
|
67
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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;
|