@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,9 +1,15 @@
|
|
|
1
|
-
import { AuthenticationError } from "../../errors/AuthenticationError.js";
|
|
2
1
|
import { isBoundToAccount } from "../accountBinding.js";
|
|
3
|
-
import {
|
|
2
|
+
import { AuthenticationError } from "../../errors/AuthenticationError.js";
|
|
3
|
+
import { LOCAL_ACCOUNT, UserRealtimeToken } from "../realtime/UserRealtimeToken.js";
|
|
4
|
+
import { isEphemeralSubject } from "./ExternalSubjectResolver.js";
|
|
5
|
+
import { tokenStatusOf } from "../token/tokenStatus.js";
|
|
4
6
|
import { Nodefony, RequestContext } from "nodefony";
|
|
5
7
|
import { authenticatedUser } from "@nodefony/user";
|
|
6
8
|
// nodefony/src/authenticator/FirewallRealtimeAuthenticator.ts
|
|
9
|
+
/** Le verdict booléen d'un état : seul `active` fait vivre l'identité. */
|
|
10
|
+
function toValidity(status) {
|
|
11
|
+
return async (nowMs) => await status(nowMs) === "active";
|
|
12
|
+
}
|
|
7
13
|
/**
|
|
8
14
|
* Authenticator realtime des identités résolues par le **firewall** — équivalent
|
|
9
15
|
* WS de tout ce que le pipeline HTTP sait authentifier.
|
|
@@ -90,9 +96,10 @@ var FirewallRealtimeAuthenticator = class {
|
|
|
90
96
|
if (user === null) throw new AuthenticationError("Invalid realtime session");
|
|
91
97
|
const issued = readFirewallToken();
|
|
92
98
|
const type = issuedType(issued);
|
|
93
|
-
const
|
|
99
|
+
const status = type === "session" ? null : this.#buildBearerStatus(issued, user.identifier, type);
|
|
100
|
+
const revalidate = status === null ? buildSessionRevalidator(user.identifier) : toValidity(status);
|
|
94
101
|
const scopes = issued ? issued.getScopes() : void 0;
|
|
95
|
-
return new UserRealtimeToken(user, revalidate, type, scopes, this.#accountConfirmationFor(user, type));
|
|
102
|
+
return new UserRealtimeToken(user, revalidate, type, scopes, this.#accountConfirmationFor(user, type), status, admissionSourceOf(issued, user));
|
|
96
103
|
}
|
|
97
104
|
/**
|
|
98
105
|
* Le revalidateur de l'identité que le firewall a posée sur la requête
|
|
@@ -140,46 +147,58 @@ var FirewallRealtimeAuthenticator = class {
|
|
|
140
147
|
};
|
|
141
148
|
}
|
|
142
149
|
#revalidatorFor(user, issued, type) {
|
|
143
|
-
return type === "session" ? buildSessionRevalidator(user.identifier) : this.#
|
|
150
|
+
return type === "session" ? buildSessionRevalidator(user.identifier) : toValidity(this.#buildBearerStatus(issued, user.identifier, type));
|
|
144
151
|
}
|
|
145
152
|
/**
|
|
146
|
-
*
|
|
147
|
-
*
|
|
153
|
+
* État d'une identité portée par un **jeton** (JWT, clé API, OAuth) : rejoue les
|
|
154
|
+
* trois conditions qui tuent un jeton, sans jamais parler de session, et en rend
|
|
155
|
+
* la CAUSE — `expired` ferme la socket en 4006, `revoked` en 4001 (ADR-0021 §S11).
|
|
156
|
+
*
|
|
157
|
+
* **Révoqué l'emporte sur expiré** : la liste de refus est interrogée AVANT de
|
|
158
|
+
* conclure à l'expiration, et l'ordre est celui de `tokenStatusOf`, appelé
|
|
159
|
+
* plutôt que recopié. Un jeton révoqué puis arrivé à `exp` ferme donc en 4001.
|
|
148
160
|
*
|
|
149
161
|
* `exp` est vérifié **sans personne** (la borne voyage dans le jeton) ; le store
|
|
150
162
|
* n'ajoute que la révocation *avant terme*. C'est pourquoi un store en panne ne
|
|
151
163
|
* coupe pas les sockets d'agents : ce serait une panne de disponibilité déguisée
|
|
152
164
|
* en mesure de sécurité, alors que la borne du jeton tient toujours.
|
|
153
165
|
*/
|
|
154
|
-
#
|
|
155
|
-
const claims = issued
|
|
166
|
+
#buildBearerStatus(issued, identifier, type) {
|
|
167
|
+
const claims = issued?.getAttribute("claims") ?? EMPTY_CLAIMS;
|
|
156
168
|
const expMs = secondsToMs(claims.exp);
|
|
157
169
|
const iatMs = secondsToMs(claims.iat);
|
|
158
|
-
const jti = issued
|
|
170
|
+
const jti = issued?.getAttribute("jti") ?? (typeof claims.jti === "string" ? claims.jti : null);
|
|
159
171
|
const resolveStore = this.#resolveStore;
|
|
160
172
|
if (expMs === null && (resolveStore === null || jti === null && iatMs === null)) {
|
|
161
173
|
Nodefony.getKernel()?.log(`Realtime ${type} token "${identifier}" carries neither an expiry nor any revocation handle ("jti"/"iat") — connection will be revoked (fail-closed). Ensure the authenticator forwards the token claims.`, "WARNING");
|
|
162
|
-
return () => Promise.resolve(
|
|
174
|
+
return () => Promise.resolve("revoked");
|
|
163
175
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
if (!resolveStore) return
|
|
176
|
+
/** `true` révoqué, `false` non, `null` = le store n'a pas pu répondre. */
|
|
177
|
+
const isRevoked = async () => {
|
|
178
|
+
if (!resolveStore) return false;
|
|
167
179
|
let store;
|
|
168
180
|
try {
|
|
169
181
|
store = resolveStore();
|
|
170
182
|
} catch {
|
|
171
|
-
return
|
|
183
|
+
return null;
|
|
172
184
|
}
|
|
173
|
-
if (!store) return
|
|
185
|
+
if (!store) return null;
|
|
174
186
|
try {
|
|
175
|
-
if (jti !== null && await store.isJtiDenied(jti)) return
|
|
187
|
+
if (jti !== null && await store.isJtiDenied(jti)) return true;
|
|
176
188
|
const invalidBefore = await store.getInvalidBefore(identifier);
|
|
177
|
-
|
|
178
|
-
return true;
|
|
189
|
+
return invalidBefore !== null && iatMs !== null && iatMs < invalidBefore;
|
|
179
190
|
} catch {
|
|
180
|
-
return
|
|
191
|
+
return null;
|
|
181
192
|
}
|
|
182
193
|
};
|
|
194
|
+
return async (nowMs = Date.now()) => {
|
|
195
|
+
const revoked = await isRevoked();
|
|
196
|
+
if (revoked === null && expMs === null) return "revoked";
|
|
197
|
+
return tokenStatusOf({
|
|
198
|
+
revokedAt: revoked === true ? nowMs : null,
|
|
199
|
+
expiresAt: expMs
|
|
200
|
+
}, nowMs);
|
|
201
|
+
};
|
|
183
202
|
}
|
|
184
203
|
};
|
|
185
204
|
const EMPTY_CLAIMS = Object.freeze({});
|
|
@@ -200,6 +219,29 @@ function secondsToMs(value) {
|
|
|
200
219
|
function issuedType(issued) {
|
|
201
220
|
return typeof issued?.type === "string" && issued.type ? issued.type : "session";
|
|
202
221
|
}
|
|
222
|
+
/**
|
|
223
|
+
* Émetteur et compte local de l'identité, pour l'identité d'admission temps réel (ADR-0021 §S10).
|
|
224
|
+
*
|
|
225
|
+
* L'émetteur est celui que l'authentificateur a VÉRIFIÉ et posé sur le jeton
|
|
226
|
+
* (attribut `issuer` : JWT de l'application, JWT tiers) ; `null` sans jeton
|
|
227
|
+
* porteur (session, clé d'API). Le compte est absent pour le seul appelant
|
|
228
|
+
* machine fabriqué en mode `ephemeral` — constaté sur l'objet, jamais déduit
|
|
229
|
+
* de la forme de son `id`.
|
|
230
|
+
*/
|
|
231
|
+
function admissionSourceOf(issued, user) {
|
|
232
|
+
const issuer = issued?.getAttribute("issuer");
|
|
233
|
+
const account = !isEphemeralSubject(user);
|
|
234
|
+
if (typeof issuer !== "string" || issuer === "") return account ? LOCAL_ACCOUNT : EPHEMERAL_WITHOUT_ISSUER;
|
|
235
|
+
return {
|
|
236
|
+
issuer,
|
|
237
|
+
account
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
/** Filet : un sujet éphémère sans émetteur posé (ne devrait pas exister). */
|
|
241
|
+
const EPHEMERAL_WITHOUT_ISSUER = Object.freeze({
|
|
242
|
+
issuer: null,
|
|
243
|
+
account: false
|
|
244
|
+
});
|
|
203
245
|
/** Le jeton posé dans l'ALS par `firewall.handleSecurity`, s'il y en a un. */
|
|
204
246
|
function readFirewallToken() {
|
|
205
247
|
return RequestContext.get()?.token;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { AuthenticationError } from "../../errors/AuthenticationError.js";
|
|
2
|
-
import { loadBoundAccount } from "../accountBinding.js";
|
|
3
2
|
import { UserToken } from "../token/UserToken.js";
|
|
3
|
+
import { REQUIRED_TIME_CLAIMS, isIssuedInFuture } from "../token/tokenTimeClaims.js";
|
|
4
4
|
import { bearerToken } from "./bearer.js";
|
|
5
5
|
import peekIssuer from "./peekIssuer.js";
|
|
6
|
+
import { loadBoundAccount } from "../accountBinding.js";
|
|
6
7
|
import { canonicalIssuer } from "nodefony";
|
|
7
8
|
// nodefony/src/authenticator/JwtAuthenticator.ts
|
|
8
9
|
const COMPACT_JWS = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*$/;
|
|
@@ -143,11 +144,13 @@ var JwtAuthenticator = class {
|
|
|
143
144
|
algorithms: ["EdDSA"],
|
|
144
145
|
issuer: this.#runtime.issuer,
|
|
145
146
|
audience,
|
|
146
|
-
typ: "at+jwt"
|
|
147
|
+
typ: "at+jwt",
|
|
148
|
+
requiredClaims: [...REQUIRED_TIME_CLAIMS]
|
|
147
149
|
})).payload;
|
|
148
150
|
} catch {
|
|
149
151
|
throw new AuthenticationError(INVALID_TOKEN);
|
|
150
152
|
}
|
|
153
|
+
if (isIssuedInFuture(payload, Math.floor(Date.now() / 1e3), 5)) throw new AuthenticationError(INVALID_TOKEN);
|
|
151
154
|
const sub = payload.sub;
|
|
152
155
|
const jti = payload.jti;
|
|
153
156
|
if (typeof sub !== "string" || typeof jti !== "string") throw new AuthenticationError(INVALID_TOKEN);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { AuthenticationError } from "../../errors/AuthenticationError.js";
|
|
2
|
-
import { ACCOUNT_ID_KEY, CREDENTIALS_VERSION_KEY } from "../accountBinding.js";
|
|
3
2
|
import { resolveBoundSessionIdentity } from "../sessionIdentity.js";
|
|
3
|
+
import { ACCOUNT_ID_KEY, CREDENTIALS_VERSION_KEY } from "../accountBinding.js";
|
|
4
4
|
import { UserToken } from "../token/UserToken.js";
|
|
5
5
|
// nodefony/src/authenticator/SessionAuthenticator.ts
|
|
6
6
|
/**
|
|
@@ -3,8 +3,8 @@ import { SessionAuthenticator } from "./SessionAuthenticator.js";
|
|
|
3
3
|
import { UserPasswordAuthenticator } from "./UserPasswordAuthenticator.js";
|
|
4
4
|
import { JwtAuthenticator } from "./JwtAuthenticator.js";
|
|
5
5
|
import { ApiKeyAuthenticator } from "./ApiKeyAuthenticator.js";
|
|
6
|
-
import { externalSubjectOptions } from "./ExternalSubjectResolver.js";
|
|
7
6
|
import { ExternalJwtAuthenticator } from "./ExternalJwtAuthenticator.js";
|
|
7
|
+
import { externalSubjectOptions } from "./ExternalSubjectResolver.js";
|
|
8
8
|
import { resolveJwtRuntime } from "../token/jwtRuntime.js";
|
|
9
9
|
// nodefony/src/authenticator/authenticatorRegistry.ts
|
|
10
10
|
const factories = /* @__PURE__ */ new Map();
|
package/dist/nodefony/src/csp.js
CHANGED
|
@@ -1,5 +1,138 @@
|
|
|
1
|
+
import { BootConfigurationError } from "nodefony";
|
|
1
2
|
// nodefony/src/csp.ts
|
|
2
3
|
/**
|
|
4
|
+
* Fusion de directives Content-Security-Policy — logique PURE (aucune dépendance
|
|
5
|
+
* à Vite, au frontend ni au transport). Permet à un module de DÉCLARER ses besoins
|
|
6
|
+
* CSP (ex. `@nodefony/frontend` en dev : origines Vite + `'unsafe-eval'` pour le
|
|
7
|
+
* Fast Refresh) sans que `@nodefony/security` connaisse leur sémantique : security
|
|
8
|
+
* se contente de MERGER des fragments génériques `directive → sources`.
|
|
9
|
+
*
|
|
10
|
+
* Pourquoi un merge structuré (et pas une simple concaténation) : en CSP une
|
|
11
|
+
* directive RÉPÉTÉE est ignorée après sa 1ʳᵉ occurrence (W3C CSP3 §3) → concaténer
|
|
12
|
+
* deux `script-src` perdrait le second. Il faut fusionner les sources dans UNE
|
|
13
|
+
* directive. Le token `'nonce-{{nonce}}'` est opaque (ni `;` ni espace) → préservé
|
|
14
|
+
* tel quel par parse/serialize.
|
|
15
|
+
*/
|
|
16
|
+
const DIRECTIVE_NAME = /^[A-Za-z0-9-]+$/;
|
|
17
|
+
const SOURCE_TOKEN = /^[\x21-\x2B\x2D-\x3A\x3C-\x7E]+$/;
|
|
18
|
+
const SCHEME_SOURCE = /^[A-Za-z][A-Za-z0-9+.-]*:$/;
|
|
19
|
+
const HOST_OF_SOURCE = /^(?:[A-Za-z][A-Za-z0-9+.-]*:\/\/)?([^/:]+)/;
|
|
20
|
+
const LOCAL_SCHEMES = /* @__PURE__ */ new Set([
|
|
21
|
+
"data:",
|
|
22
|
+
"blob:",
|
|
23
|
+
"filesystem:",
|
|
24
|
+
"mediastream:"
|
|
25
|
+
]);
|
|
26
|
+
const SCRIPT_DIRECTIVES = /* @__PURE__ */ new Set([
|
|
27
|
+
"script-src",
|
|
28
|
+
"script-src-elem",
|
|
29
|
+
"script-src-attr"
|
|
30
|
+
]);
|
|
31
|
+
/**
|
|
32
|
+
* Dit si une source CSP ouvre la directive à n'importe quel contenu.
|
|
33
|
+
*
|
|
34
|
+
* Trop large : `*`, un schéma réseau nu (`https:`, `wss:`…), un hôte joker seul
|
|
35
|
+
* (`https://*`, `*:443`). Restent acceptés : une origine exacte, un joker de
|
|
36
|
+
* sous-domaine borné (`https://*.example.com`), un mot-clé (`'self'`) et les
|
|
37
|
+
* schémas locaux (`blob:`, `data:`) — SAUF dans une directive de scripts :
|
|
38
|
+
* `data:` y exécute le code porté par l'URL, `blob:` celui qu'un script a
|
|
39
|
+
* fabriqué, et sans nonce c'est une injection de script.
|
|
40
|
+
*
|
|
41
|
+
* @param source - un jeton de source CSP.
|
|
42
|
+
* @param directive - la directive qui porte la source ; sans elle, les schémas
|
|
43
|
+
* locaux sont acceptés.
|
|
44
|
+
* @returns `true` si la source autorise n'importe quel hôte ou contenu.
|
|
45
|
+
*/
|
|
46
|
+
function isOverbroadSource(source, directive) {
|
|
47
|
+
if (source.startsWith("'")) return false;
|
|
48
|
+
if (SCHEME_SOURCE.test(source)) {
|
|
49
|
+
if (directive !== void 0 && SCRIPT_DIRECTIVES.has(directive.toLowerCase())) return true;
|
|
50
|
+
return !LOCAL_SCHEMES.has(source.toLowerCase());
|
|
51
|
+
}
|
|
52
|
+
return HOST_OF_SOURCE.exec(source)?.[1] === "*";
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Retire d'un fragment de MODULE ses sources trop larges (cf {@link isOverbroadSource}).
|
|
56
|
+
*
|
|
57
|
+
* Moindre privilège : une contribution GLOBALE (`Firewall.registerCspOrigins`)
|
|
58
|
+
* ne déclare que SON origine, et n'ouvre pas la politique de toute l'application.
|
|
59
|
+
* Retirer plutôt que lever : la sécurité ne bloque pas le démarrage. Ne
|
|
60
|
+
* s'applique PAS à ce que l'application écrit elle-même (sa config, un `@Csp` de
|
|
61
|
+
* route) : un choix délibéré de son auteur n'est jamais contourné en silence.
|
|
62
|
+
*
|
|
63
|
+
* @param fragment - directives déclarées par un module.
|
|
64
|
+
* @returns le fragment sans les sources trop larges, et les paires retirées.
|
|
65
|
+
*/
|
|
66
|
+
function withoutOverbroadSources(fragment) {
|
|
67
|
+
const dropped = findOverbroadSources(Object.entries(fragment));
|
|
68
|
+
if (dropped.length === 0) return {
|
|
69
|
+
fragment,
|
|
70
|
+
dropped
|
|
71
|
+
};
|
|
72
|
+
const kept = {};
|
|
73
|
+
for (const name in fragment) {
|
|
74
|
+
const sources = (fragment[name] ?? []).filter((s) => !isOverbroadSource(s, name));
|
|
75
|
+
if (sources.length > 0) kept[name] = sources;
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
fragment: kept,
|
|
79
|
+
dropped
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Relève les sources trop larges d'un jeu de directives (cf {@link isOverbroadSource}).
|
|
84
|
+
*
|
|
85
|
+
* @param directives - paires `[directive, sources]` (`parseCsp(csp)` ou `Object.entries(fragment)`).
|
|
86
|
+
* @returns les paires `[directive, source]` trop larges, dans l'ordre de lecture.
|
|
87
|
+
*/
|
|
88
|
+
function findOverbroadSources(directives) {
|
|
89
|
+
const out = [];
|
|
90
|
+
for (const [name, sources] of directives) for (const source of sources) if (isOverbroadSource(source, name)) out.push([name, source]);
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Relève les sources de scripts d'un fragment que `'strict-dynamic'` rend inertes.
|
|
95
|
+
*
|
|
96
|
+
* Sous `'strict-dynamic'` dans `script-src`, un navigateur CSP3 ignore les hôtes
|
|
97
|
+
* et les schémas : un script n'est admis que signé, ou chargé par un script
|
|
98
|
+
* signé (W3C CSP3 § « strict-dynamic »). La source reste utile à un navigateur
|
|
99
|
+
* CSP2 ; on la garde, mais son auteur doit savoir qu'elle n'ouvre rien.
|
|
100
|
+
*
|
|
101
|
+
* @param fragment - directives déclarées par un module.
|
|
102
|
+
* @param policy - politique à laquelle le fragment s'ajoute.
|
|
103
|
+
* @returns les paires `[directive, source]` inertes ; vide sans `'strict-dynamic'`.
|
|
104
|
+
*/
|
|
105
|
+
function findInertScriptSources(fragment, policy) {
|
|
106
|
+
const out = [];
|
|
107
|
+
let strictDynamic = null;
|
|
108
|
+
for (const name in fragment) {
|
|
109
|
+
if (!SCRIPT_DIRECTIVES.has(name.toLowerCase())) continue;
|
|
110
|
+
strictDynamic ??= parseCsp(policy).some(([n, sources]) => n.toLowerCase() === "script-src" && sources.includes("'strict-dynamic'"));
|
|
111
|
+
if (!strictDynamic) return out;
|
|
112
|
+
for (const source of fragment[name] ?? []) if (!source.startsWith("'")) out.push([name, source]);
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Refuse un fragment CSP qui sortirait de la grammaire CSP3 — seule garde contre
|
|
118
|
+
* l'injection de directives par une valeur de configuration.
|
|
119
|
+
*
|
|
120
|
+
* Sans elle, une source comme `https://a.example; frame-src *` ajoute une
|
|
121
|
+
* directive que personne n'a demandée, et `https://a.example, frame-src *` coupe
|
|
122
|
+
* l'en-tête en deux politiques. Appelée là où un fragment ENTRE (enregistrement
|
|
123
|
+
* d'un module, fusion d'une route `@Csp`), jamais par requête.
|
|
124
|
+
*
|
|
125
|
+
* @param fragment - directives additionnelles (`directive → sources`).
|
|
126
|
+
* @param origin - qui déclare le fragment, pour nommer la faute.
|
|
127
|
+
* @throws BootConfigurationError si un nom ou une source sort de la grammaire.
|
|
128
|
+
*/
|
|
129
|
+
function assertCspFragment(fragment, origin) {
|
|
130
|
+
for (const name in fragment) {
|
|
131
|
+
if (!DIRECTIVE_NAME.test(name)) throw new BootConfigurationError(`[@nodefony/security] CSP refusée (${origin}) — nom de directive invalide ${JSON.stringify(name)} : lettres, chiffres et « - » seulement (W3C CSP3 §2.2).`);
|
|
132
|
+
for (const source of fragment[name] ?? []) if (!SOURCE_TOKEN.test(source)) throw new BootConfigurationError(`[@nodefony/security] CSP refusée (${origin}) — source invalide ${JSON.stringify(source)} dans « ${name} » : ni espace, ni « ; », ni « , », ni caractère de contrôle (W3C CSP3 §2.2). Une source = une origine ou un mot-clé ; déclarer plusieurs sources = plusieurs entrées du tableau.`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
3
136
|
* Parse une chaîne CSP en directives ordonnées `[nom, sources[]]`. L'ordre des
|
|
4
137
|
* directives et des sources est préservé (déterminisme : header stable, tests
|
|
5
138
|
* fiables). Les séparateurs multiples / espaces superflus sont normalisés.
|
|
@@ -28,25 +161,147 @@ function serializeCsp(directives) {
|
|
|
28
161
|
* origines (jamais par requête). Le résultat repart dans `SecurityHeaders`, qui
|
|
29
162
|
* re-split autour de `{{nonce}}` au boot → 1 `join` par requête (hot-path inchangé).
|
|
30
163
|
*
|
|
164
|
+
* Chaque fragment passe {@link assertCspFragment} : une source hors grammaire
|
|
165
|
+
* lève au lieu d'injecter une directive. Aucune source n'est retirée ici : le
|
|
166
|
+
* filtre des sources trop larges ne vise que les contributions globales des
|
|
167
|
+
* modules ({@link withoutOverbroadSources}, appliqué à l'enregistrement).
|
|
168
|
+
*
|
|
31
169
|
* @param base - CSP de configuration (peut contenir `'nonce-{{nonce}}'`).
|
|
32
170
|
* @param fragments - fragments additifs (un par module enregistré).
|
|
33
171
|
* @returns la chaîne CSP fusionnée.
|
|
172
|
+
* @throws BootConfigurationError si un fragment sort de la grammaire CSP3.
|
|
34
173
|
*/
|
|
35
174
|
function mergeCspFragments(base, fragments) {
|
|
36
175
|
const directives = parseCsp(base);
|
|
37
176
|
const index = /* @__PURE__ */ new Map();
|
|
38
177
|
for (const [name, src] of directives) index.set(name, src);
|
|
39
|
-
for (const fragment of fragments)
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
sources =
|
|
45
|
-
|
|
46
|
-
|
|
178
|
+
for (const fragment of fragments) {
|
|
179
|
+
assertCspFragment(fragment, "fusion");
|
|
180
|
+
for (const name in fragment) {
|
|
181
|
+
const additions = fragment[name];
|
|
182
|
+
if (additions === void 0 || additions.length === 0) continue;
|
|
183
|
+
let sources = index.get(name);
|
|
184
|
+
if (!sources) {
|
|
185
|
+
sources = [];
|
|
186
|
+
index.set(name, sources);
|
|
187
|
+
directives.push([name, sources]);
|
|
188
|
+
}
|
|
189
|
+
for (const src of additions) if (!sources.includes(src)) sources.push(src);
|
|
47
190
|
}
|
|
48
|
-
for (const src of additions) if (!sources.includes(src)) sources.push(src);
|
|
49
191
|
}
|
|
50
192
|
return serializeCsp(directives);
|
|
51
193
|
}
|
|
52
|
-
|
|
194
|
+
/** Directive réservée aux réponses servies en TLS (ADR-0024 §C6). */
|
|
195
|
+
const UPGRADE_INSECURE_REQUESTS = "upgrade-insecure-requests";
|
|
196
|
+
/**
|
|
197
|
+
* Retire une directive d'une politique, sans toucher au reste.
|
|
198
|
+
*
|
|
199
|
+
* Sert à composer, au démarrage, la variante EN CLAIR d'une politique qui porte
|
|
200
|
+
* `upgrade-insecure-requests` : sur une page servie en `http`, la directive
|
|
201
|
+
* réécrirait chaque sous-ressource en `https`, que le serveur ne sert pas.
|
|
202
|
+
*
|
|
203
|
+
* @param csp - politique (peut contenir `'nonce-{{nonce}}'`).
|
|
204
|
+
* @param name - nom de la directive, insensible à la casse.
|
|
205
|
+
* @returns la politique sans la directive ; la MÊME chaîne si elle n'y est pas.
|
|
206
|
+
*/
|
|
207
|
+
function withoutDirective(csp, name) {
|
|
208
|
+
const wanted = name.toLowerCase();
|
|
209
|
+
const directives = parseCsp(csp);
|
|
210
|
+
const kept = directives.filter(([n]) => n.toLowerCase() !== wanted);
|
|
211
|
+
return kept.length === directives.length ? csp : serializeCsp(kept);
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Traduit la valeur de `X-Frame-Options` en source de `frame-ancestors`.
|
|
215
|
+
*
|
|
216
|
+
* Un seul réglage, `http.securityHeaders.frameOptions`, pilote les DEUX en-têtes :
|
|
217
|
+
* un navigateur CSP ignore `X-Frame-Options` dès que `frame-ancestors` est
|
|
218
|
+
* présent (W3C CSP3 § « `frame-ancestors` »), et l'inverse vaut pour un ancien
|
|
219
|
+
* navigateur. Deux réglages distincts finiraient par dire deux choses.
|
|
220
|
+
*
|
|
221
|
+
* @param frameOptions - valeur émise de `X-Frame-Options` (`null` = non émis).
|
|
222
|
+
* @returns `'none'` pour `DENY`, `'self'` pour `SAMEORIGIN`, `null` sinon
|
|
223
|
+
* (en-tête coupé, ou valeur que `frame-ancestors` ne sait pas traduire).
|
|
224
|
+
*/
|
|
225
|
+
function frameAncestorsFor(frameOptions) {
|
|
226
|
+
const value = frameOptions?.trim().toUpperCase();
|
|
227
|
+
if (value === "DENY") return "'none'";
|
|
228
|
+
if (value === "SAMEORIGIN") return "'self'";
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Ajoute à une politique la directive `frame-ancestors` dérivée de
|
|
233
|
+
* `X-Frame-Options` (cf {@link frameAncestorsFor}).
|
|
234
|
+
*
|
|
235
|
+
* L'application garde la main : une politique qui déclare DÉJÀ
|
|
236
|
+
* `frame-ancestors` est rendue telle quelle — c'est le geste qui autorise
|
|
237
|
+
* l'encadrement par une origine tierce. Une politique vide le reste (pas de CSP
|
|
238
|
+
* voulue). Appelée à la composition de la politique de base, jamais par requête.
|
|
239
|
+
*
|
|
240
|
+
* @param csp - politique de base (config `security.headers.csp`).
|
|
241
|
+
* @param frameOptions - valeur émise de `X-Frame-Options` (`null` = non émis).
|
|
242
|
+
* @returns la politique, complétée de `frame-ancestors` si besoin.
|
|
243
|
+
*/
|
|
244
|
+
function withFrameAncestors(csp, frameOptions) {
|
|
245
|
+
const source = frameAncestorsFor(frameOptions);
|
|
246
|
+
if (source === null || csp.trim() === "") return csp;
|
|
247
|
+
for (const [name] of parseCsp(csp)) if (name.toLowerCase() === "frame-ancestors") return csp;
|
|
248
|
+
let end = csp.length;
|
|
249
|
+
while (end > 0 && (csp[end - 1] === ";" || /\s/.test(csp[end - 1] ?? ""))) end--;
|
|
250
|
+
return `${csp.slice(0, end)}; frame-ancestors ${source}`;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Demande à une politique ses rapports de violation : `report-uri` (lu par
|
|
254
|
+
* tous les navigateurs, en http comme en https) ET `report-to` (Reporting API,
|
|
255
|
+
* que le navigateur n'honore que vers une origine sûre — https ou localhost).
|
|
256
|
+
* ADR-0024 §C7.
|
|
257
|
+
*
|
|
258
|
+
* L'application garde la main : une politique qui déclare DÉJÀ `report-uri` ou
|
|
259
|
+
* `report-to` est rendue telle quelle — elle a son collecteur. Une politique
|
|
260
|
+
* vide le reste.
|
|
261
|
+
*
|
|
262
|
+
* @param csp - politique de base.
|
|
263
|
+
* @param url - où poster les rapports (chemin absolu ou URL https).
|
|
264
|
+
* @param group - nom du groupe `report-to`, déclaré par `Reporting-Endpoints`.
|
|
265
|
+
* @returns la politique, et `added` vrai si les directives ont été posées ici —
|
|
266
|
+
* c'est alors à l'appelant d'émettre `Reporting-Endpoints`.
|
|
267
|
+
*/
|
|
268
|
+
function withViolationReports(csp, url, group) {
|
|
269
|
+
if (csp.trim() === "") return {
|
|
270
|
+
csp,
|
|
271
|
+
added: false
|
|
272
|
+
};
|
|
273
|
+
for (const [name] of parseCsp(csp)) {
|
|
274
|
+
const lower = name.toLowerCase();
|
|
275
|
+
if (lower === "report-uri" || lower === "report-to") return {
|
|
276
|
+
csp,
|
|
277
|
+
added: false
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
let end = csp.length;
|
|
281
|
+
while (end > 0 && (csp[end - 1] === ";" || /\s/.test(csp[end - 1] ?? ""))) end--;
|
|
282
|
+
return {
|
|
283
|
+
csp: `${csp.slice(0, end)}; report-uri ${url}; report-to ${group}`,
|
|
284
|
+
added: true
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Valide l'URL d'un collecteur de rapports écrite par l'application : un
|
|
289
|
+
* chemin absolu (`/csp`) ou une URL `https:` / `http:`, sans rien qu'un en-tête
|
|
290
|
+
* ou une directive CSP ne puisse porter (espace, `;`, `,`, guillemet).
|
|
291
|
+
*
|
|
292
|
+
* @param url - valeur de `headers.cspReport`.
|
|
293
|
+
* @returns la cause du refus, ou `null` si l'URL est utilisable.
|
|
294
|
+
*/
|
|
295
|
+
function reportUrlProblem(url) {
|
|
296
|
+
if (url === "") return "URL vide";
|
|
297
|
+
if (/[^\x21-\x7e]|[;,"\\]/u.test(url)) return "caractère qu’un en-tête ne peut pas porter";
|
|
298
|
+
if (url.startsWith("/")) return url.startsWith("//") ? "URL relative au schéma (//)" : null;
|
|
299
|
+
let parsed;
|
|
300
|
+
try {
|
|
301
|
+
parsed = new URL(url);
|
|
302
|
+
} catch {
|
|
303
|
+
return "ni chemin absolu ni URL";
|
|
304
|
+
}
|
|
305
|
+
return parsed.protocol === "https:" || parsed.protocol === "http:" ? null : `schéma ${parsed.protocol} refusé`;
|
|
306
|
+
}
|
|
307
|
+
export { UPGRADE_INSECURE_REQUESTS, assertCspFragment, findInertScriptSources, findOverbroadSources, frameAncestorsFor, isOverbroadSource, mergeCspFragments, parseCsp, reportUrlProblem, serializeCsp, withFrameAncestors, withViolationReports, withoutDirective, withoutOverbroadSources };
|