@nodefony/http 10.0.0-alpha.8 → 10.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- 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 +6 -4
- package/dist/nodefony/command/assetsPublishCommand.js +1 -1
- package/dist/nodefony/command/certificatesCommand.js +1 -1
- package/dist/nodefony/command/networkCommand.js +1 -1
- package/dist/nodefony/command/proxyGenerateCommand.js +1 -1
- package/dist/nodefony/config/config.js +22 -1
- package/dist/nodefony/interfaces/IRouting.js +1 -0
- package/dist/nodefony/interfaces/ISecurity.js +1 -0
- package/dist/nodefony/service/ProfilerAdminApi.js +2 -1
- package/dist/nodefony/service/audit-logger.js +16 -13
- package/dist/nodefony/service/certificates.js +25 -13
- package/dist/nodefony/service/error-renderer.js +118 -5
- package/dist/nodefony/service/http-kernel.js +342 -165
- package/dist/nodefony/service/pretty-request-logger.js +3 -10
- package/dist/nodefony/service/servers/clientError.js +2 -2
- package/dist/nodefony/service/servers/server-http.js +23 -23
- package/dist/nodefony/service/servers/server-https.js +41 -42
- package/dist/nodefony/service/servers/server-static.js +13 -17
- package/dist/nodefony/service/servers/server-websocket-secure.js +11 -10
- package/dist/nodefony/service/servers/server-websocket.js +11 -10
- package/dist/nodefony/service/servers/wsHeartbeat.js +2 -2
- package/dist/nodefony/service/sessions/sessions-service.js +25 -17
- package/dist/nodefony/service/trace.js +1 -1
- package/dist/nodefony/service/upload/upload-service.js +11 -10
- package/dist/nodefony/src/assets/prebuiltUi.js +1 -1
- package/dist/nodefony/src/context/Context.js +34 -19
- package/dist/nodefony/src/context/forwarded.js +4 -4
- package/dist/nodefony/src/context/http/HttpContext.js +133 -88
- package/dist/nodefony/src/context/http/Request.js +93 -44
- package/dist/nodefony/src/context/http/Response.js +107 -51
- package/dist/nodefony/src/context/http/parser.js +9 -10
- package/dist/nodefony/src/context/http/urlFastPath.js +1 -2
- package/dist/nodefony/src/context/http2/Response.js +53 -25
- package/dist/nodefony/src/context/metaData.js +1 -1
- package/dist/nodefony/src/context/trustProxy.js +4 -3
- package/dist/nodefony/src/context/websocket/Response.js +13 -6
- package/dist/nodefony/src/context/websocket/WebsocketContext.js +59 -26
- package/dist/nodefony/src/context/websocket/wsCloseReason.js +41 -0
- package/dist/nodefony/src/cookies/cookie.js +3 -3
- package/dist/nodefony/src/errors/httpError.js +6 -6
- package/dist/nodefony/src/errors/thrownMessage.js +27 -0
- package/dist/nodefony/src/profiler/FrameProfile.js +4 -3
- package/dist/nodefony/src/profiler/Profiler.js +4 -4
- package/dist/nodefony/src/servers/kernelServers.js +29 -0
- package/dist/nodefony/src/servers/portBinder.js +8 -21
- package/dist/nodefony/src/session/session.js +18 -10
- package/dist/nodefony/src/session/sessionStoreFailure.js +2 -1
- package/dist/nodefony/src/session/storage/RevocationGuardStorage.js +24 -9
- package/dist/types/index.d.ts +16 -0
- package/dist/types/nodefony/config/config.d.ts +22 -0
- package/dist/types/nodefony/interfaces/IContext.d.ts +24 -7
- package/dist/types/nodefony/interfaces/ICookie.d.ts +18 -18
- package/dist/types/nodefony/interfaces/IHttpKernel.d.ts +10 -4
- package/dist/types/nodefony/interfaces/IResponse.d.ts +1 -1
- package/dist/types/nodefony/interfaces/IRouting.d.ts +87 -0
- package/dist/types/nodefony/interfaces/ISecurity.d.ts +45 -0
- package/dist/types/nodefony/interfaces/ISession.d.ts +8 -8
- package/dist/types/nodefony/interfaces/IUpload.d.ts +9 -9
- package/dist/types/nodefony/interfaces/index.d.ts +2 -0
- package/dist/types/nodefony/service/audit-logger.d.ts +14 -10
- package/dist/types/nodefony/service/certificates.d.ts +10 -10
- package/dist/types/nodefony/service/error-renderer.d.ts +14 -0
- package/dist/types/nodefony/service/http-kernel.d.ts +192 -42
- package/dist/types/nodefony/service/servers/server-http.d.ts +4 -1
- package/dist/types/nodefony/service/servers/server-https.d.ts +4 -1
- package/dist/types/nodefony/service/servers/server-websocket-secure.d.ts +4 -1
- package/dist/types/nodefony/service/servers/server-websocket.d.ts +4 -1
- package/dist/types/nodefony/service/sessions/sessions-service.d.ts +6 -2
- package/dist/types/nodefony/service/upload/upload-service.d.ts +1 -1
- package/dist/types/nodefony/src/context/Context.d.ts +21 -10
- package/dist/types/nodefony/src/context/forwarded.d.ts +3 -3
- package/dist/types/nodefony/src/context/http/HttpContext.d.ts +75 -18
- package/dist/types/nodefony/src/context/http/Request.d.ts +39 -2
- package/dist/types/nodefony/src/context/http/Response.d.ts +47 -7
- package/dist/types/nodefony/src/context/http/parser.d.ts +1 -1
- package/dist/types/nodefony/src/context/http2/Response.d.ts +32 -2
- package/dist/types/nodefony/src/context/websocket/Response.d.ts +4 -4
- package/dist/types/nodefony/src/context/websocket/WebsocketContext.d.ts +11 -2
- package/dist/types/nodefony/src/context/websocket/wsCloseReason.d.ts +21 -0
- package/dist/types/nodefony/src/cookies/cookie.d.ts +9 -9
- package/dist/types/nodefony/src/errors/httpError.d.ts +7 -7
- package/dist/types/nodefony/src/errors/thrownMessage.d.ts +12 -0
- package/dist/types/nodefony/src/profiler/Profiler.d.ts +28 -28
- package/dist/types/nodefony/src/servers/kernelServers.d.ts +20 -0
- package/dist/types/nodefony/src/servers/portBinder.d.ts +6 -19
- package/dist/types/nodefony/src/session/session.d.ts +12 -12
- package/dist/types/nodefony/src/session/storage/RevocationGuardStorage.d.ts +1 -1
- package/docs/cookies.md +25 -25
- package/docs/observabilite.md +30 -30
- package/docs/rate-limit.md +22 -22
- package/docs/servers.md +61 -61
- package/docs/session.md +82 -77
- package/docs/upload.md +43 -37
- package/package.json +11 -11
package/docs/session.md
CHANGED
|
@@ -69,8 +69,8 @@ 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:
|
|
73
|
-
(`http-kernel.ts:
|
|
72
|
+
(`http-kernel.ts:1168`). Il commence par la garde paresseuse `if (!intent && !context.hasSession())`
|
|
73
|
+
(`http-kernel.ts:1207`) — sans intent de route ni cookie entrant, **aucune session n'est ouverte**.
|
|
74
74
|
|
|
75
75
|
## 📖 Lexique
|
|
76
76
|
|
|
@@ -114,15 +114,15 @@ 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
|
|
125
|
-
**jamais** prolongé (`Session.isValidSession()`, `session.ts:
|
|
125
|
+
**jamais** prolongé (`Session.isValidSession()`, `session.ts:371`), en plus de l'idle timeout.
|
|
126
126
|
|
|
127
127
|
> [!IMPORTANT]
|
|
128
128
|
> Le cookie **ne chiffre rien** et n'a pas à le faire : il ne porte qu'un numéro. La sécurité repose
|
|
@@ -135,16 +135,16 @@ Quatre partis pris, chacun vérifiable dans le code.
|
|
|
135
135
|
|
|
136
136
|
**1. Le cookie ne transporte que l'identifiant.** `Session.getSession()` lit la **valeur brute** du
|
|
137
137
|
cookie, sans déchiffrement (`session.ts:160`) ; l'identifiant vient de `Session.generateId()`
|
|
138
|
-
(`session.ts:
|
|
138
|
+
(`session.ts:231`). Modèle BFF : le web reste sur un cookie opaque, le JWT est réservé aux API et aux
|
|
139
139
|
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:1168`). 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:1491`) et `HttpKernel.onConnect()` (`http-kernel.ts:1779`) ;
|
|
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é
|
|
@@ -281,24 +281,24 @@ faute de `Secure` (`Context.getSessionCookieName()`, `Context.ts:714`).
|
|
|
281
281
|
Source unique des défauts : le schéma Zod `sessionSchema` (`config.ts:782`) et son sous-schéma
|
|
282
282
|
`sessionCookieSchema` (`config.ts:748`).
|
|
283
283
|
|
|
284
|
-
| Option | Type | Défaut | Effet
|
|
285
|
-
| ------------------- | ------- | ------------ |
|
|
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:
|
|
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:
|
|
290
|
-
| `absoluteTimeoutS` | int ≥ 0 | `43200` | Âge max depuis la création (12 h), **jamais** prolongé. `0` = désactivé.
|
|
291
|
-
| `gcIntervalS` | int ≥ 0 | `600` | Période de purge des sessions expirées, hors requête. `0` = timer désarmé.
|
|
292
|
-
| `gcJitter` | bool | `true` | Décale le départ du GC par process (anti _thundering herd_ sur un store partagé).
|
|
293
|
-
| `refererCheck` | bool | `false` | Lie la session à l'hôte de création (
|
|
294
|
-
| `cookie.maxAge` | int ≥ 0 | `0` | `0` = cookie de session (effacé à la fermeture du navigateur).
|
|
295
|
-
| `cookie.httpOnly` | bool | `true` | Inaccessible depuis JavaScript — anti-XSS.
|
|
296
|
-
| `cookie.secure` | bool | `true` | Envoyé sur TLS uniquement.
|
|
297
|
-
| `cookie.signed` | bool | `false` | Signe le cookie avec le secret HMAC du kernel.
|
|
298
|
-
| `cookie.hostPrefix` | enum | `"auto"` | `__Host-` : `auto` (sur TLS) \| `true` (toujours) \| `false` (jamais).
|
|
284
|
+
| Option | Type | Défaut | Effet |
|
|
285
|
+
| ------------------- | ------- | ------------ | ---------------------------------------------------------------------------------------- |
|
|
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:770`). |
|
|
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:823`). |
|
|
290
|
+
| `absoluteTimeoutS` | int ≥ 0 | `43200` | Âge max depuis la création (12 h), **jamais** prolongé. `0` = désactivé. |
|
|
291
|
+
| `gcIntervalS` | int ≥ 0 | `600` | Période de purge des sessions expirées, hors requête. `0` = timer désarmé. |
|
|
292
|
+
| `gcJitter` | bool | `true` | Décale le départ du GC par process (anti _thundering herd_ sur un store partagé). |
|
|
293
|
+
| `refererCheck` | bool | `false` | Lie la session à l'hôte de création — ⚠️ court-circuite les expirations (voir plus bas). |
|
|
294
|
+
| `cookie.maxAge` | int ≥ 0 | `0` | `0` = cookie de session (effacé à la fermeture du navigateur). |
|
|
295
|
+
| `cookie.httpOnly` | bool | `true` | Inaccessible depuis JavaScript — anti-XSS. |
|
|
296
|
+
| `cookie.secure` | bool | `true` | Envoyé sur TLS uniquement. |
|
|
297
|
+
| `cookie.signed` | bool | `false` | Signe le cookie avec le secret HMAC du kernel. |
|
|
298
|
+
| `cookie.hostPrefix` | enum | `"auto"` | `__Host-` : `auto` (sur TLS) \| `true` (toujours) \| `false` (jamais). |
|
|
299
299
|
|
|
300
300
|
`SameSite` n'est pas dans ce bloc : il vient des options de cookie génériques, dont le défaut est
|
|
301
|
-
`Lax` (`
|
|
301
|
+
`Lax` (`cookieDefaultSettings`, `cookie.ts:39`).
|
|
302
302
|
|
|
303
303
|
> [!WARNING]
|
|
304
304
|
> `idleTimeoutS: 0` **et** `absoluteTimeoutS: 0` désactivent les deux bornes : une session ne meurt
|
|
@@ -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:258`).
|
|
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 |
|
|
@@ -351,12 +351,12 @@ Ce qui change, c'est la topologie et la façon d'expirer.
|
|
|
351
351
|
|
|
352
352
|
### `memory` — l'implémentation de référence
|
|
353
353
|
|
|
354
|
-
Store built-in de `@nodefony/http`, enregistré d'office (`sessions-service.ts:
|
|
354
|
+
Store built-in de `@nodefony/http`, enregistré d'office (`sessions-service.ts:913`). Les sessions vivent
|
|
355
355
|
dans une `Map` du process : elles **disparaissent au redémarrage** et ne sont **pas partagées** entre
|
|
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
|
|
|
@@ -385,17 +385,17 @@ Ici l'expiration **idle** est portée par Redis lui-même : `SET … EX` pose le
|
|
|
385
385
|
(`@nodefony/redis/nodefony/src/SessionStorage.ts:168`) — aucun balayage périodique.
|
|
386
386
|
|
|
387
387
|
L'absolute timeout, lui, n'est pas exprimable par un TTL glissant : il reste honoré **à la lecture**
|
|
388
|
-
par `Session.isValidSession()` (`session.ts:
|
|
388
|
+
par `Session.isValidSession()` (`session.ts:371`). Une entrée trop vieille peut donc survivre côté
|
|
389
389
|
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.
|
|
@@ -439,12 +439,17 @@ sequenceDiagram
|
|
|
439
439
|
→ `resume()` (`session.ts:177`), sinon `create()` (`session.ts:204`) qui tire un identifiant CSPRNG,
|
|
440
440
|
pose le cookie et marque la session à persister.
|
|
441
441
|
|
|
442
|
-
**Validation à la reprise.** `Session.isValidSession()` (`session.ts:
|
|
443
|
-
`
|
|
444
|
-
|
|
445
|
-
recrée une session vierge.
|
|
442
|
+
**Validation à la reprise.** `Session.isValidSession()` (`session.ts:371`) applique l'**absolute**
|
|
443
|
+
(âge depuis `created`) puis l'**idle** (depuis `updated`). Échec → `invalidate()` (`session.ts:290`)
|
|
444
|
+
détruit l'entrée et recrée une session vierge.
|
|
446
445
|
|
|
447
|
-
|
|
446
|
+
> [!WARNING]
|
|
447
|
+
> Avec `refererCheck: true`, `isValidSession()` rend son verdict sur la **seule** correspondance
|
|
448
|
+
> d'hôte (`Session.checkSecureReferer()`) et retourne aussitôt : les bornes **absolute** et
|
|
449
|
+
> **idle** ne sont alors **pas** évaluées. Tant que ce comportement n'est pas corrigé, ne pas
|
|
450
|
+
> activer `refererCheck` quand l'expiration des sessions compte — c'est-à-dire presque toujours.
|
|
451
|
+
|
|
452
|
+
**Écriture minimale.** `SessionsService.saveSession()` (`sessions-service.ts:463`) n'écrit **que** si la
|
|
448
453
|
session est `dirty` et non `readOnly` ; sinon il appelle `Session.touchIfNeeded()` (`session.ts:421`),
|
|
449
454
|
qui prolonge l'idle **sans réécrire le blob**, et seulement au-delà d'une demi-vie d'idle
|
|
450
455
|
(`session.ts:445`). Une requête de lecture coûte donc au pire un `UPDATE` d'horodatage toutes les
|
|
@@ -452,14 +457,14 @@ qui prolonge l'idle **sans réécrire le blob**, et seulement au-delà d'une dem
|
|
|
452
457
|
|
|
453
458
|
**Anti-résurrection**, sur deux niveaux. `Session.destroy()` remet `mutated = false` (`session.ts:307`)
|
|
454
459
|
pour que la sauvegarde de fin de requête ne réécrive pas ce qu'on vient de supprimer. Surtout, **tout**
|
|
455
|
-
store est décoré par `RevocationGuardStorage` (`sessions-service.ts:
|
|
456
|
-
**pierre tombale** de 5 minutes (`RevocationGuardStorage.ts:
|
|
457
|
-
(`RevocationGuardStorage.ts:
|
|
460
|
+
store est décoré par `RevocationGuardStorage` (`sessions-service.ts:309`) : `destroy()` pose une
|
|
461
|
+
**pierre tombale** de 5 minutes (`RevocationGuardStorage.ts:156`) qui refuse ensuite tout `write`
|
|
462
|
+
(`RevocationGuardStorage.ts:163`) **et tout `touch`** (`RevocationGuardStorage.ts:130`) du même
|
|
458
463
|
identifiant — ce qui couvre la requête « en vol » d'un autre client.
|
|
459
464
|
|
|
460
465
|
**Purge hors requête.** Un `GcScheduler` est armé au `onReady`, désarmé au `onTerminate`
|
|
461
466
|
(`sessions-service.ts:284-313`). La passe métier nue, `SessionsService.runGc()`
|
|
462
|
-
(`sessions-service.ts:
|
|
467
|
+
(`sessions-service.ts:496`), est publique exprès : un CronJob Kubernetes peut l'appeler à la place du
|
|
463
468
|
timer (`gcIntervalS: 0`).
|
|
464
469
|
|
|
465
470
|
## Entités de persistance
|
|
@@ -498,17 +503,17 @@ C'est le différenciateur du framework appliqué à l'état de session : un seul
|
|
|
498
503
|
<!-- prettier-ignore -->
|
|
499
504
|
| Aspect | HTTP | WebSocket |
|
|
500
505
|
| --- | --- | --- |
|
|
501
|
-
| Ouverture | à chaque requête — `startSession()` dans `onRequestEnd()` (`http-kernel.ts:
|
|
506
|
+
| Ouverture | à chaque requête — `startSession()` dans `onRequestEnd()` (`http-kernel.ts:1491`) | **une fois** au handshake — `startSession()` dans `onConnect()` (`http-kernel.ts:1779`) |
|
|
502
507
|
| Lecture du cookie | constructeur du contexte | constructeur, même nom effectif (`WebsocketContext.ts:172`) |
|
|
503
508
|
| 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:
|
|
509
|
+
| Filet de fermeture | — | `once("onFinish")` sauve si non déjà fait (`http-kernel.ts:1566`) |
|
|
505
510
|
| Portée ALS | une requête | **handshake + toutes les frames** (`http-kernel.ts:1495`) |
|
|
506
511
|
|
|
507
512
|
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:
|
|
513
|
+
handshake par `RequestContext.run()` **enveloppe aussi les messages** (`http-kernel.ts:455`). L'identité résolue une fois est donc
|
|
509
514
|
disponible à chaque frame sans relire la base — c'est ce dont profite
|
|
510
515
|
`FirewallRealtimeAuthenticator.supports()` (`FirewallRealtimeAuthenticator.ts:80`), câblé automatiquement
|
|
511
|
-
par le firewall sur les zones temps réel protégées (`firewall.ts:
|
|
516
|
+
par le firewall sur les zones temps réel protégées (`firewall.ts:297`).
|
|
512
517
|
|
|
513
518
|
> [!WARNING]
|
|
514
519
|
> Rien à écrire dans `initialize()` : il n'existe **pas** de `Controller.startSession()`. La session WS
|
|
@@ -535,20 +540,20 @@ affiche.
|
|
|
535
540
|
|
|
536
541
|
| Surface | Méthode | Portée |
|
|
537
542
|
| ------------------------ | ----------------------------------------------- | ------------------------------------------- |
|
|
538
|
-
| Déconnexion locale | `Session.destroy()` (`session.ts:
|
|
539
|
-
| Révocation par un admin | `destroyByRef()` (`sessions-service.ts:
|
|
540
|
-
| « Déconnecter partout » | `destroyByUser()` (`sessions-service.ts:
|
|
541
|
-
| « Mes appareils » (self) | `destroyOwnByRef()` (`sessions-service.ts:
|
|
543
|
+
| Déconnexion locale | `Session.destroy()` (`session.ts:306`) | la session courante + pierre tombale |
|
|
544
|
+
| Révocation par un admin | `destroyByRef()` (`sessions-service.ts:745`) | une session désignée par sa `ref` publique |
|
|
545
|
+
| « Déconnecter partout » | `destroyByUser()` (`sessions-service.ts:775`) | toutes les sessions d'un utilisateur |
|
|
546
|
+
| « Mes appareils » (self) | `destroyOwnByRef()` (`sessions-service.ts:872`) | une session, **restreinte au propriétaire** |
|
|
542
547
|
|
|
543
548
|
Deux finesses valent d'être connues.
|
|
544
549
|
|
|
545
550
|
`destroyByUser()` ne fait pas un seul passage : il **repasse jusqu'à ce qu'un passage complet ne
|
|
546
|
-
détruise plus rien** (`sessions-service.ts:
|
|
551
|
+
détruise plus rien** (`sessions-service.ts:775`), car supprimer en parcourant décale les rangs sous un
|
|
547
552
|
curseur offset. Une révocation « partout » qui en laisserait une n'est pas une imprécision, c'est une
|
|
548
553
|
faille — on rend donc la main avec la preuve, pas l'espoir.
|
|
549
554
|
|
|
550
555
|
`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:
|
|
556
|
+
et appartenance **re-vérifiée** avant même de comparer la `ref` (`sessions-service.ts:863`). Une
|
|
552
557
|
`ref` d'autrui est structurellement introuvable.
|
|
553
558
|
|
|
554
559
|
### Redaction — l'identifiant ne sort jamais du process
|
|
@@ -561,7 +566,7 @@ Trois barrières superposées :
|
|
|
561
566
|
**liste blanche** : `ref`, `user`, `authenticated`, `ip`, `ua`, dates. Jamais un `delete` après coup.
|
|
562
567
|
3. La `ref` elle-même est un HMAC tronqué non réversible (`computeSessionRef()`,
|
|
563
568
|
`sessions-service.ts:100`) ; la clé est dérivée du certificat au boot et n'est jamais sérialisée
|
|
564
|
-
(`SessionsService.sessionRef()`, `sessions-service.ts:
|
|
569
|
+
(`SessionsService.sessionRef()`, `sessions-service.ts:564`).
|
|
565
570
|
|
|
566
571
|
### Récapitulatif des défenses actives par défaut
|
|
567
572
|
|
|
@@ -569,20 +574,20 @@ Trois barrières superposées :
|
|
|
569
574
|
| --------------------------------- | ------------------------------------------------- | -------------------------------------------------- |
|
|
570
575
|
| Vol par script injecté (XSS) | `HttpOnly` | `sessionCookieSchema` (`config.ts:748`) |
|
|
571
576
|
| Interception réseau | `Secure` + `__Host-` sur TLS | `getSessionCookieName()` (`Context.ts:714`) |
|
|
572
|
-
| Requête inter-sites | `SameSite=Lax` par défaut | `
|
|
573
|
-
| Fixation (cookie pré-posé) | `strictMode` + régénération au login | `Session.resume()` (`session.ts:
|
|
574
|
-
| Identifiant deviné | 32 octets CSPRNG (43 caractères base64url) | `Session.generateId()` (`session.ts:
|
|
575
|
-
| Session volée exploitée longtemps | absolute timeout, jamais prolongé | `absoluteTimeoutS` à la reprise (`session.ts:
|
|
576
|
-
| Session oubliée ouverte | idle timeout glissant | `idleTimeoutS` à la reprise (`session.ts:
|
|
577
|
-
| Résurrection après révocation | pierre tombale 5 min sur `write` **et** `touch` | `RevocationGuardStorage.ts:
|
|
577
|
+
| Requête inter-sites | `SameSite=Lax` par défaut | `cookieDefaultSettings` (`cookie.ts:39`) |
|
|
578
|
+
| Fixation (cookie pré-posé) | `strictMode` + régénération au login | `Session.resume()` (`session.ts:177`) |
|
|
579
|
+
| Identifiant deviné | 32 octets CSPRNG (43 caractères base64url) | `Session.generateId()` (`session.ts:231`) |
|
|
580
|
+
| Session volée exploitée longtemps | absolute timeout, jamais prolongé | `absoluteTimeoutS` à la reprise (`session.ts:388`) |
|
|
581
|
+
| Session oubliée ouverte | idle timeout glissant | `idleTimeoutS` à la reprise (`session.ts:401`) |
|
|
582
|
+
| Résurrection après révocation | pierre tombale 5 min sur `write` **et** `touch` | `RevocationGuardStorage.ts:127-171` |
|
|
578
583
|
| Fuite d'identifiant en admin | `ref` HMAC + projection en liste blanche | `toSessionSummary()` (`sessions-service.ts:112`) |
|
|
579
|
-
| IDOR sur « mes sessions » | périmètre depuis l'identité ALS, jamais du client | `destroyOwnByRef()` (`sessions-service.ts:
|
|
584
|
+
| IDOR sur « mes sessions » | périmètre depuis l'identité ALS, jamais du client | `destroyOwnByRef()` (`sessions-service.ts:872`) |
|
|
580
585
|
|
|
581
586
|
## 🧰 API publique
|
|
582
587
|
|
|
583
588
|
Les signatures vivent dans `.ai/symbols.json` (jamais recopiées ici). Voici les usages réels.
|
|
584
589
|
|
|
585
|
-
**Depuis un contrôleur** — `this.session` est un getter sur le contexte (`Controller.ts:
|
|
590
|
+
**Depuis un contrôleur** — `this.session` est un getter sur le contexte (`Controller.ts:384`) ; un
|
|
586
591
|
paramètre `@Session()` suffit à déclarer l'intent.
|
|
587
592
|
|
|
588
593
|
| Besoin | Appel | Effet |
|
|
@@ -590,36 +595,36 @@ paramètre `@Session()` suffit à déclarer l'intent.
|
|
|
590
595
|
| Lire une valeur | `session.get("panier")` | `null` si absente — jamais `undefined`. |
|
|
591
596
|
| Écrire une valeur | `session.set("panier", items)` | Marque la session `dirty` → écriture en fin de requête. |
|
|
592
597
|
| Message « une seule lecture » | `session.setFlashBag("notice", "…")` | Consommé (et effacé) au premier `getFlashBag`. |
|
|
593
|
-
| Lire ce message | `session.getFlashBag("notice")` | Rend la valeur puis la supprime (`session.ts:
|
|
598
|
+
| Lire ce message | `session.getFlashBag("notice")` | Rend la valeur puis la supprime (`session.ts:530`). |
|
|
594
599
|
| Métadonnée technique | `session.getMetaBag("ip")` | ip / ua / host / remoteAddress posés à la création. |
|
|
595
600
|
| Se déconnecter | `await session.destroy(true)` | Détruit l'entrée store **et** efface le cookie. |
|
|
596
601
|
| Renouveler l'identifiant | `session.regenerateId()` | Nouvel identifiant, état conservé (`session.ts:236`). |
|
|
597
602
|
| Savoir si une écriture aura lieu | `session.dirty` | Le drapeau de dirty-tracking (`session.ts:128`). |
|
|
598
603
|
|
|
599
|
-
**Intent de route** — `UseSession(options)` (`routerDecorators.ts:
|
|
604
|
+
**Intent de route** — `UseSession(options)` (`routerDecorators.ts:800`) s'applique à une classe **ou** à
|
|
600
605
|
une méthode ; la méthode l'emporte, par fusion et non par remplacement
|
|
601
|
-
(`resolveSessionIntent()`, `routerDecorators.ts:
|
|
606
|
+
(`resolveSessionIntent()`, `routerDecorators.ts:858`). **Une seule** option (`SessionIntent`,
|
|
602
607
|
`ISession.ts:17`) :
|
|
603
608
|
|
|
604
609
|
- `readOnly: true` — la session est reprise et lue mais **jamais** persistée ; une mutation tentée est
|
|
605
610
|
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:
|
|
611
|
+
propagé par le kernel (`http-kernel.ts:1210`).
|
|
607
612
|
|
|
608
|
-
En décorateur de **classe**, `@UseSession` se place **sous** `@controller` (`routerDecorators.ts:
|
|
613
|
+
En décorateur de **classe**, `@UseSession` se place **sous** `@controller` (`routerDecorators.ts:189`).
|
|
609
614
|
|
|
610
615
|
## 🧩 Extension — brancher son propre store
|
|
611
616
|
|
|
612
617
|
Le registre est une inversion de contrôle complète : `@nodefony/http` ne connaît **aucun** backend.
|
|
613
618
|
Chaque module fournisseur se déclare lui-même au chargement, par
|
|
614
|
-
`SessionsService.registerStorage(nom, ctor)` (`sessions-service.ts:
|
|
619
|
+
`SessionsService.registerStorage(nom, ctor)` (`sessions-service.ts:196`) — exactement ce que fait la
|
|
615
620
|
dernière ligne de chaque adaptateur (`@nodefony/redis/nodefony/src/SessionStorage.ts:323`).
|
|
616
621
|
|
|
617
622
|
Pour ajouter un backend :
|
|
618
623
|
|
|
619
|
-
1. Implémenter `ISessionStorage` (`ISession.ts:
|
|
624
|
+
1. Implémenter `ISessionStorage` (`ISession.ts:142`). Le **noyau obligatoire** est court :
|
|
620
625
|
`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:
|
|
626
|
+
2. Ajouter les capacités **optionnelles** utiles : `touch` (idle glissant sans réécriture, `ISession.ts:176`),
|
|
627
|
+
`listPage` + `countSessions` (administration paginée, `ISession.ts:236`), `listAll` (dump).
|
|
623
628
|
3. Appeler `SessionsService.registerStorage("mon-store", MonStore)` au chargement du module.
|
|
624
629
|
4. Exécuter les bancs de contrat partagés (`sessionStoreContract.ts`, `sessionPaginationContract.ts`)
|
|
625
630
|
contre l'implémentation — c'est ce qui garantit la parité.
|
|
@@ -639,7 +644,7 @@ Trois règles de conception se dégagent du contrat, et méritent d'être respec
|
|
|
639
644
|
| ----------------------------- | ------------------------- | ----------------------------------------------------------------------------- |
|
|
640
645
|
| Attributs et préfixes cookie | RFC 6265bis §4.1.3 | `__Host-` impose `Secure` + `Path=/`, interdit `Domain` (`cookie.ts:386-403`) |
|
|
641
646
|
| 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:
|
|
647
|
+
| Idle timeout | NIST SP 800-63B-4 / OWASP | défaut 1800 s, glissant par `touch` (`config.ts:831`) |
|
|
643
648
|
| Absolute timeout | NIST SP 800-63B-4 / OWASP | défaut 43200 s, jamais prolongé (`config.ts:808`) |
|
|
644
649
|
| Identifiant de session | OWASP Session Management | 32 octets CSPRNG, opaque (`session.ts:226`) |
|
|
645
650
|
| Identifiant hors URL | OWASP Session Management | cookie uniquement — jamais de réécriture d'URL (`session.ts:20-26`) |
|
|
@@ -651,15 +656,15 @@ Trois règles de conception se dégagent du contrat, et méritent d'être respec
|
|
|
651
656
|
Le coût d'une session est **payé seulement quand elle sert** :
|
|
652
657
|
|
|
653
658
|
- **Zéro par défaut** — sans intent ni cookie, `startSession()` sort immédiatement
|
|
654
|
-
(`http-kernel.ts:
|
|
659
|
+
(`http-kernel.ts:1168`) : ni objet `Session`, ni lecture de store.
|
|
655
660
|
- **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:
|
|
661
|
+
- **Zéro écriture en lecture** — le dirty-tracking court-circuite `save()` (`session.ts:255`) ; le
|
|
657
662
|
`touch` est throttlé à une écriture par demi-vie d'idle (`session.ts:445`).
|
|
658
663
|
- **GC hors requête** — timer déterministe avec jitter par process, à la place du tirage
|
|
659
664
|
probabiliste hérité de PHP (`sessions-service.ts:306-312`).
|
|
660
665
|
- **Révocation quasi gratuite** — la `Map` de pierres tombales est **paresseuse** : sans révocation,
|
|
661
666
|
`write` ne paie qu'une comparaison `=== null`, sans même un `Date.now()`
|
|
662
|
-
(`RevocationGuardStorage.ts:
|
|
667
|
+
(`RevocationGuardStorage.ts:181-186`).
|
|
663
668
|
- **Administration bornée** — jamais plus de `SCAN_PAGE = 200` enregistrements en mémoire
|
|
664
669
|
(`sessions-service.ts:75`), garde-fou à 5 000 pages (`sessions-service.ts:83`), parcours interrompu
|
|
665
670
|
**journalisé**.
|
|
@@ -700,21 +705,21 @@ dérivation est faite une fois par page (`sessions-service.ts` `currentSessionRe
|
|
|
700
705
|
requête ne porte pas de session (appel interne, invocation CLI).
|
|
701
706
|
|
|
702
707
|
Codes de réponse à connaître : **501** si le store courant ne sait pas s'énumérer
|
|
703
|
-
(`supportsEnumeration()` faux — `HttpAdminApi.ts:
|
|
708
|
+
(`supportsEnumeration()` faux — `HttpAdminApi.ts:62`), **503** si le service de session est absent, **404** pour une `ref` inconnue ou
|
|
704
709
|
une révocation sans effet, **401** sur `mine` sans identité.
|
|
705
710
|
|
|
706
711
|
**Écrans** — la page **Sessions** (`/nodefony/sessions`) de Studio liste les sessions vivantes par `ref` et permet la
|
|
707
712
|
révocation unitaire ou en masse (`@nodefony/studio/frontend/src/routes/sessions/`). L'écran **Stores**
|
|
708
713
|
affiche le backend réellement résolu, sa provenance et son emplacement physique : ces informations sont
|
|
709
|
-
publiées au boot par `registerStoreResolution()` (`sessions-service.ts:
|
|
714
|
+
publiées au boot par `registerStoreResolution()` (`sessions-service.ts:321`), avec le chemin du fichier
|
|
710
715
|
SQLite quand c'est pertinent (`SessionStorage.location`,
|
|
711
|
-
`@nodefony/drizzle/nodefony/src/SessionStorage.ts:
|
|
716
|
+
`@nodefony/drizzle/nodefony/src/SessionStorage.ts:55`).
|
|
712
717
|
|
|
713
718
|
## ⚠️ Pièges (symptôme → cause → correction)
|
|
714
719
|
|
|
715
720
|
| Symptôme | Cause | Correction |
|
|
716
721
|
| ------------------------------------------------------ | ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
|
|
717
|
-
| Aucun `Set-Cookie`, `session` toujours vide | La route n'a **aucun intent** : ni `@UseSession`, ni paramètre `@Session` | Ajouter l'un des deux — l'activation est paresseuse (`http-kernel.ts:
|
|
722
|
+
| Aucun `Set-Cookie`, `session` toujours vide | La route n'a **aucun intent** : ni `@UseSession`, ni paramètre `@Session` | Ajouter l'un des deux — l'activation est paresseuse (`http-kernel.ts:1210`) |
|
|
718
723
|
| `context.session` est `null` dans un contrôleur WS | Intent posé sur la classe au lieu de la route, ou absent | `@UseSession()` **sur la route** WebSocket ; `Controller.startSession()` n'existe plus |
|
|
719
724
|
| Mutation ignorée, WARNING « READONLY SESSION mutated » | La route est en `@UseSession({ readOnly: true })` | Retirer `readOnly` sur les routes qui écrivent (`session.ts:255-264`) |
|
|
720
725
|
| Session détruite qui « revient » après logout | Une requête en vol réécrit le blob supprimé | Déjà couvert : pierre tombale 5 min (`RevocationGuardStorage.ts:121`) |
|
package/docs/upload.md
CHANGED
|
@@ -62,10 +62,10 @@ flowchart TD
|
|
|
62
62
|
|
|
63
63
|
Trois idées portent tout le reste :
|
|
64
64
|
|
|
65
|
-
1. **Le `Content-Type` décide, pas la méthode.** `parseRequest()` (`context/http/Request.ts:
|
|
65
|
+
1. **Le `Content-Type` décide, pas la méthode.** `parseRequest()` (`context/http/Request.ts:451`)
|
|
66
66
|
aiguille vers `ParserJson` / `ParserQs` / `ParserXml` / **busboy** / parser brut selon l'en-tête.
|
|
67
67
|
2. **Seul le multipart écrit sur disque.** Les fichiers sont **streamés** au fil de l'eau vers un
|
|
68
|
-
fichier temporaire (`parseMultipart()`, `context/http/Request.ts:
|
|
68
|
+
fichier temporaire (`parseMultipart()`, `context/http/Request.ts:523`) — jamais bufferisés en RAM.
|
|
69
69
|
JSON / urlencoded / XML restent en mémoire (petits corps).
|
|
70
70
|
3. **Deux budgets distincts, jamais confondus.** Le corps **non-multipart** est borné par `maxBodySize`
|
|
71
71
|
(défaut **1 MiB**) ; le multipart a **ses propres** limites busboy (`maxFileSize`, `maxTotalFileSize`,
|
|
@@ -117,11 +117,11 @@ Trois choix structurent l'implémentation, et chacun a une raison de sécurité
|
|
|
117
117
|
|
|
118
118
|
**Le multipart ne touche jamais la RAM.** Là où l'ancien chemin bufferisait le corps entier, busboy lit
|
|
119
119
|
le flux et écrit chaque fichier au fil de l'eau dans le dossier temporaire (`streamMultipart()`,
|
|
120
|
-
`context/http/Request.ts:
|
|
120
|
+
`context/http/Request.ts:564`). Un upload de 1 Go ne coûte donc pas 1 Go de heap — seuls les petits
|
|
121
121
|
champs texte restent en mémoire. C'est ce qui rend un endpoint d'upload public tenable.
|
|
122
122
|
|
|
123
123
|
**Le nom du fichier temporaire n'est jamais celui du client.** Chaque fichier reçu est écrit sous un nom
|
|
124
|
-
`randomUUID()` + extension d'origine (`context/http/Request.ts:
|
|
124
|
+
`randomUUID()` + extension d'origine (`context/http/Request.ts:627`) : un nom malveillant
|
|
125
125
|
(`../../etc/passwd`) ne peut pas influencer le **chemin** d'écriture. Le nom d'origine est conservé en
|
|
126
126
|
**métadonnée** (`filename`), pas dans le chemin.
|
|
127
127
|
|
|
@@ -257,14 +257,14 @@ 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:451`). `PATCH` porte un corps comme `POST`/`PUT` : il figure dans la table
|
|
261
|
+
des méthodes parsées (`context/http/Request.ts:94`) — 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
|
-
(`context/http/Request.ts:
|
|
264
|
-
qu'une fois tous les fichiers fermés (`context/http/Request.ts:
|
|
263
|
+
(`context/http/Request.ts:564`) accumule les `Promise` d'écriture disque et ne résout `{ fields, files }`
|
|
264
|
+
qu'une fois tous les fichiers fermés (`bb.on("finish")`, `context/http/Request.ts:707`).
|
|
265
265
|
3. **Une limite dépassée nettoie les temporaires déjà posés** — `abort()`
|
|
266
|
-
(`context/http/Request.ts:
|
|
267
|
-
déjà écrits (`context/http/Request.ts:
|
|
266
|
+
(`context/http/Request.ts:584`) délie le flux, détruit les write-streams ouverts et `unlink` les temp
|
|
267
|
+
déjà écrits (`context/http/Request.ts:601`) avant de rejeter en `413` : pas d'orphelins sur le disque.
|
|
268
268
|
4. **Les autres formats drainent AVANT de concaténer** — la base `Parser.parse()` attend `end`
|
|
269
269
|
(`context/http/parser.ts:111`) avant `Buffer.concat` : sans ce drain, `ParserQs`/`ParserXml`
|
|
270
270
|
lisaient un corps partiel → `queryPost` vide (bug de régression, cf tests).
|
|
@@ -283,14 +283,20 @@ 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:428`), 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
|
+
|
|
291
|
+
**Par requête** : `maxBodySize` et les limites `upload.*` (hors `uploadDir`) sont sur la liste blanche
|
|
292
|
+
du calque de configuration — une application peut les changer pour une requête
|
|
293
|
+
(`overlayConfig("@nodefony/http", { maxBodySize: … })`, posé dans un écouteur `onRequestScope`). Ils
|
|
294
|
+
sont lus au moment de lire le corps (`Request.readBodyLimits()`), donc un calque posé après ce moment
|
|
295
|
+
(au pare-feu, dans un contrôleur) n'agit pas. Détail : [configuration](../../../../../docs/guides/configuration.md).
|
|
290
296
|
|
|
291
297
|
### Fichiers multipart — `upload.*`
|
|
292
298
|
|
|
293
|
-
Table dérivée de `uploadSchema` (`config/config.ts:
|
|
299
|
+
Table dérivée de `uploadSchema` (`config/config.ts:159`). Toutes les bornes sont `runtimeMutable`.
|
|
294
300
|
|
|
295
301
|
| Option | Type | Défaut | Effet |
|
|
296
302
|
| ------------------ | -------------------------------- | ---------------------- | --------------------------------------------------------------------------------------------------- |
|
|
@@ -316,16 +322,16 @@ recommandé) et les **getters** de `Controller` (impératif). Les signatures exa
|
|
|
316
322
|
|
|
317
323
|
| Accès | Source lue | Ancrage |
|
|
318
324
|
| -------------------------------------------------- | ------------------------------------ | ------------------------------------------------------------------- |
|
|
319
|
-
| `@UploadedFiles() f: IUploadedFile[]` | tous les fichiers (`queryFile`) | `resolveParamArg` `"files"` (`routerDecorators.ts:
|
|
320
|
-
| `@UploadedFile() f: IUploadedFile` | le **premier** fichier | `resolveParamArg` `"file"` (`routerDecorators.ts:
|
|
321
|
-
| `@Body() body` | tous les champs parsés (`queryPost`) | `resolveParamArg` `"body"` (`routerDecorators.ts:
|
|
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:
|
|
324
|
-
| `this.queryFile` | équivalent getter des fichiers | `Controller.queryFile` (`framework/nodefony/src/Controller.ts:
|
|
325
|
-
| `this.queryPost` | équivalent getter des champs | `Controller.queryPost` (`framework/nodefony/src/Controller.ts:
|
|
325
|
+
| `@UploadedFiles() f: IUploadedFile[]` | tous les fichiers (`queryFile`) | `resolveParamArg` `"files"` (`routerDecorators.ts:1283`) |
|
|
326
|
+
| `@UploadedFile() f: IUploadedFile` | le **premier** fichier | `resolveParamArg` `"file"` (`routerDecorators.ts:1282`) |
|
|
327
|
+
| `@Body() body` | tous les champs parsés (`queryPost`) | `resolveParamArg` `"body"` (`routerDecorators.ts:1232`) |
|
|
328
|
+
| `@Body("label") v` | un seul champ du body | même source, clé (`routerDecorators.ts:1223`) |
|
|
329
|
+
| `@Body({ stream: true }) s: NodeJS.ReadableStream` | le **flux brut**, parse **sauté** | `resolveParamArg` stream (`routerDecorators.ts:1326`) |
|
|
330
|
+
| `this.queryFile` | équivalent getter des fichiers | `Controller.queryFile` (`framework/nodefony/src/Controller.ts:341`) |
|
|
331
|
+
| `this.queryPost` | équivalent getter des champs | `Controller.queryPost` (`framework/nodefony/src/Controller.ts:351`) |
|
|
326
332
|
|
|
327
333
|
Les décorateurs `@UploadedFile` / `@UploadedFiles` sont des fabriques de paramètre
|
|
328
|
-
(`routerDecorators.ts:
|
|
334
|
+
(`routerDecorators.ts:1287`), exportées par `@nodefony/framework` ; leurs interfaces `IUploadedFile` /
|
|
329
335
|
`IParsedUploadFile` viennent de `@nodefony/http` (`interfaces/IUpload.ts:49`, `interfaces/IUpload.ts:7`).
|
|
330
336
|
|
|
331
337
|
### Un fichier uploadé — `UploadedFile`
|
|
@@ -339,9 +345,9 @@ sur disque. Ses membres utiles :
|
|
|
339
345
|
| `size` | Taille réellement écrite (octets). | `getSize()` (`service/upload/upload-service.ts:139`) |
|
|
340
346
|
| `prettySize` | Taille lisible (`« 1.2 MB »`). | `getPrettySize()` (`service/upload/upload-service.ts:143`) |
|
|
341
347
|
| `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:
|
|
343
|
-
| `moveAsync(target)` | Déplace le temp — **non bloquant, recommandé** dans le pipeline. | `moveAsync()` (`service/upload/upload-service.ts:
|
|
344
|
-
| `move(target)` | Variante **synchrone** (compat) — bloque l'event-loop. | `move()` (`service/upload/upload-service.ts:
|
|
348
|
+
| `hash` / `hashAlgorithm` | Empreinte d'intégrité si `upload.hashAlgorithm` est réglé. | `interfaces/IUpload.ts:55` |
|
|
349
|
+
| `moveAsync(target)` | Déplace le temp — **non bloquant, recommandé** dans le pipeline. | `moveAsync()` (`service/upload/upload-service.ts:218`) |
|
|
350
|
+
| `move(target)` | Variante **synchrone** (compat) — bloque l'event-loop. | `move()` (`service/upload/upload-service.ts:186`) |
|
|
345
351
|
|
|
346
352
|
`move`/`moveAsync` acceptent un **fichier cible** ou un **dossier existant** : sur un dossier, la
|
|
347
353
|
destination est bâtie avec `filename` — d'où l'avertissement de sécurité ci-dessous.
|
|
@@ -351,7 +357,7 @@ destination est bâtie avec `filename` — d'où l'avertissement de sécurité c
|
|
|
351
357
|
Pour piper directement un très gros corps (vidéo, backup) vers le disque ou S3 sans passer par busboy ni
|
|
352
358
|
par la RAM, `@Body({ stream: true })` court-circuite le parse et injecte l'`IncomingMessage` brut (un
|
|
353
359
|
`Readable`). Le pipeline sait le sauter en amont via `routeExpectsBodyStream()`
|
|
354
|
-
(`routerDecorators.ts:
|
|
360
|
+
(`routerDecorators.ts:1408`), mémoïsé sur la route.
|
|
355
361
|
|
|
356
362
|
```ts
|
|
357
363
|
// fragment — le contrôleur pipe le flux lui-même (0 parse, 0 pic RAM)
|
|
@@ -370,15 +376,15 @@ piégé. Les défenses en place, et **ce qui reste à ta charge**.
|
|
|
370
376
|
<!-- prettier-ignore -->
|
|
371
377
|
| Menace | Défense côté framework | À ta charge |
|
|
372
378
|
| --- | --- | --- |
|
|
373
|
-
| **Path traversal** (chemin d'écriture) | Le temp est nommé `randomUUID()` + extension — jamais le nom client (`context/http/Request.ts:
|
|
379
|
+
| **Path traversal** (chemin d'écriture) | Le temp est nommé `randomUUID()` + extension — jamais le nom client (`context/http/Request.ts:627`). | La **destination** de `move()` (voir avertissement). |
|
|
374
380
|
| **Saturation RAM** | Multipart streamé (jamais bufferisé) ; corps non-multipart borné (`maxBodySize`). | Resserrer `maxBodySize` selon l'endpoint. |
|
|
375
|
-
| **Saturation disque** | `maxFileSize` + `maxTotalFileSize` + `maxFiles` ; `abort()` nettoie les temp à l'abandon (`context/http/Request.ts:
|
|
376
|
-
| **DoS par quantité** | `maxFields` / `maxFiles` / `parts` → `413` (`context/http/Request.ts:
|
|
381
|
+
| **Saturation disque** | `maxFileSize` + `maxTotalFileSize` + `maxFiles` ; `abort()` nettoie les temp à l'abandon (`context/http/Request.ts:584`). | Purger les temp non déplacés (TTL / cron). |
|
|
382
|
+
| **DoS par quantité** | `maxFields` / `maxFiles` / `parts` → `413` (`context/http/Request.ts:754`). | — |
|
|
377
383
|
| **Type de fichier hostile** | `mimeType` **déclaré** est exposé tel quel. | Valider le type/contenu réel (le MIME client est déclaratif). |
|
|
378
384
|
|
|
379
385
|
> [!WARNING]
|
|
380
386
|
> **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:
|
|
387
|
+
> chemin final avec `file.filename` — le nom **client** (`service/upload/upload-service.ts:101`). Un nom
|
|
382
388
|
> `../../etc/cron.d/x` s'échapperait du dossier. Le framework protège le chemin du **temporaire**, pas
|
|
383
389
|
> celui que **tu** choisis. Règle : passe une **cible complète** que tu contrôles, ou assainis toujours
|
|
384
390
|
> avec `path.basename(file.filename)` — exactement le `safeName` du Démarrage rapide.
|
|
@@ -395,7 +401,7 @@ Le parsing du corps est sur le chemin de chaque requête écrivante — les choi
|
|
|
395
401
|
- **`stat` non bloquant** — `UploadedFile.create()` résout les stats du fichier en async
|
|
396
402
|
(`service/upload/upload-service.ts:130`), plus de `lstatSync` par fichier uploadé.
|
|
397
403
|
- **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:
|
|
404
|
+
à la main (`once` n'auto-détache que celui qui fire) (`context/http/parser.ts:78`).
|
|
399
405
|
- **Hash opt-in** — `hashAlgorithm: false` par défaut : zéro coût CPU tant que l'intégrité n'est pas
|
|
400
406
|
demandée.
|
|
401
407
|
|
|
@@ -406,12 +412,12 @@ pipeline : `npm run test:memory` (skill `nodefony-check-memory-health`).
|
|
|
406
412
|
|
|
407
413
|
| Domaine | Norme | Ancrage |
|
|
408
414
|
| ---------------------------------- | -------------------------------- | ------------------------------------------------------------- |
|
|
409
|
-
| Formulaire avec fichiers | RFC 7578 (`multipart/form-data`) | `parseMultipart()` via busboy (`context/http/Request.ts:
|
|
410
|
-
| Corps trop gros → 413 | RFC 9110 §15.5.14 | `enforceBodyLimit()` (`context/http/Request.ts:
|
|
415
|
+
| Formulaire avec fichiers | RFC 7578 (`multipart/form-data`) | `parseMultipart()` via busboy (`context/http/Request.ts:523`) |
|
|
416
|
+
| Corps trop gros → 413 | RFC 9110 §15.5.14 | `enforceBodyLimit()` (`context/http/Request.ts:428`) |
|
|
411
417
|
| 413 en streaming (chunked/menteur) | RFC 9110 §15.5.14 | `Parser.write()` (`context/http/parser.ts:33`) |
|
|
412
418
|
| Bornes multipart → 413 | RFC 9110 §15.5.14 | `stream.on("limit")` (`context/http/Request.ts:481`) |
|
|
413
|
-
| Défense path traversal (nom temp) | OWASP — File Upload | `randomUUID()` (`context/http/Request.ts:
|
|
414
|
-
| Charset du corps honoré | RFC 9110 (Content-Type) | `getCharset()` (`context/http/Request.ts:
|
|
419
|
+
| Défense path traversal (nom temp) | OWASP — File Upload | `randomUUID()` (`context/http/Request.ts:627`) |
|
|
420
|
+
| Charset du corps honoré | RFC 9110 (Content-Type) | `getCharset()` (`context/http/Request.ts:824`) |
|
|
415
421
|
|
|
416
422
|
## ⚠️ Pièges
|
|
417
423
|
|
|
@@ -423,8 +429,8 @@ pipeline : `npm run test:memory` (skill `nodefony-check-memory-health`).
|
|
|
423
429
|
| `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
430
|
| 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
431
|
| 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:
|
|
427
|
-
| Corps `latin1` mal décodé | Déjà géré : le `charset=` du `Content-Type` est honoré | Aucune — `getCharset()` normalise (`context/http/Request.ts:
|
|
432
|
+
| `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:93`) |
|
|
433
|
+
| Corps `latin1` mal décodé | Déjà géré : le `charset=` du `Content-Type` est honoré | Aucune — `getCharset()` normalise (`context/http/Request.ts:824`) |
|
|
428
434
|
| `multipart` sans boundary fait planter | `new Busboy()` throw synchrone | Déjà géré : bascule sur le parser brut (`context/http/Request.ts:502`) |
|
|
429
435
|
|
|
430
436
|
## 🧪 Tests & couverture
|