@nodefony/http 10.0.0-alpha.7 → 10.0.0-alpha.9
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.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorateMetadata.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorateParam.js +1 -1
- package/dist/index.js +2 -2
- package/dist/nodefony/service/error-renderer.js +111 -1
- package/dist/nodefony/service/http-kernel.js +2 -2
- package/dist/nodefony/service/servers/server-http.js +3 -3
- package/dist/nodefony/service/servers/server-https.js +3 -3
- package/dist/nodefony/service/servers/server-websocket-secure.js +3 -3
- package/dist/nodefony/service/servers/server-websocket.js +3 -3
- package/dist/nodefony/service/sessions/sessions-service.js +6 -5
- package/dist/nodefony/service/upload/upload-service.js +3 -3
- package/dist/types/nodefony/service/error-renderer.d.ts +14 -0
- package/docs/cookies.md +16 -16
- package/docs/observabilite.md +9 -9
- package/docs/rate-limit.md +9 -9
- package/docs/servers.md +32 -32
- package/docs/session.md +34 -34
- package/docs/upload.md +14 -14
- package/package.json +8 -8
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//#region \0@oxc-project+runtime@0.
|
|
1
|
+
//#region \0@oxc-project+runtime@0.151.0/helpers/esm/decorate.js
|
|
2
2
|
function __decorate(decorators, target, key, desc) {
|
|
3
3
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
4
4
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//#region \0@oxc-project+runtime@0.
|
|
1
|
+
//#region \0@oxc-project+runtime@0.151.0/helpers/esm/decorateMetadata.js
|
|
2
2
|
function __decorateMetadata(k, v) {
|
|
3
3
|
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
4
4
|
}
|
package/dist/index.js
CHANGED
|
@@ -21,8 +21,8 @@ import DefaultErrorRenderer, { schemaMismatchOf } from "./nodefony/service/error
|
|
|
21
21
|
import DefaultRequestLogger from "./nodefony/service/request-logger.js";
|
|
22
22
|
import JsonAuditLogger from "./nodefony/service/audit-logger.js";
|
|
23
23
|
import PrettyRequestLogger from "./nodefony/service/pretty-request-logger.js";
|
|
24
|
-
import __decorateMetadata from "./_virtual/_@oxc-project_runtime@0.
|
|
25
|
-
import __decorate from "./_virtual/_@oxc-project_runtime@0.
|
|
24
|
+
import __decorateMetadata from "./_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorateMetadata.js";
|
|
25
|
+
import __decorate from "./_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorate.js";
|
|
26
26
|
import http_kernel_default from "./nodefony/service/http-kernel.js";
|
|
27
27
|
import server_http_default from "./nodefony/service/servers/server-http.js";
|
|
28
28
|
import server_https_default from "./nodefony/service/servers/server-https.js";
|
|
@@ -145,6 +145,103 @@ function isUniqueViolation(error) {
|
|
|
145
145
|
return false;
|
|
146
146
|
}
|
|
147
147
|
/**
|
|
148
|
+
* Reconnaît une suppression refusée parce que l'entité est encore RÉFÉRENCÉE
|
|
149
|
+
* (`ReferencedEntityError` de `@nodefony/orm-core`) et rend le nom de l'entité
|
|
150
|
+
* qui la désigne.
|
|
151
|
+
*
|
|
152
|
+
* Reconnaissance **structurelle** (`name` + `referencedBy`), sans importer
|
|
153
|
+
* orm-core : `@nodefony/http` n'en dépend pas, et l'erreur reste une erreur de
|
|
154
|
+
* DONNÉES que chaque surface projette elle-même — ici en 409, un conflit d'état
|
|
155
|
+
* (RFC 9110 §15.5.10) : la requête est valide, c'est l'existence d'un enfant qui
|
|
156
|
+
* la refuse.
|
|
157
|
+
*
|
|
158
|
+
* @param error - erreur remontée par le pipeline.
|
|
159
|
+
* @returns le nom de l'entité référençante, ou `null`.
|
|
160
|
+
*/
|
|
161
|
+
function referencedByOf(error) {
|
|
162
|
+
let current = error;
|
|
163
|
+
for (let depth = 0; depth < 5 && current instanceof Error; depth += 1) {
|
|
164
|
+
const candidate = current;
|
|
165
|
+
if (candidate.name === "ReferencedEntityError" && typeof candidate.referencedBy === "string") return candidate.referencedBy;
|
|
166
|
+
current = candidate.cause;
|
|
167
|
+
}
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Types de colonne dont une valeur mal formée est un IDENTIFIANT mal formé,
|
|
172
|
+
* tel que PostgreSQL le nomme dans son message `22P02`.
|
|
173
|
+
*
|
|
174
|
+
* Restreint aux types qui portent des clés : `22P02` couvre aussi une date ou
|
|
175
|
+
* un JSON illisibles, que le contrat d'entrée rejette avant la base — les
|
|
176
|
+
* retenir ici déguiserait en faute du client une valeur fabriquée par le code.
|
|
177
|
+
*/
|
|
178
|
+
const IDENTIFIER_COLUMN_TYPES = /* @__PURE__ */ new Set([
|
|
179
|
+
"uuid",
|
|
180
|
+
"integer",
|
|
181
|
+
"bigint",
|
|
182
|
+
"smallint"
|
|
183
|
+
]);
|
|
184
|
+
/**
|
|
185
|
+
* Signature figée du message `22P02` de PostgreSQL : le type visé, puis la
|
|
186
|
+
* valeur refusée entre guillemets. Même statut que {@link SQLITE_SCHEMA_PREFIXES} :
|
|
187
|
+
* un début de message stable dans le source du moteur, lu seulement APRÈS le code.
|
|
188
|
+
*/
|
|
189
|
+
const PG_INVALID_INPUT = /^invalid input syntax for type (\w+): "(.*)"$/su;
|
|
190
|
+
/**
|
|
191
|
+
* Reconnaît un identifiant MAL FORMÉ refusé par la base, et rend la valeur fautive.
|
|
192
|
+
*
|
|
193
|
+
* Deux moteurs le disent : MongoDB par une `CastError` de Mongoose (la clé est
|
|
194
|
+
* un ObjectId), PostgreSQL par `22P02` (la clé est un `uuid` ou un entier).
|
|
195
|
+
* SQLite et MySQL rangent la clé en texte : ils ne lèvent rien, la lecture rend
|
|
196
|
+
* simplement « rien », d'où leur 404 — c'est la cohérence avec eux qui est visée.
|
|
197
|
+
*
|
|
198
|
+
* Même descente dans `cause` que {@link isUniqueViolation} (Drizzle enveloppe).
|
|
199
|
+
*
|
|
200
|
+
* @param error - erreur remontée par le pipeline.
|
|
201
|
+
* @returns la valeur refusée, ou `null` si ce n'est pas ce cas.
|
|
202
|
+
*/
|
|
203
|
+
function malformedIdentifierOf(error) {
|
|
204
|
+
let current = error;
|
|
205
|
+
for (let depth = 0; depth < 5 && current instanceof Error; depth += 1) {
|
|
206
|
+
const candidate = current;
|
|
207
|
+
if (candidate.name === "CastError" && (candidate.kind === "ObjectId" || candidate.kind === "UUID") && (typeof candidate.value === "string" || typeof candidate.value === "number")) return String(candidate.value);
|
|
208
|
+
if (String(candidate.code) === "22P02") {
|
|
209
|
+
const match = PG_INVALID_INPUT.exec(candidate.message ?? "");
|
|
210
|
+
if (match && IDENTIFIER_COLUMN_TYPES.has(match[1])) return match[2];
|
|
211
|
+
}
|
|
212
|
+
current = candidate.cause;
|
|
213
|
+
}
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Statut d'un identifiant mal formé : **404** s'il vient du CHEMIN, **400** sinon.
|
|
218
|
+
*
|
|
219
|
+
* Le chemin DÉSIGNE une ressource : `/api/posts/abc` demande une ressource qui ne
|
|
220
|
+
* peut pas exister, et SQLite comme MySQL rendent déjà 404 — le client reçoit le
|
|
221
|
+
* même statut quel que soit le moteur. Une valeur venue d'ailleurs (filtre,
|
|
222
|
+
* corps) est un PARAMÈTRE invalide : un 404 y dirait « la collection n'existe
|
|
223
|
+
* pas », ce qui est faux. On compare aux segments DÉCODÉS du chemin — l'URL est
|
|
224
|
+
* reçue encodée, la valeur est ce que la base a vu.
|
|
225
|
+
*
|
|
226
|
+
* @param value - la valeur refusée par la base.
|
|
227
|
+
* @param url - l'URL de la requête (`context.url`).
|
|
228
|
+
*/
|
|
229
|
+
function malformedIdentifierStatus(value, url) {
|
|
230
|
+
if (!url) return 400;
|
|
231
|
+
let pathname;
|
|
232
|
+
try {
|
|
233
|
+
pathname = new URL(url, "http://nodefony.invalid").pathname;
|
|
234
|
+
} catch {
|
|
235
|
+
return 400;
|
|
236
|
+
}
|
|
237
|
+
for (const segment of pathname.split("/")) try {
|
|
238
|
+
if (decodeURIComponent(segment) === value) return 404;
|
|
239
|
+
} catch {
|
|
240
|
+
if (segment === value) return 404;
|
|
241
|
+
}
|
|
242
|
+
return 400;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
148
245
|
* Codes rendus par les pilotes quand la requête porte sur une TABLE ou une
|
|
149
246
|
* COLONNE que la base n'a pas — la signature d'un schéma en retard sur le code.
|
|
150
247
|
*
|
|
@@ -298,6 +395,19 @@ var DefaultErrorRenderer = class {
|
|
|
298
395
|
conflict.stack = error.stack;
|
|
299
396
|
return new HttpError(conflict, CONFLICT_STATUS, context);
|
|
300
397
|
}
|
|
398
|
+
const referencedBy = referencedByOf(error);
|
|
399
|
+
if (referencedBy !== null) {
|
|
400
|
+
const conflict = new nodefonyError(`Conflict — this resource is still referenced by ${referencedBy}`, CONFLICT_STATUS);
|
|
401
|
+
conflict.stack = error.stack;
|
|
402
|
+
return new HttpError(conflict, CONFLICT_STATUS, context);
|
|
403
|
+
}
|
|
404
|
+
const malformed = malformedIdentifierOf(error);
|
|
405
|
+
if (malformed !== null) {
|
|
406
|
+
const status = malformedIdentifierStatus(malformed, context?.url);
|
|
407
|
+
const refused = new nodefonyError(status === 404 ? "Not Found — this identifier cannot designate any resource" : "Bad Request — malformed identifier", status);
|
|
408
|
+
refused.stack = error.stack;
|
|
409
|
+
return new HttpError(refused, status, context);
|
|
410
|
+
}
|
|
301
411
|
const code = error.code;
|
|
302
412
|
return new HttpError(error, typeof code === "number" ? code : void 0, context);
|
|
303
413
|
}
|
|
@@ -317,4 +427,4 @@ var DefaultErrorRenderer = class {
|
|
|
317
427
|
}
|
|
318
428
|
};
|
|
319
429
|
//#endregion
|
|
320
|
-
export { DefaultErrorRenderer as default, schemaMismatchOf };
|
|
430
|
+
export { DefaultErrorRenderer as default, malformedIdentifierOf, schemaMismatchOf };
|
|
@@ -12,8 +12,8 @@ import DefaultRequestLogger from "./request-logger.js";
|
|
|
12
12
|
import JsonAuditLogger from "./audit-logger.js";
|
|
13
13
|
import PrettyRequestLogger from "./pretty-request-logger.js";
|
|
14
14
|
import { resolveTraceparent } from "./trace.js";
|
|
15
|
-
import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.
|
|
16
|
-
import __decorate from "../../_virtual/_@oxc-project_runtime@0.
|
|
15
|
+
import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorateMetadata.js";
|
|
16
|
+
import __decorate from "../../_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorate.js";
|
|
17
17
|
import { GcScheduler, Module, RequestContext, Service, injectable, logColor, nodefonyError, servedUrl, writeRuntimeState } from "nodefony";
|
|
18
18
|
import cluster from "node:cluster";
|
|
19
19
|
import Ws from "ws";
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import __decorateMetadata from "../../../_virtual/_@oxc-project_runtime@0.
|
|
2
|
-
import __decorate from "../../../_virtual/_@oxc-project_runtime@0.
|
|
1
|
+
import __decorateMetadata from "../../../_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorateMetadata.js";
|
|
2
|
+
import __decorate from "../../../_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorate.js";
|
|
3
3
|
import http_kernel_default from "../http-kernel.js";
|
|
4
4
|
import { handleClientError } from "./clientError.js";
|
|
5
5
|
import { createDrainTerminator } from "./serverShutdown.js";
|
|
6
6
|
import { bindWithFallback, buildBindPlan } from "../../src/servers/portBinder.js";
|
|
7
|
-
import __decorateParam from "../../../_virtual/_@oxc-project_runtime@0.
|
|
7
|
+
import __decorateParam from "../../../_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorateParam.js";
|
|
8
8
|
import { Module, Service, extend, inject, nodefonyError } from "nodefony";
|
|
9
9
|
import http from "node:http";
|
|
10
10
|
//#region nodefony/service/servers/server-http.ts
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import __decorateMetadata from "../../../_virtual/_@oxc-project_runtime@0.
|
|
2
|
-
import __decorate from "../../../_virtual/_@oxc-project_runtime@0.
|
|
1
|
+
import __decorateMetadata from "../../../_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorateMetadata.js";
|
|
2
|
+
import __decorate from "../../../_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorate.js";
|
|
3
3
|
import http_kernel_default from "../http-kernel.js";
|
|
4
4
|
import { handleClientError } from "./clientError.js";
|
|
5
5
|
import { createDrainTerminator } from "./serverShutdown.js";
|
|
6
6
|
import { bindWithFallback, buildBindPlan } from "../../src/servers/portBinder.js";
|
|
7
|
-
import __decorateParam from "../../../_virtual/_@oxc-project_runtime@0.
|
|
7
|
+
import __decorateParam from "../../../_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorateParam.js";
|
|
8
8
|
import { Module, Service, extend, inject, nodefonyError } from "nodefony";
|
|
9
9
|
import http2 from "node:http2";
|
|
10
10
|
import https from "node:https";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import __decorateMetadata from "../../../_virtual/_@oxc-project_runtime@0.
|
|
2
|
-
import __decorate from "../../../_virtual/_@oxc-project_runtime@0.
|
|
1
|
+
import __decorateMetadata from "../../../_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorateMetadata.js";
|
|
2
|
+
import __decorate from "../../../_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorate.js";
|
|
3
3
|
import http_kernel_default from "../http-kernel.js";
|
|
4
|
-
import __decorateParam from "../../../_virtual/_@oxc-project_runtime@0.
|
|
4
|
+
import __decorateParam from "../../../_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorateParam.js";
|
|
5
5
|
import { startHeartbeat, trackPong } from "./wsHeartbeat.js";
|
|
6
6
|
import { Module, Service, inject } from "nodefony";
|
|
7
7
|
import Ws, { WebSocketServer } from "ws";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import __decorateMetadata from "../../../_virtual/_@oxc-project_runtime@0.
|
|
2
|
-
import __decorate from "../../../_virtual/_@oxc-project_runtime@0.
|
|
1
|
+
import __decorateMetadata from "../../../_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorateMetadata.js";
|
|
2
|
+
import __decorate from "../../../_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorate.js";
|
|
3
3
|
import http_kernel_default from "../http-kernel.js";
|
|
4
|
-
import __decorateParam from "../../../_virtual/_@oxc-project_runtime@0.
|
|
4
|
+
import __decorateParam from "../../../_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorateParam.js";
|
|
5
5
|
import { startHeartbeat, trackPong } from "./wsHeartbeat.js";
|
|
6
6
|
import { Module, Service, inject } from "nodefony";
|
|
7
7
|
import Ws, { WebSocketServer } from "ws";
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { SESSION_FACETS } from "../../src/session/storage/sessionFilters.js";
|
|
2
|
-
import __decorateMetadata from "../../../_virtual/_@oxc-project_runtime@0.
|
|
3
|
-
import __decorate from "../../../_virtual/_@oxc-project_runtime@0.
|
|
2
|
+
import __decorateMetadata from "../../../_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorateMetadata.js";
|
|
3
|
+
import __decorate from "../../../_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorate.js";
|
|
4
4
|
import http_kernel_default from "../http-kernel.js";
|
|
5
|
-
import __decorateParam from "../../../_virtual/_@oxc-project_runtime@0.
|
|
5
|
+
import __decorateParam from "../../../_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorateParam.js";
|
|
6
6
|
import Session from "../../src/session/session.js";
|
|
7
7
|
import MemorySessionStorage from "../../src/session/storage/MemorySessionStorage.js";
|
|
8
8
|
import RevocationGuardStorage from "../../src/session/storage/RevocationGuardStorage.js";
|
|
9
|
-
import { AUTO_STORE, EMPTY_INFRA, GcScheduler, Module, Nodefony, RequestContext, Service, countFacets, extend, inject, injectable, readStoreLocation, resolveAutoStore, runNeedsExternalServices } from "nodefony";
|
|
9
|
+
import { AUTO_STORE, EMPTY_INFRA, GcScheduler, Module, Nodefony, RequestContext, Service, countFacets, extend, inject, injectable, readStoreConnector, readStoreLocation, resolveAutoStore, runNeedsExternalServices } from "nodefony";
|
|
10
10
|
import { createHash, createHmac } from "node:crypto";
|
|
11
11
|
//#region nodefony/service/sessions/sessions-service.ts
|
|
12
12
|
var _SessionsService;
|
|
@@ -173,7 +173,8 @@ let SessionsService = class SessionsService extends Service {
|
|
|
173
173
|
available: _SessionsService.storageHandlers(),
|
|
174
174
|
reason,
|
|
175
175
|
configPath: "http.session.store",
|
|
176
|
-
location: readStoreLocation(innerStorage)
|
|
176
|
+
location: readStoreLocation(innerStorage),
|
|
177
|
+
connector: readStoreConnector(innerStorage)
|
|
177
178
|
});
|
|
178
179
|
await storage.open();
|
|
179
180
|
this.gcScheduler = new GcScheduler({
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import __decorateMetadata from "../../../_virtual/_@oxc-project_runtime@0.
|
|
2
|
-
import __decorate from "../../../_virtual/_@oxc-project_runtime@0.
|
|
1
|
+
import __decorateMetadata from "../../../_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorateMetadata.js";
|
|
2
|
+
import __decorate from "../../../_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorate.js";
|
|
3
3
|
import http_kernel_default from "../http-kernel.js";
|
|
4
|
-
import __decorateParam from "../../../_virtual/_@oxc-project_runtime@0.
|
|
4
|
+
import __decorateParam from "../../../_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorateParam.js";
|
|
5
5
|
import { Cli, FileClass, Module, Service, inject } from "nodefony";
|
|
6
6
|
import fs from "node:fs";
|
|
7
7
|
import fsp from "node:fs/promises";
|
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
import type { IErrorRenderer, IErrorHttpResult, IErrorWebsocketResult } from "../interfaces/IErrorRenderer.js";
|
|
2
2
|
import type { IHttpContext, IWebsocketContext } from "../interfaces/IContext.js";
|
|
3
|
+
/**
|
|
4
|
+
* Reconnaît un identifiant MAL FORMÉ refusé par la base, et rend la valeur fautive.
|
|
5
|
+
*
|
|
6
|
+
* Deux moteurs le disent : MongoDB par une `CastError` de Mongoose (la clé est
|
|
7
|
+
* un ObjectId), PostgreSQL par `22P02` (la clé est un `uuid` ou un entier).
|
|
8
|
+
* SQLite et MySQL rangent la clé en texte : ils ne lèvent rien, la lecture rend
|
|
9
|
+
* simplement « rien », d'où leur 404 — c'est la cohérence avec eux qui est visée.
|
|
10
|
+
*
|
|
11
|
+
* Même descente dans `cause` que {@link isUniqueViolation} (Drizzle enveloppe).
|
|
12
|
+
*
|
|
13
|
+
* @param error - erreur remontée par le pipeline.
|
|
14
|
+
* @returns la valeur refusée, ou `null` si ce n'est pas ce cas.
|
|
15
|
+
*/
|
|
16
|
+
export declare function malformedIdentifierOf(error: unknown): string | null;
|
|
3
17
|
/**
|
|
4
18
|
* Force du signal « la base ne porte pas ce que le code demande ».
|
|
5
19
|
*
|
package/docs/cookies.md
CHANGED
|
@@ -112,7 +112,7 @@ Nodefony ne se contente pas de proposer ces attributs : il **choisit des défaut
|
|
|
112
112
|
les invariants** que le navigateur exigerait de toute façon — pour que l'erreur ne parte pas sur le fil.
|
|
113
113
|
|
|
114
114
|
**Le défaut est fermé.** Un cookie créé sans options est `Secure` + `HttpOnly` + `SameSite=Lax`
|
|
115
|
-
(`cookieDefaultSettings`, `cookie.ts:
|
|
115
|
+
(`cookieDefaultSettings`, `cookie.ts:39`). Il faut **choisir** d'ouvrir (ex. `httpOnly: false` pour un
|
|
116
116
|
cookie lu en JS), jamais choisir de fermer.
|
|
117
117
|
|
|
118
118
|
**Les préfixes sont forcés, pas espérés.** Nommer un cookie `__Host-…` ne suffit pas : `serialize()`
|
|
@@ -121,7 +121,7 @@ navigateur ne rejette jamais le cookie en silence. Idem `__Secure-` (Secure impo
|
|
|
121
121
|
impose `Secure`, `cookie.ts:393`).
|
|
122
122
|
|
|
123
123
|
**La signature refuse le secret prévisible.** Un cookie `signed: true` sans secret configuré **jette**
|
|
124
|
-
(`setValue()`, `cookie.ts:
|
|
124
|
+
(`setValue()`, `cookie.ts:199`) : signer avec le secret public par défaut ne protégerait rien
|
|
125
125
|
(fail-closed). La vérification est **timing-safe** (`unsign()` → `crypto.timingSafeEqual`, `cookie.ts:380`).
|
|
126
126
|
|
|
127
127
|
**`SameSite` retombe toujours sur `Lax`, jamais sur `None`.** Toute valeur inconnue est normalisée vers
|
|
@@ -233,7 +233,7 @@ Le constructeur accepte `(nom, valeur, options?)` ou **un cookie à copier** (su
|
|
|
233
233
|
| `secret` | `string` | (par défaut) | Clé HMAC. Le secret par défaut est **refusé** pour signer. |
|
|
234
234
|
| `priority` | `High \| Medium \| Low` | `undefined` | Attribut `Priority` (`setPriority()`, `cookie.ts:297`). |
|
|
235
235
|
|
|
236
|
-
Les défauts sont matérialisés dans `cookieDefaultSettings` (`cookie.ts:
|
|
236
|
+
Les défauts sont matérialisés dans `cookieDefaultSettings` (`cookie.ts:39`) ; la fusion se fait dans le
|
|
237
237
|
constructeur de `Cookie` (`cookie.ts:130`).
|
|
238
238
|
|
|
239
239
|
### Sérialiser : `serialize()` et `serializeWebSocket()`
|
|
@@ -261,14 +261,14 @@ constructeur de `Cookie` (`cookie.ts:130`).
|
|
|
261
261
|
| Supprimer un cookie sortant | `response.deleteCookieByName("nom")` | `http/Response.ts:119` |
|
|
262
262
|
|
|
263
263
|
Côté réponse HTTP, `addCookie()` (`http/Response.ts:101`) enregistre le cookie, et `setCookies()`
|
|
264
|
-
(`http/Response.ts:
|
|
264
|
+
(`http/Response.ts:127`) émet **une ligne `Set-Cookie` par cookie** — un tableau passé à Node, jamais une
|
|
265
265
|
boucle de `setHeader` (qui écraserait tout sauf le dernier). Pour expirer un cookie chez le client :
|
|
266
|
-
`clearCookie()` (`cookie.ts:
|
|
266
|
+
`clearCookie()` (`cookie.ts:194`) recule `Expires` à l'époque.
|
|
267
267
|
|
|
268
268
|
### Parsing des cookies entrants
|
|
269
269
|
|
|
270
|
-
`cookiesParser(context)` (`cookie.ts:
|
|
271
|
-
`parser()` `cookie.ts:
|
|
270
|
+
`cookiesParser(context)` (`cookie.ts:87`) lit l'en-tête `Cookie:` (via la bibliothèque `cookie`,
|
|
271
|
+
`parser()` `cookie.ts:50`), crée un `Cookie` par entrée et l'ajoute au contexte avec `addRequestCookie()`
|
|
272
272
|
(`Context.ts:650`). Il est déclenché automatiquement par le pipeline : `parseCookies()` est appelé à
|
|
273
273
|
l'initialisation du contexte HTTP (`HttpContext.ts:190`) **et** WebSocket (`WebsocketContext.ts:170`).
|
|
274
274
|
|
|
@@ -283,7 +283,7 @@ posé pendant la **phase HTTP** qui précède l'upgrade. La forme d'un cookie d
|
|
|
283
283
|
## ⚙️ Configuration
|
|
284
284
|
|
|
285
285
|
Les cookies **applicatifs** ne se configurent pas par schéma : on les construit dans le code, avec les
|
|
286
|
-
défauts sûrs de `cookieDefaultSettings` (`cookie.ts:
|
|
286
|
+
défauts sûrs de `cookieDefaultSettings` (`cookie.ts:39`). Le seul cookie **piloté par la config** est celui
|
|
287
287
|
de la **session** — bloc Zod `sessionCookieSchema` (`config.ts:748`), avec notamment `hostPrefix`
|
|
288
288
|
(`config.ts:770`) qui décide du préfixe `__Host-`. Tout cela est documenté dans [Sessions](session.md) :
|
|
289
289
|
cette page ne le duplique pas.
|
|
@@ -295,11 +295,11 @@ Le nom effectif du cookie de session (avec ou sans `__Host-` selon le transport)
|
|
|
295
295
|
|
|
296
296
|
| Attribut / mécanisme | Faille bloquée | Comment Nodefony l'applique |
|
|
297
297
|
| -------------------------- | --------------------------------------- | ------------------------------------------------------------------------ |
|
|
298
|
-
| `HttpOnly` (défaut `true`) | Vol de cookie par **XSS** | Défaut fermé (`cookieDefaultSettings`, `cookie.ts:
|
|
298
|
+
| `HttpOnly` (défaut `true`) | Vol de cookie par **XSS** | Défaut fermé (`cookieDefaultSettings`, `cookie.ts:39`) |
|
|
299
299
|
| `SameSite=Lax` (défaut) | **CSRF** inter-site | Fallback toujours `Lax`, jamais `None` (`setSameSite()` `cookie.ts:250`) |
|
|
300
|
-
| `Secure` (défaut `true`) | Interception en clair | Forcé aussi par `None`/préfixes (`serialize()` `cookie.ts:
|
|
301
|
-
| Préfixe `__Host-` | **Session fixation** cross-sous-domaine | `Domain` retiré + `Path=/` imposés (`serialize()` `cookie.ts:
|
|
302
|
-
| Cookie signé (HMAC) | Altération de la valeur côté client | `sign()`/`unsign()` timing-safe (`cookie.ts:
|
|
300
|
+
| `Secure` (défaut `true`) | Interception en clair | Forcé aussi par `None`/préfixes (`serialize()` `cookie.ts:383`) |
|
|
301
|
+
| Préfixe `__Host-` | **Session fixation** cross-sous-domaine | `Domain` retiré + `Path=/` imposés (`serialize()` `cookie.ts:383`) |
|
|
302
|
+
| Cookie signé (HMAC) | Altération de la valeur côté client | `sign()`/`unsign()` timing-safe (`cookie.ts:355`, `cookie.ts:355`) |
|
|
303
303
|
| Refus du secret par défaut | Signature « fantôme » sans protection | Fail-closed à la signature (`setValue()` `cookie.ts:199`) |
|
|
304
304
|
|
|
305
305
|
## 📜 Normes appliquées
|
|
@@ -308,8 +308,8 @@ Le nom effectif du cookie de session (avec ou sans `__Host-` selon le transport)
|
|
|
308
308
|
| ---------------------------------- | -------------------- | ------------------------------------------------------------------ |
|
|
309
309
|
| Cookies — syntaxe `Set-Cookie` | RFC 6265bis | `serialize()` (`cookie.ts:383`) |
|
|
310
310
|
| `SameSite` — 3 valeurs canoniques | RFC 6265bis §5.4.7 | `SameSiteType` (`ICookie.ts:3`), `setSameSite()` (`cookie.ts:250`) |
|
|
311
|
-
| Préfixes `__Host-` / `__Secure-` | RFC 6265bis §4.1.3 | `serialize()` force les contraintes (`cookie.ts:
|
|
312
|
-
| `SameSite=None` impose `Secure` | RFC 6265bis | `serialize()` (`cookie.ts:
|
|
311
|
+
| Préfixes `__Host-` / `__Secure-` | RFC 6265bis §4.1.3 | `serialize()` force les contraintes (`cookie.ts:383`) |
|
|
312
|
+
| `SameSite=None` impose `Secure` | RFC 6265bis | `serialize()` (`cookie.ts:383`) |
|
|
313
313
|
| Intégrité — HMAC-SHA256, base64url | RFC 2104 / RFC 4648 | `sign()` (`cookie.ts:331`) |
|
|
314
314
|
| Vérification à temps constant | bonne pratique OWASP | `unsign()` → `timingSafeEqual` (`cookie.ts:380`) |
|
|
315
315
|
|
|
@@ -322,7 +322,7 @@ Le nom effectif du cookie de session (avec ou sans `__Host-` selon le transport)
|
|
|
322
322
|
| `new Cookie(..., { signed: true })` **jette** | Aucun `secret` configuré → refus du secret prévisible (fail-closed) | Passer un `secret` réel (`{ signed: true, secret: … }`) |
|
|
323
323
|
| Modifier un cookie entrant ne change rien côté client | Lecture (`context.cookies`) et écriture (réponse) ne sont pas symétriques | (Re)poser le cookie sur la réponse : `context.setCookie(new Cookie(…))` |
|
|
324
324
|
| Le cookie WS posé au handshake n'arrive jamais | `setCookie`/`setCookies` de la réponse WS sont des no-op (`ws`) | Poser le cookie pendant la phase HTTP avant l'upgrade (cf session) |
|
|
325
|
-
| Deux `Set-Cookie` s'écrasent, un seul survit | Un `setHeader('Set-Cookie', str)` remplace le précédent | Déjà géré : `setCookies()` passe un **tableau** (`http/Response.ts:
|
|
325
|
+
| Deux `Set-Cookie` s'écrasent, un seul survit | Un `setHeader('Set-Cookie', str)` remplace le précédent | Déjà géré : `setCookies()` passe un **tableau** (`http/Response.ts:127`) |
|
|
326
326
|
| `SameSite` mal orthographié devient `Lax` silencieusement | Fallback fail-safe sur `Lax` | Attendu — vérifier la casse ; `Strict`/`Lax`/`None` seulement |
|
|
327
327
|
| `maxAge` interprété en millisecondes | `maxAge` est en **secondes** (comme `Set-Cookie` Max-Age) | Passer des secondes (`30*24*60*60`), pas des ms |
|
|
328
328
|
|
|
@@ -330,7 +330,7 @@ Le nom effectif du cookie de session (avec ou sans `__Host-` selon le transport)
|
|
|
330
330
|
|
|
331
331
|
Il n'y a pas d'écran Studio dédié aux cookies applicatifs (le cookie **de session** est surfacé dans
|
|
332
332
|
l'écran **Sessions**). En développement, chaque écriture de cookie est journalisée en `DEBUG` par la
|
|
333
|
-
réponse HTTP (`ADD COOKIE ==> …`, `setCookie()` `http/Response.ts:
|
|
333
|
+
réponse HTTP (`ADD COOKIE ==> …`, `setCookie()` `http/Response.ts:146`) — visible via le skill
|
|
334
334
|
`nodefony-tail-error-logs` ou le Suivi de requête. Sur le fil, un `curl -i` montre les lignes `Set-Cookie`.
|
|
335
335
|
|
|
336
336
|
## 🧪 Tests & couverture
|
package/docs/observabilite.md
CHANGED
|
@@ -229,9 +229,9 @@ GET 200 /trace/whoami 3.1ms 127.0.0.1 [demo-abc]
|
|
|
229
229
|
| Adoption WS | `sanitizeRequestId(...)` au handshake (`WebsocketContext.ts:139`) | Même validation, stable sur toute la durée de la socket (handshake → close). |
|
|
230
230
|
| Réflexion HTTP/1.1 | `Response.setHeader("x-request-id", …)` (`Response.ts:153`) | Écrit dans `writeHead()`, sur **chaque** réponse. |
|
|
231
231
|
| Réflexion HTTP/2 | `this.headers["x-request-id"] = requestId` (`http2/Response.ts:71`) | Sinon les réponses du port 5152 sortiraient sans corrélation. |
|
|
232
|
-
| ALS (HTTP) | `RequestContext.run({ requestId, … })` (`http-kernel.ts:
|
|
233
|
-
| ALS (WS) | `RequestContext.run({ requestId, … })` (`http-kernel.ts:
|
|
234
|
-
| Capture dans le log | `Pdu.requestId = Pdu.requestIdProvider?.()` (`Pdu.ts:
|
|
232
|
+
| ALS (HTTP) | `RequestContext.run({ requestId, … })` (`http-kernel.ts:436`) | Ouvre la bulle → tout `Pdu` créé dedans est tagué. |
|
|
233
|
+
| ALS (WS) | `RequestContext.run({ requestId, … })` (`http-kernel.ts:436`) | Handshake **et** messages (via `AsyncResource.bind`, BUG-001). |
|
|
234
|
+
| Capture dans le log | `Pdu.requestId = Pdu.requestIdProvider?.()` (`Pdu.ts:262`) | Provider injectable branché sur l'ALS côté Node — 0 lecture côté navigateur. |
|
|
235
235
|
|
|
236
236
|
> [!IMPORTANT]
|
|
237
237
|
> Les logs de **fin** de requête (bilan `req`, `onClose`) sont émis **hors** de la bulle ALS (déjà
|
|
@@ -269,7 +269,7 @@ de bilan, au teardown, via `Context.logRequest()` (`Context.ts:595`) côté HTTP
|
|
|
269
269
|
|
|
270
270
|
Chaque frame WS (RECEIVE / SEND / BROADCAST) peut être tracée pour le Suivi de requête (Studio). Le
|
|
271
271
|
formatage du contenu est **pur** et **borné** — `formatWsLogContent()` (`wsLogContent.ts:55`), appelé par
|
|
272
|
-
`WebsocketContext.logMessageContent()` (`WebsocketContext.ts:
|
|
272
|
+
`WebsocketContext.logMessageContent()` (`WebsocketContext.ts:393`) :
|
|
273
273
|
|
|
274
274
|
- `string` → tronquée à `WS_LOG_CONTENT_CAP` (4096, `wsLogContent.ts:15`) + ellipse.
|
|
275
275
|
- **binaire** (Buffer, ArrayBuffer, TypedArray, Blob, `Buffer[]`) → résumé `[binary N B]`, **jamais**
|
|
@@ -316,7 +316,7 @@ Choisir en cinq secondes :
|
|
|
316
316
|
Le plus grand gain en dev : `méthode statut url durée remote [id]`, avec couleur du statut (vert 2xx,
|
|
317
317
|
jaune 4xx, rouge 5xx — `colorizeStatus()`, `pretty-request-logger.ts:100`) et `requestId` tronqué aux 8
|
|
318
318
|
premiers caractères (`shortId()`, `pretty-request-logger.ts:116`). La durée est dérivée des phases de
|
|
319
|
-
timing (`pretty-request-logger.ts:
|
|
319
|
+
timing (`pretty-request-logger.ts:116`).
|
|
320
320
|
|
|
321
321
|
### `JsonAuditLogger` — un PDU par requête, pour la machine
|
|
322
322
|
|
|
@@ -372,7 +372,7 @@ Les choix visibles dans le code :
|
|
|
372
372
|
- **Audit nominal coupable** — l'option `nominal` coupe le log des 2xx/3xx si le sink texte est `null`
|
|
373
373
|
(l'entrée n'atteindrait aucune destination) — ~5,9 % du profil CPU récupérés (`audit-logger.ts:104`).
|
|
374
374
|
- **Trace des frames WS gatée en prod** — `logMessageContent()` court-circuite avant toute construction
|
|
375
|
-
de chaîne hors dev (`WebsocketContext.ts:
|
|
375
|
+
de chaîne hors dev (`WebsocketContext.ts:393`) ; les events lifecycle ne créent aucun `Pdu` en production (`Context.ts:67`).
|
|
376
376
|
|
|
377
377
|
Gate mémoire avant tout commit touchant le pipeline : `npm run test:memory` (skill
|
|
378
378
|
`nodefony-check-memory-health`). Rejouer les chiffres de charge : skill `nodefony-load-test`.
|
|
@@ -404,9 +404,9 @@ instancié **qu'en dev** (fuite d'info + coût en prod).
|
|
|
404
404
|
| Domaine | Norme | Ancrage |
|
|
405
405
|
| --- | --- | --- |
|
|
406
406
|
| W3C Trace Context (`traceparent`) | W3C Trace Context | `resolveTraceparent()` (`trace.ts:83`), `parseTraceparent()` (`trace.ts:38`) |
|
|
407
|
-
| Sûreté des valeurs d'en-tête (field-value) | RFC 9110 §5.5 | `sanitizeRequestId()` allowlist (`requestId.ts:
|
|
407
|
+
| Sûreté des valeurs d'en-tête (field-value) | RFC 9110 §5.5 | `sanitizeRequestId()` allowlist (`requestId.ts:38`) |
|
|
408
408
|
| En-têtes trop volumineux / borne | anti-abus (log flooding) | `MAX_REQUEST_ID_LENGTH` (`requestId.ts:18`) |
|
|
409
|
-
| Log structuré (PDU, sévérités) | RFC 5424 | `Pdu` + `requestId`/`pid` (`Pdu.ts:
|
|
409
|
+
| Log structuré (PDU, sévérités) | RFC 5424 | `Pdu` + `requestId`/`pid` (`Pdu.ts:184`) |
|
|
410
410
|
| Sévérité dérivée du statut HTTP | RFC 9110 (catégories) | `severityFromStatus()` (`audit-logger.ts:71`) |
|
|
411
411
|
| Non-journalisation des secrets | OWASP (logging) | redaction présence-only (`audit-logger.ts:211`) |
|
|
412
412
|
|
|
@@ -420,7 +420,7 @@ instancié **qu'en dev** (fuite d'info + coût en prod).
|
|
|
420
420
|
| Le `X-Request-Id` que j'envoie n'est pas réfléchi | Valeur non conforme (espace, CR/LF, non-ASCII, > 128) → **rejetée** | Utiliser `[A-Za-z0-9._-]{1,128}` (UUID/nanoid/traceparent OK) — sinon UUID serveur |
|
|
421
421
|
| Les logs de fin de requête n'ont pas de `requestId` | Ils sont émis hors bulle ALS | Déjà géré : l'override `log()` rouvre une micro-bulle (`Context.ts:459`) |
|
|
422
422
|
| Réponse HTTP/2 sans `x-request-id` | Chemin de réponse h2 distinct du 1.1 | Déjà géré (`http2/Response.ts:71`) — le port 5152 réfléchit aussi |
|
|
423
|
-
| Pas de `traceparent` renvoyé sur un WebSocket | `ws` n'expose pas l'écriture d'en-tête au handshake | Attendu — la trace WS reste propagée en ALS (`http-kernel.ts:
|
|
423
|
+
| Pas de `traceparent` renvoyé sur un WebSocket | `ws` n'expose pas l'écriture d'en-tête au handshake | Attendu — la trace WS reste propagée en ALS (`http-kernel.ts:1348`) |
|
|
424
424
|
| Frame WS binaire loggée en `{"0":..,"1":..}` | Sérialisation naïve d'un Buffer | Déjà géré : résumé `[binary N B]` (`wsLogContent.ts:63`) |
|
|
425
425
|
| Le format de log ne change pas malgré la config | Un `setRequestLogger(...)` programmatique gagne sur la config | L'override est volontaire (last setter wins) — retirer l'appel, ou le régler |
|
|
426
426
|
| Logs d'audit trop volumineux en prod | `stack` sérialisée, ou 100 % des 2xx audités | `includeStack:false` (défaut prod) + `sampleRate` via `setRequestLogger` |
|
package/docs/rate-limit.md
CHANGED
|
@@ -203,8 +203,8 @@ flowchart TD
|
|
|
203
203
|
|
|
204
204
|
Autour de ce cœur, le kernel orchestre le cycle de vie :
|
|
205
205
|
|
|
206
|
-
- **Construction / reconfiguration** : `configureRateLimit()` (`http-kernel.ts:
|
|
207
|
-
depuis la config (`windowMs = windowS × 1000`, `http-kernel.ts:
|
|
206
|
+
- **Construction / reconfiguration** : `configureRateLimit()` (`http-kernel.ts:417`) instancie le store
|
|
207
|
+
depuis la config (`windowMs = windowS × 1000`, `http-kernel.ts:417`) et arme un `GcScheduler`
|
|
208
208
|
(`http-kernel.ts:422`) qui **purge les fenêtres expirées** hors du chemin chaud.
|
|
209
209
|
- **Émission HTTP** : sous le quota, les en-têtes `X-RateLimit-*` sont posés (`http-kernel.ts:1000`) et
|
|
210
210
|
la requête continue ; au-delà, `Retry-After` (`http-kernel.ts:1007`) puis `writeHead(429)`
|
|
@@ -219,10 +219,10 @@ schéma, écrits ici pour les montrer.
|
|
|
219
219
|
|
|
220
220
|
| Option | Type | Défaut | Effet | Chaud |
|
|
221
221
|
| ------------- | ------------ | --------- | -------------------------------------------------------------------------------- | ----- |
|
|
222
|
-
| `enabled` | bool | `false` | Arme le rate-limit (HTTP **et** handshakes WS, même compteur) (`config.ts:
|
|
223
|
-
| `windowS` | int (s) | `60` | Largeur de la fenêtre fixe ; le compteur par IP repart à zéro (`config.ts:
|
|
222
|
+
| `enabled` | bool | `false` | Arme le rate-limit (HTTP **et** handshakes WS, même compteur) (`config.ts:870`). | oui |
|
|
223
|
+
| `windowS` | int (s) | `60` | Largeur de la fenêtre fixe ; le compteur par IP repart à zéro (`config.ts:881`). | oui |
|
|
224
224
|
| `max` | int | `300` | Requêtes/IP/fenêtre ; au-delà `429` + `Retry-After` (`config.ts:892`). | oui |
|
|
225
|
-
| `maxTracked` | int (≥ 1000) | `100 000` | Borne mémoire : IP suivies ; au cap, purge puis éviction FIFO (`config.ts:
|
|
225
|
+
| `maxTracked` | int (≥ 1000) | `100 000` | Borne mémoire : IP suivies ; au cap, purge puis éviction FIFO (`config.ts:904`). | non |
|
|
226
226
|
| `gcIntervalS` | int (s) | `300` | Intervalle du balayage de purge des fenêtres expirées, hors hot-path. | non |
|
|
227
227
|
| `gcJitter` | bool | `true` | Étale le tick GC d'un jitter aléatoire (anti-thundering-herd multi-pod). | non |
|
|
228
228
|
|
|
@@ -230,7 +230,7 @@ Et un réglage **séparé**, propre au WebSocket, à la racine du module :
|
|
|
230
230
|
|
|
231
231
|
| Option | Type | Défaut | Effet | Chaud |
|
|
232
232
|
| ----------------------- | ------------- | ------ | --------------------------------------------------------------------------------------------------- | ----- |
|
|
233
|
-
| `wsMaxConnectionsPerIp` | int \| `null` | `null` | Cap de connexions WS **concurrentes** par IP ; au-delà, upgrade fermé en `1013` (`config.ts:
|
|
233
|
+
| `wsMaxConnectionsPerIp` | int \| `null` | `null` | Cap de connexions WS **concurrentes** par IP ; au-delà, upgrade fermé en `1013` (`config.ts:1067`). | oui |
|
|
234
234
|
|
|
235
235
|
> [!TIP]
|
|
236
236
|
> `max: 300` sur `windowS: 60` = **5 req/s soutenu** par IP, avec des rafales tolérées jusqu'à 300 d'un
|
|
@@ -242,14 +242,14 @@ Et un réglage **séparé**, propre au WebSocket, à la racine du module :
|
|
|
242
242
|
Un WebSocket ne peut **pas** recevoir un `429` : au moment où le rate-limit décide, le `101 Switching
|
|
243
243
|
Protocols` est déjà parti sur le fil (émis par la bibliothèque `ws`). Le refoulement se fait donc par
|
|
244
244
|
une **fermeture RFC 6455 `1013 Try Again Later`**, décidée dans `onWebsocketRequest()`
|
|
245
|
-
(`http-kernel.ts:
|
|
245
|
+
(`http-kernel.ts:1549`) — **avant** `enterScope`, l'ALS et le pipeline, comme le `429` HTTP.
|
|
246
246
|
|
|
247
247
|
Deux plafonds distincts, tous deux par IP forwarded-aware :
|
|
248
248
|
|
|
249
249
|
| Plafond | Ce qu'il borne | Source de config | Refus |
|
|
250
250
|
| ---------------------- | ---------------------------------------------- | ----------------------- | ---------------------------------------------------------- |
|
|
251
|
-
| Débit de handshakes | Ouvertures/seconde (le **même** compteur HTTP) | `rateLimit` | close `1013` (`http-kernel.ts:
|
|
252
|
-
| Connexions simultanées | Sockets **ouvertes** en même temps par IP | `wsMaxConnectionsPerIp` | close `1013` — `tryAcquire` refuse (`http-kernel.ts:
|
|
251
|
+
| Débit de handshakes | Ouvertures/seconde (le **même** compteur HTTP) | `rateLimit` | close `1013` (`http-kernel.ts:1574`) |
|
|
252
|
+
| Connexions simultanées | Sockets **ouvertes** en même temps par IP | `wsMaxConnectionsPerIp` | close `1013` — `tryAcquire` refuse (`http-kernel.ts:1583`) |
|
|
253
253
|
|
|
254
254
|
Le cap concurrent est porté par un compteur dédié, `WsConnectionCounter` (`WsConnectionCounter.ts:18`) :
|
|
255
255
|
`tryAcquire(ip)` (`WsConnectionCounter.ts:33`) réserve un créneau à l'upgrade, `release(ip)`
|
package/docs/servers.md
CHANGED
|
@@ -128,12 +128,12 @@ Nodefony crée un serveur HTTP/2 sécurisé avec `allowHTTP1: true` (`ServerHttp
|
|
|
128
128
|
|
|
129
129
|
**Le WebSocket n'est jamais un citoyen de seconde zone.** Il est adossé au serveur HTTP porteur
|
|
130
130
|
(`server-websocket.ts:80`), passe par le **même** rate-limit d'IP que les requêtes HTTP — un upgrade
|
|
131
|
-
_est_ une requête HTTP (`HttpKernel.onWebsocketRequest()`, `http-kernel.ts:
|
|
131
|
+
_est_ une requête HTTP (`HttpKernel.onWebsocketRequest()`, `http-kernel.ts:1549`) —, hérite de la même
|
|
132
132
|
session et du même firewall, et se ferme avec le même soin qu'une réponse HTTP.
|
|
133
133
|
|
|
134
134
|
> [!NOTE]
|
|
135
135
|
> Le serveur HTTP/3 (QUIC) est **réservé, pas implémenté** : la clé `http3` existe dans le schéma,
|
|
136
|
-
> marquée `reserved` (`config.ts:
|
|
136
|
+
> marquée `reserved` (`config.ts:1050`). Elle ne fait rien aujourd'hui.
|
|
137
137
|
|
|
138
138
|
## 🚀 Démarrage rapide
|
|
139
139
|
|
|
@@ -249,7 +249,7 @@ export default PingController;
|
|
|
249
249
|
|
|
250
250
|
### 4. Ce qu'on observe au boot
|
|
251
251
|
|
|
252
|
-
Le kernel démarre les serveurs à la phase `onReady` (`Kernel.ts:
|
|
252
|
+
Le kernel démarre les serveurs à la phase `onReady` (`Kernel.ts:1255`), puis affiche les URL réellement
|
|
253
253
|
en écoute — le récap de développement liste HTTP, HTTP/2, WS et WSS dans cet ordre
|
|
254
254
|
(`BootReporter.ts:389`) :
|
|
255
255
|
|
|
@@ -264,7 +264,7 @@ en écoute — le récap de développement liste HTTP, HTTP/2, WS et WSS dans ce
|
|
|
264
264
|
```
|
|
265
265
|
|
|
266
266
|
Hors écran animé (production, CI, `--debug`), ce sont les bannières par serveur qui sortent
|
|
267
|
-
(`ServerHttp.showBanner()`, `server-http.ts:
|
|
267
|
+
(`ServerHttp.showBanner()`, `server-http.ts:232`, appelées par le kernel — `Kernel.ts:561`) :
|
|
268
268
|
|
|
269
269
|
```text
|
|
270
270
|
Server Listen on http://127.0.0.1:5151 Family: IPv4 Protocol : 1.1
|
|
@@ -327,19 +327,19 @@ Choisir en cinq secondes :
|
|
|
327
327
|
| `server-websocket-secure` | `wss://` — 5152 | `server-https` actif | WebSocket adossé au serveur TLS. |
|
|
328
328
|
| `server-static` | (aucun port propre) | toujours enregistré | Fichiers statiques, en **repli** après le routing. |
|
|
329
329
|
|
|
330
|
-
L'assemblage est fait par `HttpKernel.initServers()` (`http-kernel.ts:
|
|
330
|
+
L'assemblage est fait par `HttpKernel.initServers()` (`http-kernel.ts:1046`) : chaque serveur est
|
|
331
331
|
consulté sur son drapeau `active`, un serveur désactivé est **sauté**, pas créé (`http-kernel.ts:235`).
|
|
332
332
|
Les serveurs WebSocket ne sont montés que si leur porteur l'a été.
|
|
333
333
|
|
|
334
334
|
### `server-http` — HTTP/1.1 en clair
|
|
335
335
|
|
|
336
336
|
Le plus simple, et celui qui porte le trafic en cloud-native. `ServerHttp.createServer()`
|
|
337
|
-
(`server-http.ts:
|
|
337
|
+
(`server-http.ts:74`) crée le `http.Server`, applique les réglages de transport, branche
|
|
338
338
|
`onHttpRequest`, puis écoute selon la politique de port.
|
|
339
339
|
|
|
340
340
|
- **Actif** si `servers.http` n'est pas `false` — la décision est prise dès le constructeur
|
|
341
341
|
(`server-http.ts:56`), et le port est lu de la config d'app (`ServerHttp.setPort()`,
|
|
342
|
-
`server-http.ts:
|
|
342
|
+
`server-http.ts:67`).
|
|
343
343
|
- **Réglages appliqués** : `requestTimeout` (anti slow-loris), `maxHeadersCount`, `timeout` de socket
|
|
344
344
|
(qui émet un événement `onTimeout`), `keepAliveTimeout`.
|
|
345
345
|
- **Erreurs de protocole** : l'événement `clientError` est traité explicitement
|
|
@@ -347,12 +347,12 @@ Le plus simple, et celui qui porte le trafic en cloud-native. `ServerHttp.create
|
|
|
347
347
|
|
|
348
348
|
### `server-https` — TLS, et HTTP/2 par défaut
|
|
349
349
|
|
|
350
|
-
Deux branches dans un seul service, choisies sur `servers.https.protocol` (`server-https.ts:
|
|
350
|
+
Deux branches dans un seul service, choisies sur `servers.https.protocol` (`server-https.ts:39`) :
|
|
351
351
|
|
|
352
352
|
- **`"2.0"` (défaut)** → `http2.createSecureServer` avec `allowHTTP1: true`
|
|
353
353
|
(`ServerHttps.createServerH2()`, `server-https.ts:174`). Les bornes anti-DoS HTTP/2 ne sont posées
|
|
354
354
|
**que si elles sont configurées**, pour ne pas écraser les défauts de Node
|
|
355
|
-
(`maxSessionMemory`, `server-https.ts:
|
|
355
|
+
(`maxSessionMemory`, `server-https.ts:185`).
|
|
356
356
|
Les erreurs de session et de flux sont journalisées sans tuer le serveur
|
|
357
357
|
(`sessionError`, `server-https.ts:274`).
|
|
358
358
|
- **`"1.1"`** → `https.createServer` classique.
|
|
@@ -400,7 +400,7 @@ C'est la distinction la plus utile de cette page, et celle qu'on rate le plus so
|
|
|
400
400
|
| Question | Où ça se règle | Source |
|
|
401
401
|
| ----------------------------------------- | ------------------------------ | --------------------------------------------------------- |
|
|
402
402
|
| **Quels** serveurs, sur **quels ports** ? | `servers` (config d'app) | `serversSchema` (`src/nodefony/src/config/schema.ts:149`) |
|
|
403
|
-
| **Comment** ces serveurs se comportent ? | `use("@nodefony/http", { … })` | `httpConfigSchema` (`config.ts:
|
|
403
|
+
| **Comment** ces serveurs se comportent ? | `use("@nodefony/http", { … })` | `httpConfigSchema` (`config.ts:989`) |
|
|
404
404
|
|
|
405
405
|
Autrement dit : la **topologie** est une propriété du déploiement (elle change entre le poste du dev,
|
|
406
406
|
la CI et le cluster) ; le **réglage** est une propriété de l'application.
|
|
@@ -421,7 +421,7 @@ Défauts matérialisés dans `defaultAppConfig` (`src/nodefony/src/config/defaul
|
|
|
421
421
|
|
|
422
422
|
### Niveau 2 — le transport HTTP / HTTPS
|
|
423
423
|
|
|
424
|
-
Table dérivée de `httpServerSchema` (`config.ts:
|
|
424
|
+
Table dérivée de `httpServerSchema` (`config.ts:272`) ; la section `https` reprend les mêmes clés et en
|
|
425
425
|
ajoute une (`httpsServerSchema`, `config.ts:336`).
|
|
426
426
|
|
|
427
427
|
| Option | Type | Défaut | Effet |
|
|
@@ -442,7 +442,7 @@ quelle à Node. C'est délibéré — un schéma strict effacerait silencieuseme
|
|
|
442
442
|
### Niveau 2 — HTTP/2
|
|
443
443
|
|
|
444
444
|
Depuis `http2Schema` (`config.ts:353`), appliqué seulement si défini
|
|
445
|
-
(`maxSessionMemory`, `server-https.ts:
|
|
445
|
+
(`maxSessionMemory`, `server-https.ts:185`).
|
|
446
446
|
|
|
447
447
|
| Option | Type | Défaut | Effet |
|
|
448
448
|
| ---------------------- | ---- | ------ | ----------------------------------------------------------------------------- |
|
|
@@ -452,7 +452,7 @@ Depuis `http2Schema` (`config.ts:353`), appliqué seulement si défini
|
|
|
452
452
|
### Niveau 2 — WebSocket (`websocket` et `websocketSecure`)
|
|
453
453
|
|
|
454
454
|
Depuis `websocketSchema` (`config.ts:517`). Les deux sections partagent la forme et les défauts ; le WSS
|
|
455
|
-
lit `websocketSecure` (`config.ts:
|
|
455
|
+
lit `websocketSecure` (`config.ts:1058`).
|
|
456
456
|
|
|
457
457
|
| Option | Type | Défaut | Effet |
|
|
458
458
|
| ------------------------ | ------------------- | ------- | ---------------------------------------------------------------------------------- |
|
|
@@ -484,7 +484,7 @@ panne, c'est une nuisance.
|
|
|
484
484
|
|
|
485
485
|
### La règle
|
|
486
486
|
|
|
487
|
-
`resolvePortPolicy()` (`portBinder.ts:
|
|
487
|
+
`resolvePortPolicy()` (`portBinder.ts:93`) tranche selon l'environnement, et la valeur explicite gagne
|
|
488
488
|
toujours :
|
|
489
489
|
|
|
490
490
|
| Environnement | Défaut | Pourquoi |
|
|
@@ -507,10 +507,10 @@ détails d'implémentation valent d'être connus, parce qu'ils expliquent des co
|
|
|
507
507
|
1. **On retente au `listen()`, jamais après une sonde.** Demander « le port est-il libre ? » puis
|
|
508
508
|
binder est une course : entre la réponse et le bind, un autre processus peut prendre le port. Le
|
|
509
509
|
`listen()` est atomique — on retente donc sur l'échec réel (`bindWithFallback()`,
|
|
510
|
-
`portBinder.ts:
|
|
510
|
+
`portBinder.ts:281`).
|
|
511
511
|
2. **Le port de l'autre serveur est réservé.** Si HTTP est chassé de 5151, incrémenter naïvement le
|
|
512
512
|
ferait voler 5152 à HTTPS, qui se décalerait à son tour. Les ports convoités par les autres serveurs
|
|
513
|
-
sont sautés d'emblée (`buildBindPlan()`, `portBinder.ts:
|
|
513
|
+
sont sautés d'emblée (`buildBindPlan()`, `portBinder.ts:123`, réservation `portBinder.ts:123`).
|
|
514
514
|
3. **Le gestionnaire d'erreur durable est posé APRÈS le bind.** Attaché avant, il verrait passer les
|
|
515
515
|
`EADDRINUSE` de repli et terminerait le kernel en croyant à une panne
|
|
516
516
|
(`ServerHttp.attachErrorHandler()`, `server-http.ts:186`).
|
|
@@ -537,12 +537,12 @@ serveur qui, lui, écoute très bien.
|
|
|
537
537
|
**Générer un certificat est un confort de développement, pas une fonction de production.** Nodefony
|
|
538
538
|
n'est pas une autorité de certification : en production, on fournit un vrai certificat (Let's Encrypt,
|
|
539
539
|
ingress k8s, reverse-proxy). Le service crie un avertissement si ce n'est pas le cas
|
|
540
|
-
(`Certificate.resolveStrategy()`, `certificates.ts:
|
|
540
|
+
(`Certificate.resolveStrategy()`, `certificates.ts:407`).
|
|
541
541
|
|
|
542
542
|
### Les quatre stratégies
|
|
543
543
|
|
|
544
544
|
Réglées par `certificates.strategy` (`certificatesSchema`, `config.ts:475`), résolues par
|
|
545
|
-
`Certificate.resolveStrategy()` (`certificates.ts:
|
|
545
|
+
`Certificate.resolveStrategy()` (`certificates.ts:407`).
|
|
546
546
|
|
|
547
547
|
| Stratégie | Quand l'utiliser | Ce qui se passe |
|
|
548
548
|
| --------------- | ------------------------------------------- | --------------------------------------------------------------------------- |
|
|
@@ -588,7 +588,7 @@ invalid »). Celui de Nodefony respecte les règles qui comptent :
|
|
|
588
588
|
| Exigence | Norme | Mise en œuvre |
|
|
589
589
|
| --------------------------------------- | -------------------- | --------------------------------------------------------- |
|
|
590
590
|
| Signature SHA-256, **jamais** SHA-1 | RFC 5280, CA/B Forum | `selfSigned.hash` par défaut `sha256` (`config.ts:400`) |
|
|
591
|
-
| Numéro de série aléatoire 128 bits | RFC 5280 §4.1.2.2 | `Certificate.generateSerialHex()` (`certificates.ts:
|
|
591
|
+
| Numéro de série aléatoire 128 bits | RFC 5280 §4.1.2.2 | `Certificate.generateSerialHex()` (`certificates.ts:292`) |
|
|
592
592
|
| Le SAN fait foi, pas le CN | RFC 6125 | SAN dérivé du kernel si non fourni (`config.ts:444`) |
|
|
593
593
|
| `notBefore` reculé (décalage d'horloge) | pratique | `selfSigned.backdateMinutes`, défaut 5 (`config.ts:415`) |
|
|
594
594
|
| Clé privée non lisible par tous | hygiène | `privateKeyMode` `0600` (`config.ts:499`) |
|
|
@@ -596,7 +596,7 @@ invalid »). Celui de Nodefony respecte les règles qui comptent :
|
|
|
596
596
|
### Régénération automatique
|
|
597
597
|
|
|
598
598
|
Un certificat présent sur disque n'est pas forcément **adéquat**. `Certificate.isCertAdequate()`
|
|
599
|
-
(`certificates.ts:
|
|
599
|
+
(`certificates.ts:570`) le régénère s'il est expiré, s'il est signé en SHA-1, ou si son SAN ne couvre
|
|
600
600
|
plus les noms requis — le dernier cas est celui qui sauve : changer le domaine d'écoute sans ce
|
|
601
601
|
contrôle laisserait un certificat obsolète en place indéfiniment.
|
|
602
602
|
|
|
@@ -626,7 +626,7 @@ requête. La résolution de l'IP cliente remonte la chaîne **de droite à gauch
|
|
|
626
626
|
|
|
627
627
|
Barrière testée **avant le routage**, contre l'injection d'en-tête `Host`. Le domaine canonique du
|
|
628
628
|
kernel est toujours accepté, plus le loopback en développement (`HttpKernel.compileAlias()`,
|
|
629
|
-
`http-kernel.ts:
|
|
629
|
+
`http-kernel.ts:941`). `false` (défaut) = ce socle seul ; une liste ajoute des vhosts (exact ou joker
|
|
630
630
|
d'un seul niveau, `*.cdn.example.com`) ; `true` désactive la barrière — à réserver au cas où le proxy
|
|
631
631
|
filtre déjà le `Host` (`config.ts:974`).
|
|
632
632
|
|
|
@@ -658,13 +658,13 @@ redémarrer le pod **en plein drain** par le kubelet, et casserait précisément
|
|
|
658
658
|
protéger.
|
|
659
659
|
|
|
660
660
|
Implémentation : court-circuit **total** du pipeline dans `HttpKernel.onHttpRequest()`
|
|
661
|
-
(`http-kernel.ts:
|
|
661
|
+
(`http-kernel.ts:949`) — pas de contexte, pas de portée DI, pas de session, pas de journal par sonde,
|
|
662
662
|
réponses pré-allouées (`HttpKernel.#respondHealth()`, `http-kernel.ts:473`). Et surtout : **avant le
|
|
663
663
|
rate-limit**. Un kubelet qui reçoit un `429` croit le pod mort → cascade de redémarrages.
|
|
664
664
|
|
|
665
665
|
| Option | Type | Défaut | Effet |
|
|
666
666
|
| --------------- | ------ | --------- | ---------------------------------------------------------------------- |
|
|
667
|
-
| `enabled` | bool | `true` | Expose les probes (`healthSchema`, `config.ts:
|
|
667
|
+
| `enabled` | bool | `true` | Expose les probes (`healthSchema`, `config.ts:940`). |
|
|
668
668
|
| `livenessPath` | string | `/livez` | Chemin de la sonde de vie (`livenessProbe.httpGet.path` k8s). |
|
|
669
669
|
| `readinessPath` | string | `/readyz` | Chemin de la sonde de disponibilité. |
|
|
670
670
|
| `shutdownDelay` | ms | `0` | Délai entre la bascule `503` et le début du drain (propagation du LB). |
|
|
@@ -733,7 +733,7 @@ load balancer cesse d'envoyer du trafic, et `shutdownDelay` laisse à cette info
|
|
|
733
733
|
propager.
|
|
734
734
|
|
|
735
735
|
**Étape 2 — les clients WebSocket sont prévenus.** `Websocket.terminate()` (`server-websocket.ts:117`)
|
|
736
|
-
envoie un message applicatif puis une **frame Close 1001 « Going Away »** (`server-websocket.ts:
|
|
736
|
+
envoie un message applicatif puis une **frame Close 1001 « Going Away »** (`server-websocket.ts:117`).
|
|
737
737
|
Sans elle, la coupure TCP ferait voir un **1006** au client — code réservé, jamais émis sur le fil,
|
|
738
738
|
indiscernable d'une panne réseau. Avec 1001, le client sait qu'il doit simplement se reconnecter.
|
|
739
739
|
|
|
@@ -784,13 +784,13 @@ processus à l'arrêt.
|
|
|
784
784
|
|
|
785
785
|
L'upgrade WebSocket **est** une requête HTTP : il passe donc par le **même** compteur de rate-limit par
|
|
786
786
|
IP que les requêtes ordinaires, vérifié avant toute allocation de contexte
|
|
787
|
-
(`HttpKernel.onWebsocketRequest()`, `http-kernel.ts:
|
|
787
|
+
(`HttpKernel.onWebsocketRequest()`, `http-kernel.ts:1549`). Le `101` étant déjà émis par `ws`, un `429`
|
|
788
788
|
est impossible → la connexion est fermée en **1013 « Try Again Later »**
|
|
789
|
-
(`rateLimiter`, `http-kernel.ts:
|
|
789
|
+
(`rateLimiter`, `http-kernel.ts:292`), sans
|
|
790
790
|
journalisation (un journal par handshake rejeté serait lui-même un amplificateur sous flood).
|
|
791
791
|
|
|
792
792
|
Un second plafond, **désactivé par défaut**, borne le nombre de connexions **simultanées** par IP :
|
|
793
|
-
`wsMaxConnectionsPerIp` (`config.ts:
|
|
793
|
+
`wsMaxConnectionsPerIp` (`config.ts:1067`). En cloud-native, laisser `null` et déléguer à l'edge —
|
|
794
794
|
nginx `limit_conn`, HAProxy `sc_conn_cur` — qui voit tout le trafic, rejette avant le coût du
|
|
795
795
|
descripteur et du TLS, et couvre tous les pods. Ne l'activer que sur une machine sans ingress.
|
|
796
796
|
|
|
@@ -809,7 +809,7 @@ par seconde. Les choix visibles dans le code :
|
|
|
809
809
|
- **Aucun timer par connexion** — un `setInterval` par serveur WebSocket, `unref`, et deux `number` par
|
|
810
810
|
socket (`wsHeartbeat.ts:69`).
|
|
811
811
|
- **Rien de compilé par requête** — la politique de trust-proxy, celle des `Origin` WS et les motifs de
|
|
812
|
-
`trustedHosts` sont compilés une fois et mémoïsés (`http-kernel.ts:
|
|
812
|
+
`trustedHosts` sont compilés une fois et mémoïsés (`http-kernel.ts:248`, `http-kernel.ts:248`).
|
|
813
813
|
- **Rejets avant allocation** — rate-limit HTTP et bornes WS sont vérifiés avant le contexte, la portée
|
|
814
814
|
DI et l'ALS : un flood coûte une recherche dans une table de hachage.
|
|
815
815
|
- **Probes hors pipeline** — réponses pré-allouées, aucun objet créé, aucun journal
|
|
@@ -834,18 +834,18 @@ demande le backplane realtime.
|
|
|
834
834
|
|
|
835
835
|
| Domaine | Norme | Ancrage |
|
|
836
836
|
| ------------------------------------- | ------------------ | -------------------------------------------------------------------------- |
|
|
837
|
-
| HTTP/1.1 (sémantique, message) | RFC 9110, 9112 | `node:http` + pipeline `HttpKernel.onHttpRequest()` (`http-kernel.ts:
|
|
837
|
+
| HTTP/1.1 (sémantique, message) | RFC 9110, 9112 | `node:http` + pipeline `HttpKernel.onHttpRequest()` (`http-kernel.ts:949`) |
|
|
838
838
|
| HTTP/2 | RFC 9113 | `ServerHttps.createServerH2()` (`server-https.ts:174`) |
|
|
839
839
|
| HTTP/2 Rapid Reset | CVE-2023-44487 | `maxConcurrentStreams` (`config.ts:355`) |
|
|
840
|
-
| En-têtes trop volumineux → 431 | RFC 6585 §5 | `handleClientError()` (`clientError.ts:
|
|
840
|
+
| En-têtes trop volumineux → 431 | RFC 6585 §5 | `handleClientError()` (`clientError.ts:15`) |
|
|
841
841
|
| WebSocket — protocole | RFC 6455 | `ws@8` + options (`config.ts:496`) |
|
|
842
|
-
| WebSocket — Close 1001 « Going Away » | RFC 6455 §7.4.1 | `Websocket.terminate()` (`server-websocket.ts:
|
|
842
|
+
| WebSocket — Close 1001 « Going Away » | RFC 6455 §7.4.1 | `Websocket.terminate()` (`server-websocket.ts:117`) |
|
|
843
843
|
| WebSocket — 1009 « Message Too Big » | RFC 6455 §7.4.1 | `maxPayload` (`config.ts:543`) |
|
|
844
844
|
| WebSocket — validation UTF-8 | RFC 6455 §8.1 | `skipUTF8Validation` (`config.ts:623`) |
|
|
845
845
|
| WebSocket — compression | RFC 7692 | `perMessageDeflate` (`config.ts:567`) |
|
|
846
846
|
| CSWSH (Origin au handshake) | OWASP WSTG-CLNT-10 | `HttpKernel.checkWebsocketOrigin()` (`http-kernel.ts:599`) |
|
|
847
847
|
| En-têtes forwarded | RFC 7239 | `resolveForwarded()` (`forwarded.ts:253`) |
|
|
848
|
-
| Certificat — série, SAN, extensions | RFC 5280 | `Certificate.generateSerialHex()` (`certificates.ts:
|
|
848
|
+
| Certificat — série, SAN, extensions | RFC 5280 | `Certificate.generateSerialHex()` (`certificates.ts:292`) |
|
|
849
849
|
| Certificat — identité par le SAN | RFC 6125 | `sanSchema` (`config.ts:442`) |
|
|
850
850
|
|
|
851
851
|
## ⚠️ Pièges (symptôme → cause → correction)
|
package/docs/session.md
CHANGED
|
@@ -69,7 +69,7 @@ flowchart TD
|
|
|
69
69
|
```
|
|
70
70
|
|
|
71
71
|
Le point d'activation est **unique et commun aux deux transports** : `HttpKernel.startSession()`
|
|
72
|
-
(`http-kernel.ts:
|
|
72
|
+
(`http-kernel.ts:1152`). Il commence par la garde paresseuse `if (!intent && !context.hasSession())`
|
|
73
73
|
(`http-kernel.ts:1137`) — sans intent de route ni cookie entrant, **aucune session n'est ouverte**.
|
|
74
74
|
|
|
75
75
|
## 📖 Lexique
|
|
@@ -114,11 +114,11 @@ défaut** dans Nodefony :
|
|
|
114
114
|
|
|
115
115
|
- **Vol du cookie (hijacking).** Un script injecté (XSS) ou un réseau en clair capte le cookie et
|
|
116
116
|
rejoue la session. → `HttpOnly` et `Secure` sont à `true` par défaut (`sessionCookieSchema`,
|
|
117
|
-
`config.ts:
|
|
117
|
+
`config.ts:748-755`), et le nom du cookie prend le préfixe `__Host-` dès que le transport est TLS
|
|
118
118
|
(`Context.getSessionCookieName()`, `Context.ts:714`).
|
|
119
119
|
- **Fixation.** L'attaquant pose lui-même un identifiant dans le navigateur de la victime, attend
|
|
120
120
|
qu'elle se connecte, puis réutilise **le même** identifiant. → double défense : `strictMode` rejette
|
|
121
|
-
tout identifiant inconnu du store (`Session.resume()`, `session.ts:
|
|
121
|
+
tout identifiant inconnu du store (`Session.resume()`, `session.ts:177`), et le login **régénère**
|
|
122
122
|
l'identifiant (`AuthFlow` — voir plus bas).
|
|
123
123
|
- **Exploitation prolongée d'un identifiant volé.** Une session maintenue artificiellement vivante
|
|
124
124
|
resterait exploitable indéfiniment. → l'**absolute timeout** borne l'âge depuis la création et n'est
|
|
@@ -140,11 +140,11 @@ agents (voir [Firewall](../../security/docs/firewall.md)).
|
|
|
140
140
|
|
|
141
141
|
**2. La session est paresseuse.** Elle n'existe que si une route la demande — `@UseSession`, ou la
|
|
142
142
|
seule présence d'un paramètre `@Session` — ou si un cookie arrive déjà : c'est la garde de
|
|
143
|
-
`HttpKernel.startSession()` (`http-kernel.ts:
|
|
143
|
+
`HttpKernel.startSession()` (`http-kernel.ts:1152`). Une route publique ne paie **ni lecture de store,
|
|
144
144
|
ni `Set-Cookie`**.
|
|
145
145
|
|
|
146
146
|
**3. Un seul modèle d'état pour le web et le temps réel.** Le même `startSession()` sert
|
|
147
|
-
`HttpKernel.onRequestEnd()` (`http-kernel.ts:
|
|
147
|
+
`HttpKernel.onRequestEnd()` (`http-kernel.ts:1443`) et `HttpKernel.onConnect()` (`http-kernel.ts:1711`) ;
|
|
148
148
|
l'activité HTTP **ou** WS prolonge la même session (`Session.touchIfNeeded()`, `session.ts:421`).
|
|
149
149
|
|
|
150
150
|
**4. L'administration ne voit jamais un identifiant.** Un opérateur manipule une `ref`, HMAC tronqué
|
|
@@ -284,9 +284,9 @@ Source unique des défauts : le schéma Zod `sessionSchema` (`config.ts:782`) et
|
|
|
284
284
|
| Option | Type | Défaut | Effet |
|
|
285
285
|
| ------------------- | ------- | ------------ | --------------------------------------------------------------------------------- |
|
|
286
286
|
| `store` | string | `"auto"` | Backend de persistance — voir la résolution ci-dessous (`config.ts:795`). |
|
|
287
|
-
| `name` | string | `"nodefony"` | Nom du cookie, préfixé `__Host-` selon `cookie.hostPrefix` (`config.ts:
|
|
287
|
+
| `name` | string | `"nodefony"` | Nom du cookie, préfixé `__Host-` selon `cookie.hostPrefix` (`config.ts:770`). |
|
|
288
288
|
| `strictMode` | bool | `true` | Un identifiant inconnu du store est rejeté → session neuve (anti-fixation). |
|
|
289
|
-
| `idleTimeoutS` | int ≥ 0 | `1800` | Inactivité max (30 min). `0` = pas d'expiration par inactivité (`config.ts:
|
|
289
|
+
| `idleTimeoutS` | int ≥ 0 | `1800` | Inactivité max (30 min). `0` = pas d'expiration par inactivité (`config.ts:823`). |
|
|
290
290
|
| `absoluteTimeoutS` | int ≥ 0 | `43200` | Âge max depuis la création (12 h), **jamais** prolongé. `0` = désactivé. |
|
|
291
291
|
| `gcIntervalS` | int ≥ 0 | `600` | Période de purge des sessions expirées, hors requête. `0` = timer désarmé. |
|
|
292
292
|
| `gcJitter` | bool | `true` | Décale le départ du GC par process (anti _thundering herd_ sur un store partagé). |
|
|
@@ -309,8 +309,8 @@ Source unique des défauts : le schéma Zod `sessionSchema` (`config.ts:782`) et
|
|
|
309
309
|
### Comment `store: "auto"` se résout au boot
|
|
310
310
|
|
|
311
311
|
`auto` n'est pas un store : c'est une sentinelle résolue une fois, au boot, par `resolveAutoStore()`
|
|
312
|
-
(`config/infra.ts:
|
|
313
|
-
réellement enregistrés (`SessionsService.initializeStorage()`, `sessions-service.ts:
|
|
312
|
+
(`config/infra.ts:297`), puis journalisée. Elle suit **l'infra que tu as déclarée**, bornée aux stores
|
|
313
|
+
réellement enregistrés (`SessionsService.initializeStorage()`, `sessions-service.ts:241`).
|
|
314
314
|
|
|
315
315
|
```mermaid
|
|
316
316
|
flowchart TD
|
|
@@ -337,7 +337,7 @@ Deux comportements à connaître :
|
|
|
337
337
|
|
|
338
338
|
## 🗂️ Choisir son store
|
|
339
339
|
|
|
340
|
-
Le contrat est unique — `ISessionStorage` (`ISession.ts:
|
|
340
|
+
Le contrat est unique — `ISessionStorage` (`ISession.ts:142`) — et **tous** les backends le portent.
|
|
341
341
|
Ce qui change, c'est la topologie et la façon d'expirer.
|
|
342
342
|
|
|
343
343
|
| Store | Où vit l'état | Multi-pod | Expiration idle | `total` admin | Quand le choisir |
|
|
@@ -356,7 +356,7 @@ dans une `Map` du process : elles **disparaissent au redémarrage** et ne sont *
|
|
|
356
356
|
pods — c'est un choix (mesurer le framework sans le goulot disque/SQL), pas une limite.
|
|
357
357
|
|
|
358
358
|
Il porte quand même **toute** la sémantique du contrat : `createdAt` figé à la création, `updatedAt`
|
|
359
|
-
rafraîchi par `MemorySessionStorage.touch()` (`MemorySessionStorage.ts:
|
|
359
|
+
rafraîchi par `MemorySessionStorage.touch()` (`MemorySessionStorage.ts:96`), purge sur les deux bornes
|
|
360
360
|
par `gc(idleSeconds, absoluteSeconds)` (`MemorySessionStorage.ts:99`), pagination offset à `total` exact
|
|
361
361
|
et tri déterministe (`MemorySessionStorage.listPage()`, `MemorySessionStorage.ts:161`).
|
|
362
362
|
|
|
@@ -390,12 +390,12 @@ Redis jusqu'à son TTL idle, mais elle est **refusée à la reprise**.
|
|
|
390
390
|
|
|
391
391
|
Capacités réduites, annoncées et non simulées : la pagination est **par curseur** (pas de `total`, pas
|
|
392
392
|
d'ordre global) et `countSessions()` renvoie **`-1`** = « je ne sais pas »
|
|
393
|
-
(`@nodefony/redis/nodefony/src/SessionStorage.ts:
|
|
393
|
+
(`@nodefony/redis/nodefony/src/SessionStorage.ts:303`). L'appelant affiche l'inconnu, il ne l'invente pas.
|
|
394
394
|
|
|
395
395
|
### `mongoose` — MongoDB, parité de comportement
|
|
396
396
|
|
|
397
397
|
Même sémantique que le store SQL : `findOneAndUpdate({ upsert: true })` en une passe
|
|
398
|
-
(`@nodefony/mongoose/nodefony/src/SessionStorage.ts:
|
|
398
|
+
(`@nodefony/mongoose/nodefony/src/SessionStorage.ts:195`), `touch` en `updateOne`
|
|
399
399
|
(`@nodefony/mongoose/nodefony/src/SessionStorage.ts:174`), GC en deux suppressions `$lt`
|
|
400
400
|
(`@nodefony/mongoose/nodefony/src/SessionStorage.ts:144-166`). Les horodatages sont des **nombres**
|
|
401
401
|
(epoch ms) et non des `Date` Mongo, précisément pour que le store reste interchangeable avec Drizzle.
|
|
@@ -444,7 +444,7 @@ pose le cookie et marque la session à persister.
|
|
|
444
444
|
(depuis `updated`, `session.ts:394`). Échec → `invalidate()` (`session.ts:284`) détruit l'entrée et
|
|
445
445
|
recrée une session vierge.
|
|
446
446
|
|
|
447
|
-
**Écriture minimale.** `SessionsService.saveSession()` (`sessions-service.ts:
|
|
447
|
+
**Écriture minimale.** `SessionsService.saveSession()` (`sessions-service.ts:429`) n'écrit **que** si la
|
|
448
448
|
session est `dirty` et non `readOnly` ; sinon il appelle `Session.touchIfNeeded()` (`session.ts:421`),
|
|
449
449
|
qui prolonge l'idle **sans réécrire le blob**, et seulement au-delà d'une demi-vie d'idle
|
|
450
450
|
(`session.ts:445`). Une requête de lecture coûte donc au pire un `UPDATE` d'horodatage toutes les
|
|
@@ -454,7 +454,7 @@ qui prolonge l'idle **sans réécrire le blob**, et seulement au-delà d'une dem
|
|
|
454
454
|
pour que la sauvegarde de fin de requête ne réécrive pas ce qu'on vient de supprimer. Surtout, **tout**
|
|
455
455
|
store est décoré par `RevocationGuardStorage` (`sessions-service.ts:279`) : `destroy()` pose une
|
|
456
456
|
**pierre tombale** de 5 minutes (`RevocationGuardStorage.ts:144`) qui refuse ensuite tout `write`
|
|
457
|
-
(`RevocationGuardStorage.ts:
|
|
457
|
+
(`RevocationGuardStorage.ts:151`) **et tout `touch`** (`RevocationGuardStorage.ts:151`) du même
|
|
458
458
|
identifiant — ce qui couvre la requête « en vol » d'un autre client.
|
|
459
459
|
|
|
460
460
|
**Purge hors requête.** Un `GcScheduler` est armé au `onReady`, désarmé au `onTerminate`
|
|
@@ -498,14 +498,14 @@ C'est le différenciateur du framework appliqué à l'état de session : un seul
|
|
|
498
498
|
<!-- prettier-ignore -->
|
|
499
499
|
| Aspect | HTTP | WebSocket |
|
|
500
500
|
| --- | --- | --- |
|
|
501
|
-
| Ouverture | à chaque requête — `startSession()` dans `onRequestEnd()` (`http-kernel.ts:
|
|
501
|
+
| Ouverture | à chaque requête — `startSession()` dans `onRequestEnd()` (`http-kernel.ts:1443`) | **une fois** au handshake — `startSession()` dans `onConnect()` (`http-kernel.ts:1711`) |
|
|
502
502
|
| Lecture du cookie | constructeur du contexte | constructeur, même nom effectif (`WebsocketContext.ts:172`) |
|
|
503
503
|
| Sauvegarde | fin de requête | après **chaque frame** traitée (`WebsocketContext.ts:302`) |
|
|
504
|
-
| Filet de fermeture | — | `once("onFinish")` sauve si non déjà fait (`http-kernel.ts:
|
|
504
|
+
| Filet de fermeture | — | `once("onFinish")` sauve si non déjà fait (`http-kernel.ts:1518`) |
|
|
505
505
|
| Portée ALS | une requête | **handshake + toutes les frames** (`http-kernel.ts:1495`) |
|
|
506
506
|
|
|
507
507
|
La conséquence pratique la plus utile : côté WebSocket, la bulle `AsyncLocalStorage` ouverte au
|
|
508
|
-
handshake par `RequestContext.run()` **enveloppe aussi les messages** (`http-kernel.ts:
|
|
508
|
+
handshake par `RequestContext.run()` **enveloppe aussi les messages** (`http-kernel.ts:436`). L'identité résolue une fois est donc
|
|
509
509
|
disponible à chaque frame sans relire la base — c'est ce dont profite
|
|
510
510
|
`FirewallRealtimeAuthenticator.supports()` (`FirewallRealtimeAuthenticator.ts:80`), câblé automatiquement
|
|
511
511
|
par le firewall sur les zones temps réel protégées (`firewall.ts:300`).
|
|
@@ -548,7 +548,7 @@ curseur offset. Une révocation « partout » qui en laisserait une n'est pas un
|
|
|
548
548
|
faille — on rend donc la main avec la preuve, pas l'espoir.
|
|
549
549
|
|
|
550
550
|
`destroyOwnByRef()` ferme l'IDOR **par construction** : parcours restreint aux sessions du demandeur,
|
|
551
|
-
et appartenance **re-vérifiée** avant même de comparer la `ref` (`sessions-service.ts:
|
|
551
|
+
et appartenance **re-vérifiée** avant même de comparer la `ref` (`sessions-service.ts:840`). Une
|
|
552
552
|
`ref` d'autrui est structurellement introuvable.
|
|
553
553
|
|
|
554
554
|
### Redaction — l'identifiant ne sort jamais du process
|
|
@@ -570,7 +570,7 @@ Trois barrières superposées :
|
|
|
570
570
|
| Vol par script injecté (XSS) | `HttpOnly` | `sessionCookieSchema` (`config.ts:748`) |
|
|
571
571
|
| Interception réseau | `Secure` + `__Host-` sur TLS | `getSessionCookieName()` (`Context.ts:714`) |
|
|
572
572
|
| Requête inter-sites | `SameSite=Lax` par défaut | `defaultCookieOptions` (`cookie.ts:48`) |
|
|
573
|
-
| Fixation (cookie pré-posé) | `strictMode` + régénération au login | `Session.resume()` (`session.ts:
|
|
573
|
+
| Fixation (cookie pré-posé) | `strictMode` + régénération au login | `Session.resume()` (`session.ts:177`) |
|
|
574
574
|
| Identifiant deviné | 32 octets CSPRNG (43 caractères base64url) | `Session.generateId()` (`session.ts:226`) |
|
|
575
575
|
| Session volée exploitée longtemps | absolute timeout, jamais prolongé | `absoluteTimeoutS` à la reprise (`session.ts:381`) |
|
|
576
576
|
| Session oubliée ouverte | idle timeout glissant | `idleTimeoutS` à la reprise (`session.ts:394`) |
|
|
@@ -596,30 +596,30 @@ paramètre `@Session()` suffit à déclarer l'intent.
|
|
|
596
596
|
| Renouveler l'identifiant | `session.regenerateId()` | Nouvel identifiant, état conservé (`session.ts:236`). |
|
|
597
597
|
| Savoir si une écriture aura lieu | `session.dirty` | Le drapeau de dirty-tracking (`session.ts:128`). |
|
|
598
598
|
|
|
599
|
-
**Intent de route** — `UseSession(options)` (`routerDecorators.ts:
|
|
599
|
+
**Intent de route** — `UseSession(options)` (`routerDecorators.ts:800`) s'applique à une classe **ou** à
|
|
600
600
|
une méthode ; la méthode l'emporte, par fusion et non par remplacement
|
|
601
|
-
(`resolveSessionIntent()`, `routerDecorators.ts:
|
|
601
|
+
(`resolveSessionIntent()`, `routerDecorators.ts:833`). **Une seule** option (`SessionIntent`,
|
|
602
602
|
`ISession.ts:17`) :
|
|
603
603
|
|
|
604
604
|
- `readOnly: true` — la session est reprise et lue mais **jamais** persistée ; une mutation tentée est
|
|
605
605
|
journalisée en WARNING sans écriture (`Session.save()`, `session.ts:255-264`). C'est le seul champ
|
|
606
|
-
propagé par le kernel (`http-kernel.ts:
|
|
606
|
+
propagé par le kernel (`http-kernel.ts:1194`).
|
|
607
607
|
|
|
608
|
-
En décorateur de **classe**, `@UseSession` se place **sous** `@controller` (`routerDecorators.ts:
|
|
608
|
+
En décorateur de **classe**, `@UseSession` se place **sous** `@controller` (`routerDecorators.ts:189`).
|
|
609
609
|
|
|
610
610
|
## 🧩 Extension — brancher son propre store
|
|
611
611
|
|
|
612
612
|
Le registre est une inversion de contrôle complète : `@nodefony/http` ne connaît **aucun** backend.
|
|
613
613
|
Chaque module fournisseur se déclare lui-même au chargement, par
|
|
614
|
-
`SessionsService.registerStorage(nom, ctor)` (`sessions-service.ts:
|
|
614
|
+
`SessionsService.registerStorage(nom, ctor)` (`sessions-service.ts:184`) — exactement ce que fait la
|
|
615
615
|
dernière ligne de chaque adaptateur (`@nodefony/redis/nodefony/src/SessionStorage.ts:323`).
|
|
616
616
|
|
|
617
617
|
Pour ajouter un backend :
|
|
618
618
|
|
|
619
|
-
1. Implémenter `ISessionStorage` (`ISession.ts:
|
|
619
|
+
1. Implémenter `ISessionStorage` (`ISession.ts:142`). Le **noyau obligatoire** est court :
|
|
620
620
|
`read`/`start`/`write`/`open`/`close`/`destroy`/`gc`.
|
|
621
|
-
2. Ajouter les capacités **optionnelles** utiles : `touch` (idle glissant sans réécriture, `ISession.ts:
|
|
622
|
-
`listPage` + `countSessions` (administration paginée, `ISession.ts:
|
|
621
|
+
2. Ajouter les capacités **optionnelles** utiles : `touch` (idle glissant sans réécriture, `ISession.ts:176`),
|
|
622
|
+
`listPage` + `countSessions` (administration paginée, `ISession.ts:236`), `listAll` (dump).
|
|
623
623
|
3. Appeler `SessionsService.registerStorage("mon-store", MonStore)` au chargement du module.
|
|
624
624
|
4. Exécuter les bancs de contrat partagés (`sessionStoreContract.ts`, `sessionPaginationContract.ts`)
|
|
625
625
|
contre l'implémentation — c'est ce qui garantit la parité.
|
|
@@ -639,7 +639,7 @@ Trois règles de conception se dégagent du contrat, et méritent d'être respec
|
|
|
639
639
|
| ----------------------------- | ------------------------- | ----------------------------------------------------------------------------- |
|
|
640
640
|
| Attributs et préfixes cookie | RFC 6265bis §4.1.3 | `__Host-` impose `Secure` + `Path=/`, interdit `Domain` (`cookie.ts:386-403`) |
|
|
641
641
|
| Nom du cookie selon transport | RFC 6265bis / OWASP | `getSessionCookieName()` (`Context.ts:714`) |
|
|
642
|
-
| Idle timeout | NIST SP 800-63B-4 / OWASP | défaut 1800 s, glissant par `touch` (`config.ts:
|
|
642
|
+
| Idle timeout | NIST SP 800-63B-4 / OWASP | défaut 1800 s, glissant par `touch` (`config.ts:831`) |
|
|
643
643
|
| Absolute timeout | NIST SP 800-63B-4 / OWASP | défaut 43200 s, jamais prolongé (`config.ts:808`) |
|
|
644
644
|
| Identifiant de session | OWASP Session Management | 32 octets CSPRNG, opaque (`session.ts:226`) |
|
|
645
645
|
| Identifiant hors URL | OWASP Session Management | cookie uniquement — jamais de réécriture d'URL (`session.ts:20-26`) |
|
|
@@ -651,15 +651,15 @@ Trois règles de conception se dégagent du contrat, et méritent d'être respec
|
|
|
651
651
|
Le coût d'une session est **payé seulement quand elle sert** :
|
|
652
652
|
|
|
653
653
|
- **Zéro par défaut** — sans intent ni cookie, `startSession()` sort immédiatement
|
|
654
|
-
(`http-kernel.ts:
|
|
654
|
+
(`http-kernel.ts:1152`) : ni objet `Session`, ni lecture de store.
|
|
655
655
|
- **Objet léger** — trois sacs `{}` à plat, pas de container DI par session (`session.ts:100-104`).
|
|
656
|
-
- **Zéro écriture en lecture** — le dirty-tracking court-circuite `save()` (`session.ts:
|
|
656
|
+
- **Zéro écriture en lecture** — le dirty-tracking court-circuite `save()` (`session.ts:255`) ; le
|
|
657
657
|
`touch` est throttlé à une écriture par demi-vie d'idle (`session.ts:445`).
|
|
658
658
|
- **GC hors requête** — timer déterministe avec jitter par process, à la place du tirage
|
|
659
659
|
probabiliste hérité de PHP (`sessions-service.ts:306-312`).
|
|
660
660
|
- **Révocation quasi gratuite** — la `Map` de pierres tombales est **paresseuse** : sans révocation,
|
|
661
661
|
`write` ne paie qu'une comparaison `=== null`, sans même un `Date.now()`
|
|
662
|
-
(`RevocationGuardStorage.ts:
|
|
662
|
+
(`RevocationGuardStorage.ts:163-168`).
|
|
663
663
|
- **Administration bornée** — jamais plus de `SCAN_PAGE = 200` enregistrements en mémoire
|
|
664
664
|
(`sessions-service.ts:75`), garde-fou à 5 000 pages (`sessions-service.ts:83`), parcours interrompu
|
|
665
665
|
**journalisé**.
|
|
@@ -700,7 +700,7 @@ dérivation est faite une fois par page (`sessions-service.ts` `currentSessionRe
|
|
|
700
700
|
requête ne porte pas de session (appel interne, invocation CLI).
|
|
701
701
|
|
|
702
702
|
Codes de réponse à connaître : **501** si le store courant ne sait pas s'énumérer
|
|
703
|
-
(`supportsEnumeration()` faux — `HttpAdminApi.ts:
|
|
703
|
+
(`supportsEnumeration()` faux — `HttpAdminApi.ts:62`), **503** si le service de session est absent, **404** pour une `ref` inconnue ou
|
|
704
704
|
une révocation sans effet, **401** sur `mine` sans identité.
|
|
705
705
|
|
|
706
706
|
**Écrans** — la page **Sessions** (`/nodefony/sessions`) de Studio liste les sessions vivantes par `ref` et permet la
|
|
@@ -708,7 +708,7 @@ révocation unitaire ou en masse (`@nodefony/studio/frontend/src/routes/sessions
|
|
|
708
708
|
affiche le backend réellement résolu, sa provenance et son emplacement physique : ces informations sont
|
|
709
709
|
publiées au boot par `registerStoreResolution()` (`sessions-service.ts:290`), avec le chemin du fichier
|
|
710
710
|
SQLite quand c'est pertinent (`SessionStorage.location`,
|
|
711
|
-
`@nodefony/drizzle/nodefony/src/SessionStorage.ts:
|
|
711
|
+
`@nodefony/drizzle/nodefony/src/SessionStorage.ts:55`).
|
|
712
712
|
|
|
713
713
|
## ⚠️ Pièges (symptôme → cause → correction)
|
|
714
714
|
|
package/docs/upload.md
CHANGED
|
@@ -257,8 +257,8 @@ sequenceDiagram
|
|
|
257
257
|
Les points d'implémentation qui expliquent des comportements surprenants :
|
|
258
258
|
|
|
259
259
|
1. **L'aiguillage lit le `Content-Type`, pas la méthode** — `parseRequest()`
|
|
260
|
-
(`context/http/Request.ts:
|
|
261
|
-
des méthodes parsées (`context/http/Request.ts:
|
|
260
|
+
(`context/http/Request.ts:429`). `PATCH` porte un corps comme `POST`/`PUT` : il figure dans la table
|
|
261
|
+
des méthodes parsées (`context/http/Request.ts:79`) — l'oubli laissait tout `PATCH` avec un corps vide.
|
|
262
262
|
2. **Le multipart draine sur un `finish`, après flush de tous les writes** — `streamMultipart()`
|
|
263
263
|
(`context/http/Request.ts:537`) accumule les `Promise` d'écriture disque et ne résout `{ fields, files }`
|
|
264
264
|
qu'une fois tous les fichiers fermés (`context/http/Request.ts:538`).
|
|
@@ -283,14 +283,14 @@ non-multipart n'écoute pas `upload.*`.
|
|
|
283
283
|
| `maxBodySize` | octets | `1_048_576` (1 MiB) | Plafond d'un corps **JSON / urlencoded / XML / brut** → `413`. `0` = illimité. |
|
|
284
284
|
|
|
285
285
|
Deux rideaux, tous deux `runtimeMutable` (éditable à chaud) : un **pré-check** sur `Content-Length`
|
|
286
|
-
qui rejette **avant** de lire (`enforceBodyLimit()`, `context/http/Request.ts:
|
|
286
|
+
qui rejette **avant** de lire (`enforceBodyLimit()`, `context/http/Request.ts:407`), puis un **compteur
|
|
287
287
|
en streaming** qui coupe le socket si le corps déborde sans `Content-Length` honnête — chunked ou
|
|
288
|
-
menteur (`Parser.write()`, `context/http/parser.ts:33`, dépassement `context/http/parser.ts:
|
|
289
|
-
Champ `maxBodySize` du schéma : `config/config.ts:
|
|
288
|
+
menteur (`Parser.write()`, `context/http/parser.ts:33`, dépassement `context/http/parser.ts:33`).
|
|
289
|
+
Champ `maxBodySize` du schéma : `config/config.ts:1026`.
|
|
290
290
|
|
|
291
291
|
### Fichiers multipart — `upload.*`
|
|
292
292
|
|
|
293
|
-
Table dérivée de `uploadSchema` (`config/config.ts:
|
|
293
|
+
Table dérivée de `uploadSchema` (`config/config.ts:159`). Toutes les bornes sont `runtimeMutable`.
|
|
294
294
|
|
|
295
295
|
| Option | Type | Défaut | Effet |
|
|
296
296
|
| ------------------ | -------------------------------- | ---------------------- | --------------------------------------------------------------------------------------------------- |
|
|
@@ -319,13 +319,13 @@ recommandé) et les **getters** de `Controller` (impératif). Les signatures exa
|
|
|
319
319
|
| `@UploadedFiles() f: IUploadedFile[]` | tous les fichiers (`queryFile`) | `resolveParamArg` `"files"` (`routerDecorators.ts:1240`) |
|
|
320
320
|
| `@UploadedFile() f: IUploadedFile` | le **premier** fichier | `resolveParamArg` `"file"` (`routerDecorators.ts:1239`) |
|
|
321
321
|
| `@Body() body` | tous les champs parsés (`queryPost`) | `resolveParamArg` `"body"` (`routerDecorators.ts:1178`) |
|
|
322
|
-
| `@Body("label") v` | un seul champ du body | même source, clé (`routerDecorators.ts:
|
|
323
|
-
| `@Body({ stream: true }) s: NodeJS.ReadableStream` | le **flux brut**, parse **sauté** | `resolveParamArg` stream (`routerDecorators.ts:
|
|
322
|
+
| `@Body("label") v` | un seul champ du body | même source, clé (`routerDecorators.ts:1223`) |
|
|
323
|
+
| `@Body({ stream: true }) s: NodeJS.ReadableStream` | le **flux brut**, parse **sauté** | `resolveParamArg` stream (`routerDecorators.ts:1297`) |
|
|
324
324
|
| `this.queryFile` | équivalent getter des fichiers | `Controller.queryFile` (`framework/nodefony/src/Controller.ts:239`) |
|
|
325
325
|
| `this.queryPost` | équivalent getter des champs | `Controller.queryPost` (`framework/nodefony/src/Controller.ts:248`) |
|
|
326
326
|
|
|
327
327
|
Les décorateurs `@UploadedFile` / `@UploadedFiles` sont des fabriques de paramètre
|
|
328
|
-
(`routerDecorators.ts:
|
|
328
|
+
(`routerDecorators.ts:1254`), exportées par `@nodefony/framework` ; leurs interfaces `IUploadedFile` /
|
|
329
329
|
`IParsedUploadFile` viennent de `@nodefony/http` (`interfaces/IUpload.ts:49`, `interfaces/IUpload.ts:7`).
|
|
330
330
|
|
|
331
331
|
### Un fichier uploadé — `UploadedFile`
|
|
@@ -339,7 +339,7 @@ sur disque. Ses membres utiles :
|
|
|
339
339
|
| `size` | Taille réellement écrite (octets). | `getSize()` (`service/upload/upload-service.ts:139`) |
|
|
340
340
|
| `prettySize` | Taille lisible (`« 1.2 MB »`). | `getPrettySize()` (`service/upload/upload-service.ts:143`) |
|
|
341
341
|
| `mimeType` | Type MIME déclaré (`image/png`…), sinon deviné de l'extension. | `getMimeType()` (`service/upload/upload-service.ts:153`) |
|
|
342
|
-
| `hash` / `hashAlgorithm` | Empreinte d'intégrité si `upload.hashAlgorithm` est réglé. | `interfaces/IUpload.ts:
|
|
342
|
+
| `hash` / `hashAlgorithm` | Empreinte d'intégrité si `upload.hashAlgorithm` est réglé. | `interfaces/IUpload.ts:55` |
|
|
343
343
|
| `moveAsync(target)` | Déplace le temp — **non bloquant, recommandé** dans le pipeline. | `moveAsync()` (`service/upload/upload-service.ts:193`) |
|
|
344
344
|
| `move(target)` | Variante **synchrone** (compat) — bloque l'event-loop. | `move()` (`service/upload/upload-service.ts:160`) |
|
|
345
345
|
|
|
@@ -351,7 +351,7 @@ destination est bâtie avec `filename` — d'où l'avertissement de sécurité c
|
|
|
351
351
|
Pour piper directement un très gros corps (vidéo, backup) vers le disque ou S3 sans passer par busboy ni
|
|
352
352
|
par la RAM, `@Body({ stream: true })` court-circuite le parse et injecte l'`IncomingMessage` brut (un
|
|
353
353
|
`Readable`). Le pipeline sait le sauter en amont via `routeExpectsBodyStream()`
|
|
354
|
-
(`routerDecorators.ts:
|
|
354
|
+
(`routerDecorators.ts:1379`), mémoïsé sur la route.
|
|
355
355
|
|
|
356
356
|
```ts
|
|
357
357
|
// fragment — le contrôleur pipe le flux lui-même (0 parse, 0 pic RAM)
|
|
@@ -378,7 +378,7 @@ piégé. Les défenses en place, et **ce qui reste à ta charge**.
|
|
|
378
378
|
|
|
379
379
|
> [!WARNING]
|
|
380
380
|
> **Path traversal sur la destination.** `move(dir)` / `moveAsync(dir)` vers un **dossier** construit le
|
|
381
|
-
> chemin final avec `file.filename` — le nom **client** (`service/upload/upload-service.ts:
|
|
381
|
+
> chemin final avec `file.filename` — le nom **client** (`service/upload/upload-service.ts:101`). Un nom
|
|
382
382
|
> `../../etc/cron.d/x` s'échapperait du dossier. Le framework protège le chemin du **temporaire**, pas
|
|
383
383
|
> celui que **tu** choisis. Règle : passe une **cible complète** que tu contrôles, ou assainis toujours
|
|
384
384
|
> avec `path.basename(file.filename)` — exactement le `safeName` du Démarrage rapide.
|
|
@@ -395,7 +395,7 @@ Le parsing du corps est sur le chemin de chaque requête écrivante — les choi
|
|
|
395
395
|
- **`stat` non bloquant** — `UploadedFile.create()` résout les stats du fichier en async
|
|
396
396
|
(`service/upload/upload-service.ts:130`), plus de `lstatSync` par fichier uploadé.
|
|
397
397
|
- **Listeners jumeaux nettoyés** — le drain de fin de corps retire ses écouteurs `end`/`error`/overflow
|
|
398
|
-
à la main (`once` n'auto-détache que celui qui fire) (`context/http/parser.ts:
|
|
398
|
+
à la main (`once` n'auto-détache que celui qui fire) (`context/http/parser.ts:78`).
|
|
399
399
|
- **Hash opt-in** — `hashAlgorithm: false` par défaut : zéro coût CPU tant que l'intégrité n'est pas
|
|
400
400
|
demandée.
|
|
401
401
|
|
|
@@ -423,7 +423,7 @@ pipeline : `npm run test:memory` (skill `nodefony-check-memory-health`).
|
|
|
423
423
|
| `413` sur upload sans message clair | Une borne busboy atteinte en streaming (fichier, cumul, nombre) | Vérifier les bornes `upload.*` — 413 émis sur `stream.on("limit")` (`context/http/Request.ts:481`) |
|
|
424
424
|
| Un fichier écrit `../../etc/…` après un `move` | `move(dossier)` utilise le nom **client** (`upload-service.ts:197`) | Passer une cible complète, ou `path.basename(file.filename)` |
|
|
425
425
|
| Des fichiers temporaires s'accumulent dans `uploadDir` | Le contrôleur ne déplace jamais le temp | Appeler `moveAsync()` (ou purger l'ancien temp par TTL) |
|
|
426
|
-
| `queryPost` vide sur `PATCH` | Déjà géré : `PATCH` est dans la table des méthodes parsées | Aucune — corps `PATCH` parsé comme `POST` (`context/http/Request.ts:
|
|
426
|
+
| `queryPost` vide sur `PATCH` | Déjà géré : `PATCH` est dans la table des méthodes parsées | Aucune — corps `PATCH` parsé comme `POST` (`context/http/Request.ts:78`) |
|
|
427
427
|
| Corps `latin1` mal décodé | Déjà géré : le `charset=` du `Content-Type` est honoré | Aucune — `getCharset()` normalise (`context/http/Request.ts:799`) |
|
|
428
428
|
| `multipart` sans boundary fait planter | `new Busboy()` throw synchrone | Déjà géré : bascule sur le parser brut (`context/http/Request.ts:502`) |
|
|
429
429
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nodefony/http",
|
|
3
|
-
"version": "10.0.0-alpha.
|
|
3
|
+
"version": "10.0.0-alpha.9",
|
|
4
4
|
"description": "Serveurs HTTP, HTTPS, HTTP/2 et WebSocket natifs pour Nodefony : sessions, contextes de requête, certificats TLS",
|
|
5
5
|
"author": "Christophe CAMENSULI <ccamensuli@gmail.com>",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -8,15 +8,16 @@
|
|
|
8
8
|
"types": "./dist/types/index.d.ts",
|
|
9
9
|
"exports": {
|
|
10
10
|
".": {
|
|
11
|
+
"nodefony-source": "./index.ts",
|
|
11
12
|
"types": "./dist/types/index.d.ts",
|
|
12
13
|
"import": "./dist/index.js",
|
|
13
14
|
"default": "./dist/index.js"
|
|
14
15
|
}
|
|
15
16
|
},
|
|
16
17
|
"scripts": {
|
|
17
|
-
"build": "
|
|
18
|
+
"build": "rolldown -c rolldown.config.ts && tsgo -p tsconfig.declarations.json",
|
|
18
19
|
"dev": "rolldown -c rolldown.config.ts --watch",
|
|
19
|
-
"clean": "
|
|
20
|
+
"clean": "node --eval \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
20
21
|
"test": "vitest run",
|
|
21
22
|
"test:integration": "vitest run --config vitest.integration.config.ts",
|
|
22
23
|
"test:load": "vitest run --config vitest.load.config.ts",
|
|
@@ -66,7 +67,7 @@
|
|
|
66
67
|
"@types/chai": "5.2.3",
|
|
67
68
|
"@types/mime-types": "3.0.1",
|
|
68
69
|
"@types/ms": "2.1.0",
|
|
69
|
-
"@types/node": "26.6.
|
|
70
|
+
"@types/node": "26.6.2",
|
|
70
71
|
"@types/node-forge": "1.3.14",
|
|
71
72
|
"@types/qs": "6.15.1",
|
|
72
73
|
"@types/serve-static": "2.2.0",
|
|
@@ -75,16 +76,15 @@
|
|
|
75
76
|
"@types/xml2js": "0.4.14",
|
|
76
77
|
"@vitest/coverage-v8": "5.0.1",
|
|
77
78
|
"chai": "6.2.2",
|
|
78
|
-
"nodefony": "^10.0.0-alpha.
|
|
79
|
-
"
|
|
80
|
-
"tsx": "4.23.13",
|
|
79
|
+
"nodefony": "^10.0.0-alpha.9",
|
|
80
|
+
"tsx": "4.23.15",
|
|
81
81
|
"vitest": "5.0.1"
|
|
82
82
|
},
|
|
83
83
|
"license": "Apache-2.0",
|
|
84
84
|
"readmeFilename": "README.md",
|
|
85
85
|
"contributors": [],
|
|
86
86
|
"peerDependencies": {
|
|
87
|
-
"nodefony": "^10.0.0-alpha.
|
|
87
|
+
"nodefony": "^10.0.0-alpha.9",
|
|
88
88
|
"zod": "^4.6.5"
|
|
89
89
|
},
|
|
90
90
|
"files": [
|