@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,20 +1,21 @@
|
|
|
1
|
-
import { compileProviderRoleMapping, isPlatformRole } from "../src/oauth/providerRoles.js";
|
|
2
1
|
import { SecuredArea } from "../src/SecuredArea.js";
|
|
3
2
|
import { entryPointLocation } from "../src/entryPoint.js";
|
|
4
3
|
import { LoginThrottler } from "../src/throttle/LoginThrottler.js";
|
|
5
|
-
import { InsecureTransportError } from "../errors/InsecureTransportError.js";
|
|
6
4
|
import { CredentialTransportPolicy, shouldEnforceCredentialTransport } from "../src/transport/CredentialTransportPolicy.js";
|
|
7
5
|
import { RoleHierarchyWalker } from "../src/RoleHierarchyWalker.js";
|
|
8
6
|
import { rolesWithin } from "../src/rolesWithin.js";
|
|
9
|
-
import { mergeCspFragments } from "../src/csp.js";
|
|
7
|
+
import { assertCspFragment, findInertScriptSources, findOverbroadSources, mergeCspFragments, parseCsp, reportUrlProblem, withFrameAncestors, withViolationReports, withoutOverbroadSources } from "../src/csp.js";
|
|
8
|
+
import { CspReportCollector } from "../src/cspReports.js";
|
|
10
9
|
import { CsrfTokenManager } from "../src/csrfToken.js";
|
|
11
|
-
import { CsrfError } from "../errors/CsrfError.js";
|
|
12
10
|
import { Csrf } from "./csrf.js";
|
|
13
11
|
import { Cors } from "./cors.js";
|
|
14
|
-
import { SecurityHeaders } from "./securityHeaders.js";
|
|
12
|
+
import { SecurityHeaders, responseKindOf } from "./securityHeaders.js";
|
|
15
13
|
import { AuthenticationError } from "../errors/AuthenticationError.js";
|
|
14
|
+
import { StaleSessionError } from "../errors/StaleSessionError.js";
|
|
16
15
|
import { ThrottledError } from "../errors/ThrottledError.js";
|
|
17
16
|
import { UnverifiableTokenError } from "../errors/UnverifiableTokenError.js";
|
|
17
|
+
import { InsecureTransportError } from "../errors/InsecureTransportError.js";
|
|
18
|
+
import { CsrfError } from "../errors/CsrfError.js";
|
|
18
19
|
import { defineSecurityConfig } from "../config/defineModuleConfig.js";
|
|
19
20
|
import { getAuthenticatorFactory, listAuthenticatorFactories } from "../src/authenticator/authenticatorRegistry.js";
|
|
20
21
|
import { FirewallRealtimeAuthenticator } from "../src/authenticator/FirewallRealtimeAuthenticator.js";
|
|
@@ -23,8 +24,10 @@ import { recordAudit } from "../src/audit/recordAudit.js";
|
|
|
23
24
|
import { readAuditContext } from "../src/audit/readAuditContext.js";
|
|
24
25
|
import { SECURITY_AUDIT_CHANNEL, createAuditBridge } from "../src/audit/auditBridge.js";
|
|
25
26
|
import { findZonesOpenToSignup } from "../src/openToSignup.js";
|
|
26
|
-
import {
|
|
27
|
+
import { compileProviderRoleMapping, isPlatformRole } from "../src/oauth/providerRoles.js";
|
|
28
|
+
import { CSP_REPORT_GROUP, CSP_REPORT_PATH, ROLES_WITHIN, RequestContext, SESSION_AUTH_CHALLENGE, Service, logColor } from "nodefony";
|
|
27
29
|
import { randomBytes } from "node:crypto";
|
|
30
|
+
import { rpcCorsLists } from "@nodefony/http";
|
|
28
31
|
import { encoderFromConfig } from "@nodefony/user";
|
|
29
32
|
// nodefony/service/firewall.ts
|
|
30
33
|
const serviceName = "firewall";
|
|
@@ -35,29 +38,27 @@ const serviceName = "firewall";
|
|
|
35
38
|
* genre de dépense que la règle de lazy-allocation proscrit.
|
|
36
39
|
*/
|
|
37
40
|
const EMPTY_PROTECTED_RESOURCES = Object.freeze([]);
|
|
41
|
+
/**
|
|
42
|
+
* `Cache-Control` par défaut d'une réponse dont la CSP porte un nonce : jamais
|
|
43
|
+
* gardée par un cache partagé (le nonce y serait servi à plusieurs visiteurs),
|
|
44
|
+
* toujours redemandée avant réutilisation (ADR-0024 §C2).
|
|
45
|
+
*/
|
|
46
|
+
const NONCED_DOCUMENT_CACHE_CONTROL = "private, no-cache";
|
|
38
47
|
function headerValue(headers, name) {
|
|
39
48
|
const v = headers?.[name];
|
|
40
49
|
return Array.isArray(v) ? v[0] : v;
|
|
41
50
|
}
|
|
42
51
|
/**
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
52
|
+
* Valeur d'un cookie de la requête, lue sur le contexte : l'en-tête `Cookie`
|
|
53
|
+
* n'a qu'UN lecteur, `cookiesParser` (`@nodefony/http`), qui tourne à la
|
|
54
|
+
* construction du contexte — donc avant toute étape du pare-feu. Une seconde
|
|
55
|
+
* lecture ici divergeait (décodage, doublons) et levait sur un `%` isolé.
|
|
46
56
|
*/
|
|
47
|
-
function
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const eq = part.indexOf("=");
|
|
51
|
-
if (eq === -1) continue;
|
|
52
|
-
if (part.slice(0, eq).trim() === name) return decodeURIComponent(part.slice(eq + 1).trim());
|
|
53
|
-
}
|
|
57
|
+
function requestCookie(context, name) {
|
|
58
|
+
const value = context.cookies[name]?.value;
|
|
59
|
+
return typeof value === "string" ? value : void 0;
|
|
54
60
|
}
|
|
55
61
|
/**
|
|
56
|
-
* Défi posé sur un 401 quand aucun authenticator de la zone n'en déclare
|
|
57
|
-
* (RFC 9110 §15.5.2). Schéma inconnu des navigateurs : aucune fenêtre de login.
|
|
58
|
-
*/
|
|
59
|
-
const FALLBACK_CHALLENGE = "Session realm=\"nodefony\"";
|
|
60
|
-
/**
|
|
61
62
|
* Orchestrateur de sécurité Nodefony — refonte 2026 (P6).
|
|
62
63
|
*
|
|
63
64
|
* `isSecure()` (hot-path, court-circuit si aucune zone) ne fait QUE matcher la
|
|
@@ -76,7 +77,7 @@ const FALLBACK_CHALLENGE = "Session realm=\"nodefony\"";
|
|
|
76
77
|
*
|
|
77
78
|
* Conformité : tout 401 porte un challenge `WWW-Authenticate` (RFC 9110
|
|
78
79
|
* §15.5.2) — celui du premier authenticator de la zone qui en déclare un, sinon
|
|
79
|
-
* le défi de repli {@link
|
|
80
|
+
* le défi de repli {@link SESSION_AUTH_CHALLENGE}.
|
|
80
81
|
*
|
|
81
82
|
* CORS, CSRF et autorisation par décorateurs viennent se brancher en S4/S5.
|
|
82
83
|
* Toutes les structures sont **lazy** (perf : une app sans zone = zéro alloc).
|
|
@@ -91,6 +92,11 @@ var Firewall = class extends Service {
|
|
|
91
92
|
#cors = null;
|
|
92
93
|
#securityHeaders = null;
|
|
93
94
|
#cspFragments = null;
|
|
95
|
+
#cspMerged = null;
|
|
96
|
+
#cspBase = "";
|
|
97
|
+
#appCspSources = null;
|
|
98
|
+
#cspReportUrl = null;
|
|
99
|
+
#reportingEndpoints = null;
|
|
94
100
|
#config = null;
|
|
95
101
|
#configError = null;
|
|
96
102
|
#longLivedRevalidation = null;
|
|
@@ -98,6 +104,29 @@ var Firewall = class extends Service {
|
|
|
98
104
|
super(serviceName, module.container, module.notificationsCenter, module.options);
|
|
99
105
|
this.module = module;
|
|
100
106
|
this.kernel?.once("onBoot", () => this.#build());
|
|
107
|
+
this.kernel?.once("onReady", () => this.#widenCorsForRpc());
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Ajoute à la politique CORS les méthodes et en-têtes que demandent les
|
|
111
|
+
* protocoles RPC — dès qu'un service gRPC est déclaré et que le CORS est
|
|
112
|
+
* actif (ADR-0026 D9 point 2). Le préflight part AVANT le routage et ne
|
|
113
|
+
* connaît pas la route : l'union est la seule voie. Une seule politique,
|
|
114
|
+
* celle de l'application ; calculée une fois, annoncée au démarrage.
|
|
115
|
+
*/
|
|
116
|
+
#widenCorsForRpc() {
|
|
117
|
+
const config = this.#config;
|
|
118
|
+
const rpc = rpcCorsLists();
|
|
119
|
+
if (this.#cors === null || config === null || rpc === null) return;
|
|
120
|
+
const methods = withMissing(config.cors.methods, rpc.allowedMethods);
|
|
121
|
+
const allowedHeaders = withMissing(config.cors.allowedHeaders, rpc.allowedHeaders);
|
|
122
|
+
const added = [...methods.added, ...allowedHeaders.added];
|
|
123
|
+
if (added.length === 0) return;
|
|
124
|
+
this.#cors = new Cors({
|
|
125
|
+
...config.cors,
|
|
126
|
+
methods: methods.list,
|
|
127
|
+
allowedHeaders: allowedHeaders.list
|
|
128
|
+
});
|
|
129
|
+
this.log(`CORS : service gRPC déclaré — le préflight autorise aussi ${added.join(", ")} (ADR-0026 D9)`, "INFO");
|
|
101
130
|
}
|
|
102
131
|
#build() {
|
|
103
132
|
try {
|
|
@@ -121,7 +150,25 @@ var Firewall = class extends Service {
|
|
|
121
150
|
this.#csrfTokens = new CsrfTokenManager(secret);
|
|
122
151
|
}
|
|
123
152
|
if (this.#config.cors.enabled) this.#cors = new Cors(this.#config.cors);
|
|
124
|
-
if (this.#config.headers.enabled)
|
|
153
|
+
if (this.#config.headers.enabled) {
|
|
154
|
+
this.#appCspSources = this.#validatedAppCspSources(this.#config.headers.cspSources);
|
|
155
|
+
this.#cspReportUrl = this.#validatedCspReportUrl(this.#config.headers.cspReport);
|
|
156
|
+
this.#cspBase = this.#composeCspBase(this.#config.headers.csp);
|
|
157
|
+
this.#securityHeaders = this.#newSecurityHeaders({
|
|
158
|
+
...this.#config.headers,
|
|
159
|
+
csp: this.#cspBase
|
|
160
|
+
});
|
|
161
|
+
if (this.#reportingEndpoints !== null && this.#cspReportUrl === CSP_REPORT_PATH) this.container?.set("cspReports", new CspReportCollector({
|
|
162
|
+
endpoint: CSP_REPORT_PATH,
|
|
163
|
+
log: (message, severity) => this.log(message, severity)
|
|
164
|
+
}));
|
|
165
|
+
this.#cspMerged = null;
|
|
166
|
+
if (this.#cspFragments !== null && this.#cspFragments.size > 0) {
|
|
167
|
+
this.#rebuildSecurityHeaders();
|
|
168
|
+
for (const [name, fragment] of this.#cspFragments) this.#warnInertScriptSources(name, fragment);
|
|
169
|
+
}
|
|
170
|
+
for (const [directive, source] of findOverbroadSources(parseCsp(this.#config.headers.csp))) this.log(`CSP de l’application : « ${source} » dans ${directive} ouvre la directive à n’importe quel contenu — conservée (choix de la configuration) ; préférer une origine exacte.`, "WARNING");
|
|
171
|
+
}
|
|
125
172
|
const areas = this.#config.areas;
|
|
126
173
|
if (Object.keys(areas).length) {
|
|
127
174
|
const list = [];
|
|
@@ -258,7 +305,7 @@ var Firewall = class extends Service {
|
|
|
258
305
|
* @param config - config security, utilisée en repli si le HttpKernel est absent.
|
|
259
306
|
*/
|
|
260
307
|
#transportHeaderState(config) {
|
|
261
|
-
const emitted =
|
|
308
|
+
const emitted = this.#emittedTransportHeaders();
|
|
262
309
|
if (!emitted) return {
|
|
263
310
|
hsts: config.headers.hsts,
|
|
264
311
|
hstsMaxAgeS: config.headers.hstsMaxAgeS,
|
|
@@ -530,8 +577,7 @@ var Firewall = class extends Service {
|
|
|
530
577
|
* @returns la trace fraîche (posée sur le context), ou `null` hors profiling.
|
|
531
578
|
*/
|
|
532
579
|
#startTrace(context) {
|
|
533
|
-
|
|
534
|
-
if (!ctx.profiling) return null;
|
|
580
|
+
if (!context.profiling) return null;
|
|
535
581
|
const trace = {
|
|
536
582
|
authenticator: null,
|
|
537
583
|
outcome: "denied",
|
|
@@ -539,7 +585,7 @@ var Firewall = class extends Service {
|
|
|
539
585
|
user: null,
|
|
540
586
|
roles: null
|
|
541
587
|
};
|
|
542
|
-
|
|
588
|
+
context.securityTrace = trace;
|
|
543
589
|
return trace;
|
|
544
590
|
}
|
|
545
591
|
#recordAuth(context, area, action, outcome, reason, actor) {
|
|
@@ -597,7 +643,7 @@ var Firewall = class extends Service {
|
|
|
597
643
|
host: headerValue(headers, "host") ?? headerValue(headers, ":authority") ?? context.domain
|
|
598
644
|
});
|
|
599
645
|
if (ctx.csrfProtect && this.#csrfTokens) {
|
|
600
|
-
if (!this.#csrfTokens.verify(headerValue(headers, "x-csrf-token"),
|
|
646
|
+
if (!this.#csrfTokens.verify(headerValue(headers, "x-csrf-token"), requestCookie(context, "csrf-token"))) throw new CsrfError("Missing or invalid CSRF token");
|
|
601
647
|
}
|
|
602
648
|
}
|
|
603
649
|
/**
|
|
@@ -643,39 +689,195 @@ var Firewall = class extends Service {
|
|
|
643
689
|
/**
|
|
644
690
|
* En-têtes de sécurité APPLICATIFS (P6 J5) — CSP, Referrer-Policy, isolation
|
|
645
691
|
* cross-origin (COOP/COEP/CORP), Origin-Agent-Cluster, Permissions-Policy.
|
|
646
|
-
*
|
|
647
|
-
*
|
|
648
|
-
* SANS le ré-émettre. No-op si désactivé
|
|
692
|
+
* Appelé par la réponse juste avant son `writeHead`, une fois son type fixé :
|
|
693
|
+
* la politique suit ce type (ADR-0024 §C11). Complète le socle transport de
|
|
694
|
+
* `@nodefony/http` (nosniff/frame/HSTS) SANS le ré-émettre. No-op si désactivé
|
|
695
|
+
* ou réponse non-HTTP (WS).
|
|
649
696
|
*
|
|
650
697
|
* En-têtes constants = table figée pré-calculée au boot (0 alloc/concat). Le CSP
|
|
651
|
-
* nonce/req
|
|
698
|
+
* nonce/req ajoute 1 `join` + 1 `setHeader`, sur un document seulement.
|
|
699
|
+
*
|
|
700
|
+
* @param context - contexte de la réponse.
|
|
701
|
+
* @param contentType - `Content-Type` de la réponse ; `null` = sans contenu ;
|
|
702
|
+
* omis = type inconnu, servi comme un document (politique complète).
|
|
703
|
+
* @param servedAsFile - le corps est servi tel quel (fichier statique,
|
|
704
|
+
* relais) : aucun gabarit ne l'a écrit, il ne peut pas porter de nonce. Un
|
|
705
|
+
* document reçoit alors la politique sans nonce ni `'strict-dynamic'`
|
|
706
|
+
* (ADR-0024 §C1) ; les autres types n'en changent pas.
|
|
652
707
|
*/
|
|
653
|
-
applySecurityHeaders(context) {
|
|
708
|
+
applySecurityHeaders(context, contentType, servedAsFile = false) {
|
|
654
709
|
const sh = this.#securityHeaders;
|
|
655
710
|
if (!sh) return;
|
|
656
711
|
const response = context.response;
|
|
657
712
|
if (typeof response?.setHeader !== "function") return;
|
|
658
|
-
const
|
|
713
|
+
const extra = context.cspDirectives;
|
|
714
|
+
const tls = context.scheme === "https";
|
|
715
|
+
const validated = typeof response.setDefaultHeader === "function";
|
|
716
|
+
if (extra == null) {
|
|
717
|
+
const kind = responseKindOf(contentType);
|
|
718
|
+
if (kind !== "document" || servedAsFile) {
|
|
719
|
+
const table = kind === "document" ? sh.headersForServedFile(tls) : sh.headersForResource(kind, tls);
|
|
720
|
+
for (const name in table) {
|
|
721
|
+
const value = table[name];
|
|
722
|
+
if (value === void 0) continue;
|
|
723
|
+
if (validated) response.setDefaultHeader?.(name, value);
|
|
724
|
+
else response.setHeader(name, value);
|
|
725
|
+
}
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
const headers = sh.headersFor(tls, extra != null && !sh.hasNonce);
|
|
659
730
|
for (const name in headers) {
|
|
660
731
|
const value = headers[name];
|
|
661
|
-
if (value
|
|
732
|
+
if (value === void 0) continue;
|
|
733
|
+
if (validated) response.setDefaultHeader?.(name, value);
|
|
734
|
+
else response.setHeader(name, value);
|
|
662
735
|
}
|
|
663
|
-
|
|
736
|
+
let csp = null;
|
|
664
737
|
if (sh.hasNonce) {
|
|
665
738
|
const nonce = context.cspNonce;
|
|
666
|
-
|
|
667
|
-
} else if (extra)
|
|
739
|
+
csp = extra ? sh.cspForExtra(nonce, extra, tls) : sh.cspFor(nonce, tls);
|
|
740
|
+
} else if (extra) csp = sh.cspForExtra("", extra, tls);
|
|
741
|
+
if (csp !== null) {
|
|
742
|
+
if (validated) response.setDefaultHeader?.("Content-Security-Policy", csp);
|
|
743
|
+
else response.setHeader("Content-Security-Policy", csp);
|
|
744
|
+
}
|
|
745
|
+
if (sh.hasNonce) {
|
|
746
|
+
if (validated) response.setDefaultHeader?.("Cache-Control", NONCED_DOCUMENT_CACHE_CONTROL);
|
|
747
|
+
else response.setHeader("Cache-Control", NONCED_DOCUMENT_CACHE_CONTROL);
|
|
748
|
+
}
|
|
668
749
|
}
|
|
669
750
|
/**
|
|
670
751
|
* Déclare des directives CSP additionnelles pour `moduleName` (cf `IFirewall`).
|
|
671
752
|
* No-op si les en-têtes applicatifs sont désactivés (pas de CSP à étendre).
|
|
672
753
|
* Recompose `#securityHeaders` (merge + re-split nonce) — hors hot-path.
|
|
673
754
|
*/
|
|
674
|
-
registerCspOrigins(moduleName,
|
|
755
|
+
registerCspOrigins(moduleName, declared) {
|
|
756
|
+
const config = this.#config;
|
|
757
|
+
if (config !== null && this.#securityHeaders === null) return;
|
|
758
|
+
try {
|
|
759
|
+
assertCspFragment(declared, moduleName);
|
|
760
|
+
} catch (error) {
|
|
761
|
+
this.log(`CSP : contribution de ${moduleName} IGNORÉE — ${error instanceof Error ? error.message : String(error)}`, "ERROR");
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
const { fragment, dropped } = withoutOverbroadSources(declared);
|
|
765
|
+
for (const [directive, source] of dropped) this.log(`CSP : « ${source} » déclarée par ${moduleName} dans ${directive} ouvre la directive à n’importe quel contenu — RETIRÉE de la politique ; déclarer l’origine exacte.`, "WARNING");
|
|
766
|
+
const fragments = this.#cspFragments ??= /* @__PURE__ */ new Map();
|
|
767
|
+
if (config === null) {
|
|
768
|
+
fragments.set(moduleName, fragment);
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
this.#warnInertScriptSources(moduleName, fragment);
|
|
772
|
+
const previous = fragments.get(moduleName);
|
|
773
|
+
if (previous !== void 0 || this.#cspMerged === null && fragments.size > 0) {
|
|
774
|
+
fragments.set(moduleName, fragment);
|
|
775
|
+
try {
|
|
776
|
+
this.#rebuildSecurityHeaders();
|
|
777
|
+
} catch (error) {
|
|
778
|
+
if (previous === void 0) fragments.delete(moduleName);
|
|
779
|
+
else fragments.set(moduleName, previous);
|
|
780
|
+
throw error;
|
|
781
|
+
}
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
const csp = mergeCspFragments(this.#cspMerged ?? this.#cspBase, [fragment]);
|
|
785
|
+
fragments.set(moduleName, fragment);
|
|
786
|
+
this.#cspMerged = csp;
|
|
787
|
+
this.#securityHeaders = this.#newSecurityHeaders({
|
|
788
|
+
...config.headers,
|
|
789
|
+
csp
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* Le noyau HTTP a recalculé ses en-têtes de transport (édition à chaud de sa
|
|
794
|
+
* config) : la politique suit `frameOptions`, qui pilote aussi
|
|
795
|
+
* `frame-ancestors` (ADR-0024 §C5). Hors chemin chaud.
|
|
796
|
+
*/
|
|
797
|
+
onTransportHeadersChanged() {
|
|
675
798
|
if (!this.#securityHeaders || !this.#config) return;
|
|
676
|
-
|
|
799
|
+
const base = this.#composeCspBase(this.#config.headers.csp);
|
|
800
|
+
if (base === this.#cspBase) return;
|
|
801
|
+
this.#cspBase = base;
|
|
677
802
|
this.#rebuildSecurityHeaders();
|
|
678
803
|
}
|
|
804
|
+
/**
|
|
805
|
+
* Politique de base : la config, complétée de `frame-ancestors` dérivé de ce
|
|
806
|
+
* que le noyau HTTP émet en `X-Frame-Options`. Lu chez l’émetteur, par son
|
|
807
|
+
* contrat d’introspection ; sans noyau HTTP, rien n’est dérivé.
|
|
808
|
+
*/
|
|
809
|
+
#composeCspBase(csp) {
|
|
810
|
+
const emitted = this.#emittedTransportHeaders();
|
|
811
|
+
const framed = emitted ? withFrameAncestors(csp, emitted.frameOptions) : csp;
|
|
812
|
+
const sources = this.#appCspSources;
|
|
813
|
+
const merged = sources === null || csp.trim() === "" ? framed : mergeCspFragments(framed, sources);
|
|
814
|
+
if (this.#cspReportUrl === null) {
|
|
815
|
+
this.#reportingEndpoints = null;
|
|
816
|
+
return merged;
|
|
817
|
+
}
|
|
818
|
+
const reported = withViolationReports(merged, this.#cspReportUrl, CSP_REPORT_GROUP);
|
|
819
|
+
this.#reportingEndpoints = reported.added ? `${CSP_REPORT_GROUP}="${this.#cspReportUrl}"` : null;
|
|
820
|
+
return reported.csp;
|
|
821
|
+
}
|
|
822
|
+
/**
|
|
823
|
+
* Lit `headers.cspReport` : `true` = la route du framework, une chaîne = le
|
|
824
|
+
* collecteur de l’application, `false` = aucun rapport. Une URL qu’un en-tête
|
|
825
|
+
* ne peut pas porter est RETIRÉE et dite (ADR-0024 C0 : une faute de
|
|
826
|
+
* configuration de sécurité n’empêche pas le démarrage).
|
|
827
|
+
*/
|
|
828
|
+
#validatedCspReportUrl(value) {
|
|
829
|
+
if (value === false) return null;
|
|
830
|
+
if (value === true) return CSP_REPORT_PATH;
|
|
831
|
+
const problem = reportUrlProblem(value);
|
|
832
|
+
if (problem === null) return value;
|
|
833
|
+
this.log(`CSP : headers.cspReport « ${value.slice(0, 120)} » IGNORÉE — ${problem} ; aucun rapport de violation demandé.`, "ERROR");
|
|
834
|
+
return null;
|
|
835
|
+
}
|
|
836
|
+
/**
|
|
837
|
+
* Dit, une fois, les sources de scripts d'un module que `'strict-dynamic'`
|
|
838
|
+
* rend inertes (cf `findInertScriptSources`) : gardées pour un navigateur
|
|
839
|
+
* CSP2, elles n'ouvrent rien aux autres — le module doit charger son script
|
|
840
|
+
* depuis un script signé.
|
|
841
|
+
*
|
|
842
|
+
* @param moduleName - module qui a déclaré le fragment
|
|
843
|
+
* @param fragment - fragment retenu (sources trop larges déjà retirées)
|
|
844
|
+
*/
|
|
845
|
+
#warnInertScriptSources(moduleName, fragment) {
|
|
846
|
+
for (const [directive, source] of findInertScriptSources(fragment, this.#cspBase)) this.log(`CSP : « ${source} » déclarée par ${moduleName} dans ${directive} est INERTE sous 'strict-dynamic' (navigateurs CSP3) — conservée pour CSP2 ; charger ce script depuis un script signé.`, "WARNING");
|
|
847
|
+
}
|
|
848
|
+
/**
|
|
849
|
+
* Valide les contributions nommées de `headers.cspSources`, une fois. Une
|
|
850
|
+
* contribution malformée est IGNORÉE et dite (l'application démarre, plus
|
|
851
|
+
* stricte) ; une source ouverte à tout hôte est GARDÉE — choix écrit par
|
|
852
|
+
* l'application, comme sa politique — et dite.
|
|
853
|
+
*
|
|
854
|
+
* @param declared - contributions par nom
|
|
855
|
+
* @returns les fragments retenus, ou `null` s'il n'y en a aucun
|
|
856
|
+
*/
|
|
857
|
+
#validatedAppCspSources(declared) {
|
|
858
|
+
let kept = null;
|
|
859
|
+
for (const [name, fragment] of Object.entries(declared)) {
|
|
860
|
+
try {
|
|
861
|
+
assertCspFragment(fragment, name);
|
|
862
|
+
} catch (error) {
|
|
863
|
+
this.log(`CSP : headers.cspSources.${name} IGNORÉE — ${error instanceof Error ? error.message : String(error)}`, "ERROR");
|
|
864
|
+
continue;
|
|
865
|
+
}
|
|
866
|
+
for (const [directive, source] of withoutOverbroadSources(fragment).dropped) this.log(`CSP : « ${source} » dans headers.cspSources.${name} (${directive}) ouvre la directive à n’importe quel contenu — conservée (choix de la configuration) ; préférer une origine exacte.`, "WARNING");
|
|
867
|
+
(kept ??= []).push(fragment);
|
|
868
|
+
}
|
|
869
|
+
return kept;
|
|
870
|
+
}
|
|
871
|
+
/**
|
|
872
|
+
* En-têtes de transport que le noyau HTTP ÉMET, lus par son contrat
|
|
873
|
+
* d’introspection, jamais par ses champs internes : `security` ne dépend pas
|
|
874
|
+
* de la mécanique de cache du kernel HTTP, seulement de ce qu’il déclare.
|
|
875
|
+
*
|
|
876
|
+
* @returns les valeurs émises, `undefined` sans noyau HTTP au conteneur.
|
|
877
|
+
*/
|
|
878
|
+
#emittedTransportHeaders() {
|
|
879
|
+
return (this.container?.get("HttpKernel"))?.describeTransportSecurityHeaders?.();
|
|
880
|
+
}
|
|
679
881
|
/** Retire les directives CSP de `moduleName` et recompose si nécessaire. */
|
|
680
882
|
unregisterCspOrigins(moduleName) {
|
|
681
883
|
if (!this.#cspFragments?.delete(moduleName)) return;
|
|
@@ -690,13 +892,27 @@ var Firewall = class extends Service {
|
|
|
690
892
|
#rebuildSecurityHeaders() {
|
|
691
893
|
const headers = this.#config?.headers;
|
|
692
894
|
if (!headers) return;
|
|
693
|
-
const
|
|
694
|
-
this.#
|
|
895
|
+
const base = this.#cspBase;
|
|
896
|
+
const csp = this.#cspFragments && this.#cspFragments.size > 0 ? mergeCspFragments(base, this.#cspFragments.values()) : base;
|
|
897
|
+
this.#cspMerged = csp === base ? null : csp;
|
|
898
|
+
this.#securityHeaders = this.#newSecurityHeaders({
|
|
695
899
|
...headers,
|
|
696
900
|
csp
|
|
697
901
|
});
|
|
698
902
|
}
|
|
699
903
|
/**
|
|
904
|
+
* Construit la table des en-têtes applicatifs, branchée sur le journal : un
|
|
905
|
+
* `@Csp` de route hors grammaire est IGNORÉ (politique de base servie) et dit.
|
|
906
|
+
*/
|
|
907
|
+
#newSecurityHeaders(headers) {
|
|
908
|
+
return new SecurityHeaders({
|
|
909
|
+
...headers,
|
|
910
|
+
reportingEndpoints: this.#reportingEndpoints
|
|
911
|
+
}, (error) => {
|
|
912
|
+
this.log(`CSP : directives @Csp d’une route IGNORÉES, la politique de base est servie — ${error instanceof Error ? error.message : String(error)}`, "ERROR");
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
/**
|
|
700
916
|
* Exécute la chaîne d'authenticators de la zone selon son `mode`.
|
|
701
917
|
*
|
|
702
918
|
* `first` : le premier dont `supports()` est vrai authentifie — un credential
|
|
@@ -731,6 +947,10 @@ var Firewall = class extends Service {
|
|
|
731
947
|
authenticated = await authenticator.authenticate(created);
|
|
732
948
|
} catch (error) {
|
|
733
949
|
await authenticator.onFailure(context, error);
|
|
950
|
+
if (error instanceof StaleSessionError && area.mode === "first") {
|
|
951
|
+
this.log(`session périmée purgée (area "${area.name}", authenticator "${name}") — la requête continue sans elle`, "WARNING");
|
|
952
|
+
continue;
|
|
953
|
+
}
|
|
734
954
|
if (error instanceof AuthenticationError) {
|
|
735
955
|
this.log(`authentication failed (area "${area.name}", authenticator "${name}")`, "WARNING");
|
|
736
956
|
throw error;
|
|
@@ -792,11 +1012,28 @@ var Firewall = class extends Service {
|
|
|
792
1012
|
return;
|
|
793
1013
|
}
|
|
794
1014
|
}
|
|
795
|
-
response.setHeader("WWW-Authenticate",
|
|
1015
|
+
response.setHeader("WWW-Authenticate", SESSION_AUTH_CHALLENGE);
|
|
796
1016
|
}
|
|
797
1017
|
log(pci, severity, msgid, msg) {
|
|
798
1018
|
if (!msgid) msgid = logColor.cyan("FIREWALL");
|
|
799
1019
|
return super.log(pci, severity, msgid, msg);
|
|
800
1020
|
}
|
|
801
1021
|
};
|
|
802
|
-
|
|
1022
|
+
/**
|
|
1023
|
+
* La liste `base` complétée des entrées de `extra` qu'elle ne porte pas encore
|
|
1024
|
+
* — comparaison sans casse (les noms d'en-têtes et de méthodes HTTP ne
|
|
1025
|
+
* distinguent pas la casse) ; l'ordre et l'écriture de `base` sont gardés.
|
|
1026
|
+
*
|
|
1027
|
+
* @param base - la liste de la configuration.
|
|
1028
|
+
* @param extra - les entrées à garantir.
|
|
1029
|
+
* @returns la liste complétée et les entrées ajoutées.
|
|
1030
|
+
*/
|
|
1031
|
+
function withMissing(base, extra) {
|
|
1032
|
+
const known = new Set(base.map((entry) => entry.toLowerCase()));
|
|
1033
|
+
const added = extra.filter((entry) => !known.has(entry.toLowerCase()));
|
|
1034
|
+
return {
|
|
1035
|
+
list: [...base, ...added],
|
|
1036
|
+
added
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
export { Firewall, Firewall as default, NONCED_DOCUMENT_CACHE_CONTROL };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { readIfPresentSync } from "../src/token/secretFile.js";
|
|
2
1
|
import { defineSecurityConfig } from "../config/defineModuleConfig.js";
|
|
2
|
+
import { readIfPresentSync } from "../src/token/secretFile.js";
|
|
3
3
|
import { buildKeycloakRealm, mergeKeycloakRealm, renderKeycloakRealm } from "../keycloak/keycloakRealm.js";
|
|
4
4
|
import { KeycloakRealmError, deriveKeycloakRealmInput, keycloakLoginTheme } from "../keycloak/keycloakRealmInput.js";
|
|
5
5
|
import { KeycloakAdminClient, keycloakAdminTarget } from "../keycloak/keycloakAdmin.js";
|
|
@@ -1,14 +1,16 @@
|
|
|
1
|
-
import { compileProviderRoleMapping, isPlatformRole, mapProviderRoles } from "../src/oauth/providerRoles.js";
|
|
2
|
-
import { AuthenticationError } from "../errors/AuthenticationError.js";
|
|
3
1
|
import { defineSecurityConfig } from "../config/defineModuleConfig.js";
|
|
4
|
-
import {
|
|
2
|
+
import { AuthenticationError } from "../errors/AuthenticationError.js";
|
|
5
3
|
import { keycloakLoginThemeCheck } from "../keycloak/keycloakAdmin.js";
|
|
4
|
+
import { recordAudit } from "../src/audit/recordAudit.js";
|
|
5
|
+
import { getOAuthProviderFactory, listOAuthProviders, oauthProviderImageSources, oauthProviderRequiresIssuer } from "../src/oauth/oauthProviderRegistry.js";
|
|
6
6
|
import { generateCodeVerifier, generateState } from "../src/oauth/oauth2Client.js";
|
|
7
|
-
import { getOAuthProviderFactory, listOAuthProviders, oauthProviderRequiresIssuer } from "../src/oauth/oauthProviderRegistry.js";
|
|
8
7
|
import { diagnoseOAuthProvider } from "../src/oauth/providerDiagnosis.js";
|
|
8
|
+
import { compileProviderRoleMapping, isPlatformRole, mapProviderRoles } from "../src/oauth/providerRoles.js";
|
|
9
9
|
import { BootConfigurationError, LOGIN_PAGE_PATH, Service, canonicalIssuer } from "nodefony";
|
|
10
10
|
// nodefony/service/oauth2.ts
|
|
11
11
|
const serviceName = "oauth2";
|
|
12
|
+
/** Nom sous lequel la connexion sociale contribue à la politique de contenu. */
|
|
13
|
+
const OAUTH2_CSP_CONTRIBUTOR = "oauth2";
|
|
12
14
|
/**
|
|
13
15
|
* Clé de session de l'indice de déconnexion. Lue et écrite par CE service
|
|
14
16
|
* seulement ({@link OAuth2Service.rememberLogout},
|
|
@@ -242,6 +244,8 @@ var OAuth2Service = class extends Service {
|
|
|
242
244
|
}
|
|
243
245
|
this.#config = config;
|
|
244
246
|
this.#ready = true;
|
|
247
|
+
const imageSources = oauthProviderImageSources(active);
|
|
248
|
+
if (imageSources.length > 0) this.get("firewall")?.registerCspOrigins(OAUTH2_CSP_CONTRIBUTOR, { "img-src": imageSources });
|
|
245
249
|
this.log(`oauth2 ready — providers: [${active.join(", ") || "aucun"}]`, "DEBUG");
|
|
246
250
|
if (this.kernel?.runProfile.servers === true) for (const name of active) this.#probe(name);
|
|
247
251
|
}
|