@nodefony/security 10.0.0-beta.5 → 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.
- package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.157.0}/helpers/esm/decorate.js +1 -1
- package/dist/index.js +60 -60
- package/dist/nodefony/config/config.js +13 -5
- package/dist/nodefony/contracts/IWebhookPayload.js +1 -0
- package/dist/nodefony/errors/index.js +4 -4
- package/dist/nodefony/keycloak/keycloakAdmin.js +3 -3
- package/dist/nodefony/keycloak/security-keycloak-realm.js +1 -1
- package/dist/nodefony/service/accessTokenVerifier.js +5 -5
- package/dist/nodefony/service/apiKeys.js +8 -4
- package/dist/nodefony/service/authFlow.js +3 -3
- package/dist/nodefony/service/authorization.js +1 -1
- package/dist/nodefony/service/firewall.js +233 -41
- 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 +8 -8
- package/dist/nodefony/service/totp.js +3 -3
- package/dist/nodefony/service/webAuthn.js +1 -1
- package/dist/nodefony/service/webhooks.js +11 -4
- package/dist/nodefony/src/admin/SecurityAdminApi.js +19 -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 +5 -2
- package/dist/nodefony/src/authenticator/SessionAuthenticator.js +1 -1
- package/dist/nodefony/src/authenticator/authenticatorRegistry.js +1 -1
- package/dist/nodefony/src/csp.js +265 -10
- package/dist/nodefony/src/cspReports.js +307 -0
- 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 +6 -5
- package/dist/nodefony/src/token/RemoteJwtVerifier.js +8 -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 +3 -3
- package/dist/nodefony/testing/index.js +26 -36
- package/dist/types/index.d.ts +1 -0
- package/dist/types/nodefony/config/config.d.ts +2 -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/service/apiKeys.d.ts +3 -0
- 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/webhooks.d.ts +7 -1
- package/dist/types/nodefony/src/admin/SecurityAdminApi.d.ts +3 -0
- package/dist/types/nodefony/src/authenticator/ExternalSubjectResolver.d.ts +11 -0
- 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/oauth/oauthProviderRegistry.d.ts +15 -0
- package/dist/types/nodefony/src/realtime/UserRealtimeToken.d.ts +35 -1
- package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +25 -17
- 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/testing/index.d.ts +6 -0
- package/docs/api-keys.md +3 -1
- 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/login-page.md +4 -3
- package/docs/totp.md +2 -2
- package/docs/webauthn.md +2 -2
- package/docs/webhooks.md +48 -11
- package/package.json +14 -14
|
@@ -1,20 +1,20 @@
|
|
|
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";
|
|
16
14
|
import { ThrottledError } from "../errors/ThrottledError.js";
|
|
17
15
|
import { UnverifiableTokenError } from "../errors/UnverifiableTokenError.js";
|
|
16
|
+
import { InsecureTransportError } from "../errors/InsecureTransportError.js";
|
|
17
|
+
import { CsrfError } from "../errors/CsrfError.js";
|
|
18
18
|
import { defineSecurityConfig } from "../config/defineModuleConfig.js";
|
|
19
19
|
import { getAuthenticatorFactory, listAuthenticatorFactories } from "../src/authenticator/authenticatorRegistry.js";
|
|
20
20
|
import { FirewallRealtimeAuthenticator } from "../src/authenticator/FirewallRealtimeAuthenticator.js";
|
|
@@ -23,7 +23,8 @@ import { recordAudit } from "../src/audit/recordAudit.js";
|
|
|
23
23
|
import { readAuditContext } from "../src/audit/readAuditContext.js";
|
|
24
24
|
import { SECURITY_AUDIT_CHANNEL, createAuditBridge } from "../src/audit/auditBridge.js";
|
|
25
25
|
import { findZonesOpenToSignup } from "../src/openToSignup.js";
|
|
26
|
-
import {
|
|
26
|
+
import { compileProviderRoleMapping, isPlatformRole } from "../src/oauth/providerRoles.js";
|
|
27
|
+
import { CSP_REPORT_GROUP, CSP_REPORT_PATH, ROLES_WITHIN, RequestContext, SESSION_AUTH_CHALLENGE, Service, logColor } from "nodefony";
|
|
27
28
|
import { randomBytes } from "node:crypto";
|
|
28
29
|
import { encoderFromConfig } from "@nodefony/user";
|
|
29
30
|
// nodefony/service/firewall.ts
|
|
@@ -35,29 +36,27 @@ const serviceName = "firewall";
|
|
|
35
36
|
* genre de dépense que la règle de lazy-allocation proscrit.
|
|
36
37
|
*/
|
|
37
38
|
const EMPTY_PROTECTED_RESOURCES = Object.freeze([]);
|
|
39
|
+
/**
|
|
40
|
+
* `Cache-Control` par défaut d'une réponse dont la CSP porte un nonce : jamais
|
|
41
|
+
* gardée par un cache partagé (le nonce y serait servi à plusieurs visiteurs),
|
|
42
|
+
* toujours redemandée avant réutilisation (ADR-0024 §C2).
|
|
43
|
+
*/
|
|
44
|
+
const NONCED_DOCUMENT_CACHE_CONTROL = "private, no-cache";
|
|
38
45
|
function headerValue(headers, name) {
|
|
39
46
|
const v = headers?.[name];
|
|
40
47
|
return Array.isArray(v) ? v[0] : v;
|
|
41
48
|
}
|
|
42
49
|
/**
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
50
|
+
* Valeur d'un cookie de la requête, lue sur le contexte : l'en-tête `Cookie`
|
|
51
|
+
* n'a qu'UN lecteur, `cookiesParser` (`@nodefony/http`), qui tourne à la
|
|
52
|
+
* construction du contexte — donc avant toute étape du pare-feu. Une seconde
|
|
53
|
+
* lecture ici divergeait (décodage, doublons) et levait sur un `%` isolé.
|
|
46
54
|
*/
|
|
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
|
-
}
|
|
55
|
+
function requestCookie(context, name) {
|
|
56
|
+
const value = context.cookies[name]?.value;
|
|
57
|
+
return typeof value === "string" ? value : void 0;
|
|
54
58
|
}
|
|
55
59
|
/**
|
|
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
60
|
* Orchestrateur de sécurité Nodefony — refonte 2026 (P6).
|
|
62
61
|
*
|
|
63
62
|
* `isSecure()` (hot-path, court-circuit si aucune zone) ne fait QUE matcher la
|
|
@@ -76,7 +75,7 @@ const FALLBACK_CHALLENGE = "Session realm=\"nodefony\"";
|
|
|
76
75
|
*
|
|
77
76
|
* Conformité : tout 401 porte un challenge `WWW-Authenticate` (RFC 9110
|
|
78
77
|
* §15.5.2) — celui du premier authenticator de la zone qui en déclare un, sinon
|
|
79
|
-
* le défi de repli {@link
|
|
78
|
+
* le défi de repli {@link SESSION_AUTH_CHALLENGE}.
|
|
80
79
|
*
|
|
81
80
|
* CORS, CSRF et autorisation par décorateurs viennent se brancher en S4/S5.
|
|
82
81
|
* Toutes les structures sont **lazy** (perf : une app sans zone = zéro alloc).
|
|
@@ -91,6 +90,11 @@ var Firewall = class extends Service {
|
|
|
91
90
|
#cors = null;
|
|
92
91
|
#securityHeaders = null;
|
|
93
92
|
#cspFragments = null;
|
|
93
|
+
#cspMerged = null;
|
|
94
|
+
#cspBase = "";
|
|
95
|
+
#appCspSources = null;
|
|
96
|
+
#cspReportUrl = null;
|
|
97
|
+
#reportingEndpoints = null;
|
|
94
98
|
#config = null;
|
|
95
99
|
#configError = null;
|
|
96
100
|
#longLivedRevalidation = null;
|
|
@@ -121,7 +125,25 @@ var Firewall = class extends Service {
|
|
|
121
125
|
this.#csrfTokens = new CsrfTokenManager(secret);
|
|
122
126
|
}
|
|
123
127
|
if (this.#config.cors.enabled) this.#cors = new Cors(this.#config.cors);
|
|
124
|
-
if (this.#config.headers.enabled)
|
|
128
|
+
if (this.#config.headers.enabled) {
|
|
129
|
+
this.#appCspSources = this.#validatedAppCspSources(this.#config.headers.cspSources);
|
|
130
|
+
this.#cspReportUrl = this.#validatedCspReportUrl(this.#config.headers.cspReport);
|
|
131
|
+
this.#cspBase = this.#composeCspBase(this.#config.headers.csp);
|
|
132
|
+
this.#securityHeaders = this.#newSecurityHeaders({
|
|
133
|
+
...this.#config.headers,
|
|
134
|
+
csp: this.#cspBase
|
|
135
|
+
});
|
|
136
|
+
if (this.#reportingEndpoints !== null && this.#cspReportUrl === CSP_REPORT_PATH) this.container?.set("cspReports", new CspReportCollector({
|
|
137
|
+
endpoint: CSP_REPORT_PATH,
|
|
138
|
+
log: (message, severity) => this.log(message, severity)
|
|
139
|
+
}));
|
|
140
|
+
this.#cspMerged = null;
|
|
141
|
+
if (this.#cspFragments !== null && this.#cspFragments.size > 0) {
|
|
142
|
+
this.#rebuildSecurityHeaders();
|
|
143
|
+
for (const [name, fragment] of this.#cspFragments) this.#warnInertScriptSources(name, fragment);
|
|
144
|
+
}
|
|
145
|
+
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");
|
|
146
|
+
}
|
|
125
147
|
const areas = this.#config.areas;
|
|
126
148
|
if (Object.keys(areas).length) {
|
|
127
149
|
const list = [];
|
|
@@ -258,7 +280,7 @@ var Firewall = class extends Service {
|
|
|
258
280
|
* @param config - config security, utilisée en repli si le HttpKernel est absent.
|
|
259
281
|
*/
|
|
260
282
|
#transportHeaderState(config) {
|
|
261
|
-
const emitted =
|
|
283
|
+
const emitted = this.#emittedTransportHeaders();
|
|
262
284
|
if (!emitted) return {
|
|
263
285
|
hsts: config.headers.hsts,
|
|
264
286
|
hstsMaxAgeS: config.headers.hstsMaxAgeS,
|
|
@@ -597,7 +619,7 @@ var Firewall = class extends Service {
|
|
|
597
619
|
host: headerValue(headers, "host") ?? headerValue(headers, ":authority") ?? context.domain
|
|
598
620
|
});
|
|
599
621
|
if (ctx.csrfProtect && this.#csrfTokens) {
|
|
600
|
-
if (!this.#csrfTokens.verify(headerValue(headers, "x-csrf-token"),
|
|
622
|
+
if (!this.#csrfTokens.verify(headerValue(headers, "x-csrf-token"), requestCookie(context, "csrf-token"))) throw new CsrfError("Missing or invalid CSRF token");
|
|
601
623
|
}
|
|
602
624
|
}
|
|
603
625
|
/**
|
|
@@ -643,39 +665,195 @@ var Firewall = class extends Service {
|
|
|
643
665
|
/**
|
|
644
666
|
* En-têtes de sécurité APPLICATIFS (P6 J5) — CSP, Referrer-Policy, isolation
|
|
645
667
|
* cross-origin (COOP/COEP/CORP), Origin-Agent-Cluster, Permissions-Policy.
|
|
646
|
-
*
|
|
647
|
-
*
|
|
648
|
-
* SANS le ré-émettre. No-op si désactivé
|
|
668
|
+
* Appelé par la réponse juste avant son `writeHead`, une fois son type fixé :
|
|
669
|
+
* la politique suit ce type (ADR-0024 §C11). Complète le socle transport de
|
|
670
|
+
* `@nodefony/http` (nosniff/frame/HSTS) SANS le ré-émettre. No-op si désactivé
|
|
671
|
+
* ou réponse non-HTTP (WS).
|
|
649
672
|
*
|
|
650
673
|
* En-têtes constants = table figée pré-calculée au boot (0 alloc/concat). Le CSP
|
|
651
|
-
* nonce/req
|
|
674
|
+
* nonce/req ajoute 1 `join` + 1 `setHeader`, sur un document seulement.
|
|
675
|
+
*
|
|
676
|
+
* @param context - contexte de la réponse.
|
|
677
|
+
* @param contentType - `Content-Type` de la réponse ; `null` = sans contenu ;
|
|
678
|
+
* omis = type inconnu, servi comme un document (politique complète).
|
|
679
|
+
* @param servedAsFile - le corps est servi tel quel (fichier statique,
|
|
680
|
+
* relais) : aucun gabarit ne l'a écrit, il ne peut pas porter de nonce. Un
|
|
681
|
+
* document reçoit alors la politique sans nonce ni `'strict-dynamic'`
|
|
682
|
+
* (ADR-0024 §C1) ; les autres types n'en changent pas.
|
|
652
683
|
*/
|
|
653
|
-
applySecurityHeaders(context) {
|
|
684
|
+
applySecurityHeaders(context, contentType, servedAsFile = false) {
|
|
654
685
|
const sh = this.#securityHeaders;
|
|
655
686
|
if (!sh) return;
|
|
656
687
|
const response = context.response;
|
|
657
688
|
if (typeof response?.setHeader !== "function") return;
|
|
658
|
-
const
|
|
689
|
+
const extra = context.cspDirectives;
|
|
690
|
+
const tls = context.scheme === "https";
|
|
691
|
+
const validated = typeof response.setDefaultHeader === "function";
|
|
692
|
+
if (extra == null) {
|
|
693
|
+
const kind = responseKindOf(contentType);
|
|
694
|
+
if (kind !== "document" || servedAsFile) {
|
|
695
|
+
const table = kind === "document" ? sh.headersForServedFile(tls) : sh.headersForResource(kind, tls);
|
|
696
|
+
for (const name in table) {
|
|
697
|
+
const value = table[name];
|
|
698
|
+
if (value === void 0) continue;
|
|
699
|
+
if (validated) response.setDefaultHeader?.(name, value);
|
|
700
|
+
else response.setHeader(name, value);
|
|
701
|
+
}
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
const headers = sh.headersFor(tls, extra != null && !sh.hasNonce);
|
|
659
706
|
for (const name in headers) {
|
|
660
707
|
const value = headers[name];
|
|
661
|
-
if (value
|
|
708
|
+
if (value === void 0) continue;
|
|
709
|
+
if (validated) response.setDefaultHeader?.(name, value);
|
|
710
|
+
else response.setHeader(name, value);
|
|
662
711
|
}
|
|
663
|
-
|
|
712
|
+
let csp = null;
|
|
664
713
|
if (sh.hasNonce) {
|
|
665
714
|
const nonce = context.cspNonce;
|
|
666
|
-
|
|
667
|
-
} else if (extra)
|
|
715
|
+
csp = extra ? sh.cspForExtra(nonce, extra, tls) : sh.cspFor(nonce, tls);
|
|
716
|
+
} else if (extra) csp = sh.cspForExtra("", extra, tls);
|
|
717
|
+
if (csp !== null) {
|
|
718
|
+
if (validated) response.setDefaultHeader?.("Content-Security-Policy", csp);
|
|
719
|
+
else response.setHeader("Content-Security-Policy", csp);
|
|
720
|
+
}
|
|
721
|
+
if (sh.hasNonce) {
|
|
722
|
+
if (validated) response.setDefaultHeader?.("Cache-Control", NONCED_DOCUMENT_CACHE_CONTROL);
|
|
723
|
+
else response.setHeader("Cache-Control", NONCED_DOCUMENT_CACHE_CONTROL);
|
|
724
|
+
}
|
|
668
725
|
}
|
|
669
726
|
/**
|
|
670
727
|
* Déclare des directives CSP additionnelles pour `moduleName` (cf `IFirewall`).
|
|
671
728
|
* No-op si les en-têtes applicatifs sont désactivés (pas de CSP à étendre).
|
|
672
729
|
* Recompose `#securityHeaders` (merge + re-split nonce) — hors hot-path.
|
|
673
730
|
*/
|
|
674
|
-
registerCspOrigins(moduleName,
|
|
731
|
+
registerCspOrigins(moduleName, declared) {
|
|
732
|
+
const config = this.#config;
|
|
733
|
+
if (config !== null && this.#securityHeaders === null) return;
|
|
734
|
+
try {
|
|
735
|
+
assertCspFragment(declared, moduleName);
|
|
736
|
+
} catch (error) {
|
|
737
|
+
this.log(`CSP : contribution de ${moduleName} IGNORÉE — ${error instanceof Error ? error.message : String(error)}`, "ERROR");
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
const { fragment, dropped } = withoutOverbroadSources(declared);
|
|
741
|
+
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");
|
|
742
|
+
const fragments = this.#cspFragments ??= /* @__PURE__ */ new Map();
|
|
743
|
+
if (config === null) {
|
|
744
|
+
fragments.set(moduleName, fragment);
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
this.#warnInertScriptSources(moduleName, fragment);
|
|
748
|
+
const previous = fragments.get(moduleName);
|
|
749
|
+
if (previous !== void 0 || this.#cspMerged === null && fragments.size > 0) {
|
|
750
|
+
fragments.set(moduleName, fragment);
|
|
751
|
+
try {
|
|
752
|
+
this.#rebuildSecurityHeaders();
|
|
753
|
+
} catch (error) {
|
|
754
|
+
if (previous === void 0) fragments.delete(moduleName);
|
|
755
|
+
else fragments.set(moduleName, previous);
|
|
756
|
+
throw error;
|
|
757
|
+
}
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
const csp = mergeCspFragments(this.#cspMerged ?? this.#cspBase, [fragment]);
|
|
761
|
+
fragments.set(moduleName, fragment);
|
|
762
|
+
this.#cspMerged = csp;
|
|
763
|
+
this.#securityHeaders = this.#newSecurityHeaders({
|
|
764
|
+
...config.headers,
|
|
765
|
+
csp
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
/**
|
|
769
|
+
* Le noyau HTTP a recalculé ses en-têtes de transport (édition à chaud de sa
|
|
770
|
+
* config) : la politique suit `frameOptions`, qui pilote aussi
|
|
771
|
+
* `frame-ancestors` (ADR-0024 §C5). Hors chemin chaud.
|
|
772
|
+
*/
|
|
773
|
+
onTransportHeadersChanged() {
|
|
675
774
|
if (!this.#securityHeaders || !this.#config) return;
|
|
676
|
-
|
|
775
|
+
const base = this.#composeCspBase(this.#config.headers.csp);
|
|
776
|
+
if (base === this.#cspBase) return;
|
|
777
|
+
this.#cspBase = base;
|
|
677
778
|
this.#rebuildSecurityHeaders();
|
|
678
779
|
}
|
|
780
|
+
/**
|
|
781
|
+
* Politique de base : la config, complétée de `frame-ancestors` dérivé de ce
|
|
782
|
+
* que le noyau HTTP émet en `X-Frame-Options`. Lu chez l’émetteur, par son
|
|
783
|
+
* contrat d’introspection ; sans noyau HTTP, rien n’est dérivé.
|
|
784
|
+
*/
|
|
785
|
+
#composeCspBase(csp) {
|
|
786
|
+
const emitted = this.#emittedTransportHeaders();
|
|
787
|
+
const framed = emitted ? withFrameAncestors(csp, emitted.frameOptions) : csp;
|
|
788
|
+
const sources = this.#appCspSources;
|
|
789
|
+
const merged = sources === null || csp.trim() === "" ? framed : mergeCspFragments(framed, sources);
|
|
790
|
+
if (this.#cspReportUrl === null) {
|
|
791
|
+
this.#reportingEndpoints = null;
|
|
792
|
+
return merged;
|
|
793
|
+
}
|
|
794
|
+
const reported = withViolationReports(merged, this.#cspReportUrl, CSP_REPORT_GROUP);
|
|
795
|
+
this.#reportingEndpoints = reported.added ? `${CSP_REPORT_GROUP}="${this.#cspReportUrl}"` : null;
|
|
796
|
+
return reported.csp;
|
|
797
|
+
}
|
|
798
|
+
/**
|
|
799
|
+
* Lit `headers.cspReport` : `true` = la route du framework, une chaîne = le
|
|
800
|
+
* collecteur de l’application, `false` = aucun rapport. Une URL qu’un en-tête
|
|
801
|
+
* ne peut pas porter est RETIRÉE et dite (ADR-0024 C0 : une faute de
|
|
802
|
+
* configuration de sécurité n’empêche pas le démarrage).
|
|
803
|
+
*/
|
|
804
|
+
#validatedCspReportUrl(value) {
|
|
805
|
+
if (value === false) return null;
|
|
806
|
+
if (value === true) return CSP_REPORT_PATH;
|
|
807
|
+
const problem = reportUrlProblem(value);
|
|
808
|
+
if (problem === null) return value;
|
|
809
|
+
this.log(`CSP : headers.cspReport « ${value.slice(0, 120)} » IGNORÉE — ${problem} ; aucun rapport de violation demandé.`, "ERROR");
|
|
810
|
+
return null;
|
|
811
|
+
}
|
|
812
|
+
/**
|
|
813
|
+
* Dit, une fois, les sources de scripts d'un module que `'strict-dynamic'`
|
|
814
|
+
* rend inertes (cf `findInertScriptSources`) : gardées pour un navigateur
|
|
815
|
+
* CSP2, elles n'ouvrent rien aux autres — le module doit charger son script
|
|
816
|
+
* depuis un script signé.
|
|
817
|
+
*
|
|
818
|
+
* @param moduleName - module qui a déclaré le fragment
|
|
819
|
+
* @param fragment - fragment retenu (sources trop larges déjà retirées)
|
|
820
|
+
*/
|
|
821
|
+
#warnInertScriptSources(moduleName, fragment) {
|
|
822
|
+
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");
|
|
823
|
+
}
|
|
824
|
+
/**
|
|
825
|
+
* Valide les contributions nommées de `headers.cspSources`, une fois. Une
|
|
826
|
+
* contribution malformée est IGNORÉE et dite (l'application démarre, plus
|
|
827
|
+
* stricte) ; une source ouverte à tout hôte est GARDÉE — choix écrit par
|
|
828
|
+
* l'application, comme sa politique — et dite.
|
|
829
|
+
*
|
|
830
|
+
* @param declared - contributions par nom
|
|
831
|
+
* @returns les fragments retenus, ou `null` s'il n'y en a aucun
|
|
832
|
+
*/
|
|
833
|
+
#validatedAppCspSources(declared) {
|
|
834
|
+
let kept = null;
|
|
835
|
+
for (const [name, fragment] of Object.entries(declared)) {
|
|
836
|
+
try {
|
|
837
|
+
assertCspFragment(fragment, name);
|
|
838
|
+
} catch (error) {
|
|
839
|
+
this.log(`CSP : headers.cspSources.${name} IGNORÉE — ${error instanceof Error ? error.message : String(error)}`, "ERROR");
|
|
840
|
+
continue;
|
|
841
|
+
}
|
|
842
|
+
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");
|
|
843
|
+
(kept ??= []).push(fragment);
|
|
844
|
+
}
|
|
845
|
+
return kept;
|
|
846
|
+
}
|
|
847
|
+
/**
|
|
848
|
+
* En-têtes de transport que le noyau HTTP ÉMET, lus par son contrat
|
|
849
|
+
* d’introspection, jamais par ses champs internes : `security` ne dépend pas
|
|
850
|
+
* de la mécanique de cache du kernel HTTP, seulement de ce qu’il déclare.
|
|
851
|
+
*
|
|
852
|
+
* @returns les valeurs émises, `undefined` sans noyau HTTP au conteneur.
|
|
853
|
+
*/
|
|
854
|
+
#emittedTransportHeaders() {
|
|
855
|
+
return (this.container?.get("HttpKernel"))?.describeTransportSecurityHeaders?.();
|
|
856
|
+
}
|
|
679
857
|
/** Retire les directives CSP de `moduleName` et recompose si nécessaire. */
|
|
680
858
|
unregisterCspOrigins(moduleName) {
|
|
681
859
|
if (!this.#cspFragments?.delete(moduleName)) return;
|
|
@@ -690,13 +868,27 @@ var Firewall = class extends Service {
|
|
|
690
868
|
#rebuildSecurityHeaders() {
|
|
691
869
|
const headers = this.#config?.headers;
|
|
692
870
|
if (!headers) return;
|
|
693
|
-
const
|
|
694
|
-
this.#
|
|
871
|
+
const base = this.#cspBase;
|
|
872
|
+
const csp = this.#cspFragments && this.#cspFragments.size > 0 ? mergeCspFragments(base, this.#cspFragments.values()) : base;
|
|
873
|
+
this.#cspMerged = csp === base ? null : csp;
|
|
874
|
+
this.#securityHeaders = this.#newSecurityHeaders({
|
|
695
875
|
...headers,
|
|
696
876
|
csp
|
|
697
877
|
});
|
|
698
878
|
}
|
|
699
879
|
/**
|
|
880
|
+
* Construit la table des en-têtes applicatifs, branchée sur le journal : un
|
|
881
|
+
* `@Csp` de route hors grammaire est IGNORÉ (politique de base servie) et dit.
|
|
882
|
+
*/
|
|
883
|
+
#newSecurityHeaders(headers) {
|
|
884
|
+
return new SecurityHeaders({
|
|
885
|
+
...headers,
|
|
886
|
+
reportingEndpoints: this.#reportingEndpoints
|
|
887
|
+
}, (error) => {
|
|
888
|
+
this.log(`CSP : directives @Csp d’une route IGNORÉES, la politique de base est servie — ${error instanceof Error ? error.message : String(error)}`, "ERROR");
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
/**
|
|
700
892
|
* Exécute la chaîne d'authenticators de la zone selon son `mode`.
|
|
701
893
|
*
|
|
702
894
|
* `first` : le premier dont `supports()` est vrai authentifie — un credential
|
|
@@ -792,11 +984,11 @@ var Firewall = class extends Service {
|
|
|
792
984
|
return;
|
|
793
985
|
}
|
|
794
986
|
}
|
|
795
|
-
response.setHeader("WWW-Authenticate",
|
|
987
|
+
response.setHeader("WWW-Authenticate", SESSION_AUTH_CHALLENGE);
|
|
796
988
|
}
|
|
797
989
|
log(pci, severity, msgid, msg) {
|
|
798
990
|
if (!msgid) msgid = logColor.cyan("FIREWALL");
|
|
799
991
|
return super.log(pci, severity, msgid, msg);
|
|
800
992
|
}
|
|
801
993
|
};
|
|
802
|
-
export { Firewall, Firewall as default };
|
|
994
|
+
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
|
}
|