@nodefony/http 10.0.0-beta.3 → 10.0.0-beta.4
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.153.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
- package/dist/_virtual/{_@oxc-project_runtime@0.153.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorateParam.js +1 -2
- package/dist/index.js +12 -10
- package/dist/nodefony/command/assetsPublishCommand.js +5 -3
- package/dist/nodefony/command/certificatesCommand.js +5 -3
- package/dist/nodefony/command/networkCommand.js +5 -3
- package/dist/nodefony/command/proxyGenerateCommand.js +8 -3
- package/dist/nodefony/config/config.js +1 -2
- package/dist/nodefony/config/defineModuleConfig.js +1 -2
- package/dist/nodefony/service/HttpAdminApi.js +2 -2
- package/dist/nodefony/service/ProfilerAdminApi.js +1 -2
- package/dist/nodefony/service/audit-logger.js +20 -13
- package/dist/nodefony/service/certificates.js +97 -13
- package/dist/nodefony/service/error-renderer.js +29 -12
- package/dist/nodefony/service/http-kernel.js +118 -43
- package/dist/nodefony/service/pretty-request-logger.js +5 -4
- package/dist/nodefony/service/request-logger.js +24 -16
- package/dist/nodefony/service/reverse-proxy.js +3 -3
- package/dist/nodefony/service/servers/clientError.js +22 -3
- package/dist/nodefony/service/servers/server-h2c.js +126 -0
- package/dist/nodefony/service/servers/server-http.js +5 -8
- package/dist/nodefony/service/servers/server-https.js +12 -24
- package/dist/nodefony/service/servers/server-static.js +3 -3
- package/dist/nodefony/service/servers/server-websocket-secure.js +10 -18
- package/dist/nodefony/service/servers/server-websocket.js +10 -18
- package/dist/nodefony/service/servers/serverShutdown.js +78 -18
- package/dist/nodefony/service/servers/wsHeartbeat.js +1 -2
- package/dist/nodefony/service/sessions/sessions-service.js +7 -14
- package/dist/nodefony/service/trace.js +1 -2
- package/dist/nodefony/service/upload/upload-service.js +5 -8
- package/dist/nodefony/service/x509.js +1 -2
- package/dist/nodefony/src/assets/collectAssets.js +1 -2
- package/dist/nodefony/src/assets/prebuiltUi.js +1 -2
- package/dist/nodefony/src/context/Context.js +23 -31
- package/dist/nodefony/src/context/domainMatcher.js +1 -2
- package/dist/nodefony/src/context/forwarded.js +1 -2
- package/dist/nodefony/src/context/http/HttpContext.js +7 -6
- package/dist/nodefony/src/context/http/Request.js +33 -29
- package/dist/nodefony/src/context/http/Response.js +23 -6
- package/dist/nodefony/src/context/http/SseStream.js +1 -2
- package/dist/nodefony/src/context/http/parser.js +36 -10
- package/dist/nodefony/src/context/http/urlFastPath.js +1 -2
- package/dist/nodefony/src/context/http2/Request.js +4 -8
- package/dist/nodefony/src/context/http2/Response.js +22 -9
- package/dist/nodefony/src/context/metaData.js +1 -2
- package/dist/nodefony/src/context/requestId.js +1 -2
- package/dist/nodefony/src/context/responseEnded.js +1 -2
- package/dist/nodefony/src/context/transport.js +45 -0
- package/dist/nodefony/src/context/trustProxy.js +1 -2
- package/dist/nodefony/src/context/websocket/Response.js +33 -6
- package/dist/nodefony/src/context/websocket/WebsocketContext.js +92 -17
- package/dist/nodefony/src/context/websocket/isWebsocketContext.js +17 -0
- package/dist/nodefony/src/context/websocket/websocketUrl.js +1 -2
- package/dist/nodefony/src/context/websocket/wsBackpressure.js +1 -2
- package/dist/nodefony/src/context/websocket/wsCloseReason.js +1 -2
- package/dist/nodefony/src/context/websocket/wsLogContent.js +1 -2
- package/dist/nodefony/src/cookies/cookie.js +3 -7
- package/dist/nodefony/src/errors/httpError.js +1 -2
- package/dist/nodefony/src/errors/thrownMessage.js +1 -2
- package/dist/nodefony/src/profiler/FrameProfile.js +1 -2
- package/dist/nodefony/src/profiler/Profiler.js +102 -20
- package/dist/nodefony/src/profiler/aggregate.js +422 -0
- package/dist/nodefony/src/proxy/forward.js +29 -5
- package/dist/nodefony/src/proxy/generateProxyConfig.js +14 -6
- package/dist/nodefony/src/proxy/rules.js +12 -6
- package/dist/nodefony/src/rateLimit/MemoryRateLimitStore.js +1 -2
- package/dist/nodefony/src/rateLimit/WsConnectionCounter.js +1 -2
- package/dist/nodefony/src/rateLimit/rateLimitFilters.js +1 -2
- package/dist/nodefony/src/servers/http2Limits.js +50 -0
- package/dist/nodefony/src/servers/kernelServers.js +12 -3
- package/dist/nodefony/src/servers/portBinder.js +41 -10
- package/dist/nodefony/src/servers/upgradeDispatch.js +86 -7
- package/dist/nodefony/src/session/session.js +2 -3
- package/dist/nodefony/src/session/sessionStoreFailure.js +1 -2
- package/dist/nodefony/src/session/storage/MemorySessionStorage.js +1 -2
- package/dist/nodefony/src/session/storage/RevocationGuardStorage.js +1 -2
- package/dist/nodefony/src/session/storage/sessionFilters.js +1 -2
- package/dist/nodefony/src/session/storage/sessionSort.js +1 -2
- package/dist/types/index.d.ts +7 -3
- package/dist/types/nodefony/interfaces/IContext.d.ts +63 -4
- package/dist/types/nodefony/interfaces/IRequest.d.ts +9 -4
- package/dist/types/nodefony/interfaces/IRouting.d.ts +10 -0
- package/dist/types/nodefony/interfaces/index.d.ts +2 -2
- package/dist/types/nodefony/service/certificates.d.ts +55 -4
- package/dist/types/nodefony/service/error-renderer.d.ts +31 -0
- package/dist/types/nodefony/service/http-kernel.d.ts +46 -10
- package/dist/types/nodefony/service/servers/clientError.d.ts +16 -0
- package/dist/types/nodefony/service/servers/server-h2c.d.ts +51 -0
- package/dist/types/nodefony/service/servers/server-http.d.ts +3 -3
- package/dist/types/nodefony/service/servers/server-https.d.ts +4 -4
- package/dist/types/nodefony/service/servers/server-websocket-secure.d.ts +1 -3
- package/dist/types/nodefony/service/servers/server-websocket.d.ts +1 -3
- package/dist/types/nodefony/service/servers/serverShutdown.d.ts +30 -16
- package/dist/types/nodefony/src/context/Context.d.ts +13 -4
- package/dist/types/nodefony/src/context/http/HttpContext.d.ts +1 -1
- package/dist/types/nodefony/src/context/http/Request.d.ts +20 -15
- package/dist/types/nodefony/src/context/http/Response.d.ts +11 -0
- package/dist/types/nodefony/src/context/http/parser.d.ts +18 -2
- package/dist/types/nodefony/src/context/http2/Request.d.ts +1 -1
- package/dist/types/nodefony/src/context/http2/Response.d.ts +15 -5
- package/dist/types/nodefony/src/context/transport.d.ts +53 -0
- package/dist/types/nodefony/src/context/websocket/Response.d.ts +18 -2
- package/dist/types/nodefony/src/context/websocket/WebsocketContext.d.ts +57 -7
- package/dist/types/nodefony/src/context/websocket/isWebsocketContext.d.ts +15 -0
- package/dist/types/nodefony/src/profiler/FrameProfile.d.ts +13 -12
- package/dist/types/nodefony/src/profiler/Profiler.d.ts +68 -15
- package/dist/types/nodefony/src/profiler/aggregate.d.ts +215 -0
- package/dist/types/nodefony/src/proxy/forward.d.ts +14 -2
- package/dist/types/nodefony/src/proxy/generateProxyConfig.d.ts +16 -0
- package/dist/types/nodefony/src/proxy/rules.d.ts +3 -0
- package/dist/types/nodefony/src/servers/http2Limits.d.ts +37 -0
- package/dist/types/nodefony/src/servers/kernelServers.d.ts +10 -1
- package/dist/types/nodefony/src/servers/portBinder.d.ts +29 -5
- package/dist/types/nodefony/src/servers/upgradeDispatch.d.ts +33 -3
- package/docs/cookies.md +85 -78
- package/docs/observabilite.md +80 -80
- package/docs/rate-limit.md +83 -83
- package/docs/reverse-proxy.md +54 -54
- package/docs/servers.md +338 -224
- package/docs/session.md +179 -179
- package/docs/sse.md +24 -20
- package/docs/upload.md +120 -117
- package/package.json +8 -9
- package/dist/_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js +0 -6
package/docs/rate-limit.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
title: "Rate-limit — plafond de trafic par IP (429,
|
|
2
|
+
title: "Rate-limit — plafond de trafic par IP (429, HTTP et handshake WebSocket)"
|
|
3
3
|
navTitle: Rate-limit
|
|
4
4
|
lang: fr
|
|
5
5
|
module: "@nodefony/http"
|
|
@@ -15,7 +15,7 @@ tags:
|
|
|
15
15
|
x-ratelimit,
|
|
16
16
|
fenetre-fixe,
|
|
17
17
|
websocket,
|
|
18
|
-
|
|
18
|
+
handshake,
|
|
19
19
|
ddos,
|
|
20
20
|
trust-proxy,
|
|
21
21
|
]
|
|
@@ -27,11 +27,11 @@ coverageModule: http
|
|
|
27
27
|
coverageFiles: rateLimit/MemoryRateLimitStore.ts,rateLimit/IRateLimitStore.ts,rateLimit/WsConnectionCounter.ts,http-kernel.ts,HttpAdminApi.ts
|
|
28
28
|
---
|
|
29
29
|
|
|
30
|
-
# Rate-limit — plafond de trafic par IP (429,
|
|
30
|
+
# Rate-limit — plafond de trafic par IP (429, HTTP et handshake WebSocket)
|
|
31
31
|
|
|
32
32
|
> Un **tourniquet de métro** posé à l'entrée du processus : chaque IP cliente reçoit un quota de
|
|
33
|
-
> requêtes par fenêtre de temps ; au-delà, on la refoule — un `429 Too Many Requests
|
|
34
|
-
>
|
|
33
|
+
> requêtes par fenêtre de temps ; au-delà, on la refoule — un `429 Too Many Requests`, en HTTP comme
|
|
34
|
+
> au handshake d'un WebSocket (avant le `101` : la socket ne s'ouvre jamais). Le refus se décide **avant** d'allouer le moindre
|
|
35
35
|
> contexte, pour qu'un flood coûte une simple recherche dans une table de hachage. C'est une **défense
|
|
36
36
|
> de capacité par IP**, à ne pas confondre avec le backoff anti-bruteforce du **login** (page voisine,
|
|
37
37
|
> côté sécurité). Chaque fait ci-dessous est ancré sur le code.
|
|
@@ -49,19 +49,19 @@ flowchart TD
|
|
|
49
49
|
IP --> HIT["rateLimiter.hit(ip)<br/>fenêtre fixe · O(1) · synchrone"]
|
|
50
50
|
HIT -->|"sous le quota"| PIPE["→ pipeline<br/>+ en-têtes X-RateLimit-*"]
|
|
51
51
|
HIT -->|"quota dépassé · HTTP"| R429["429 Too Many Requests<br/>+ Retry-After"]
|
|
52
|
-
HIT -->|"quota dépassé · WS"|
|
|
52
|
+
HIT -->|"quota dépassé · handshake WS"| H429["429 au handshake<br/>avant le 101, aucune socket"]
|
|
53
53
|
```
|
|
54
54
|
|
|
55
55
|
Trois idées à retenir :
|
|
56
56
|
|
|
57
57
|
1. **La clé est l'IP, pas l'utilisateur.** On borne du **trafic**, pas des identités. L'IP est
|
|
58
|
-
résolue exactement comme pour les logs et l'audit (`resolveForwarded()`, `http-kernel.ts
|
|
58
|
+
résolue exactement comme pour les logs et l'audit (`resolveForwarded()`, `http-kernel.ts#HttpKernel.dispatchHttpRequest`) —
|
|
59
59
|
non falsifiable tant que `trustProxy` n'accorde pas sa confiance à un proxy.
|
|
60
|
-
2. **Le verdict porte tout.** Un seul appel `hit(key)` (`IRateLimitStore.ts
|
|
61
|
-
`RateLimitVerdict` (`IRateLimitStore.ts
|
|
60
|
+
2. **Le verdict porte tout.** Un seul appel `hit(key)` (`IRateLimitStore.ts#IRateLimitStore.hit`) rend un
|
|
61
|
+
`RateLimitVerdict` (`IRateLimitStore.ts#RateLimitVerdict`) qui contient déjà limite, restant, reset et
|
|
62
62
|
`Retry-After` — de quoi émettre les en-têtes sans relire l'état.
|
|
63
63
|
3. **HTTP et WebSocket partagent le même compteur.** Un upgrade WS **est** une requête HTTP : il passe
|
|
64
|
-
par le même `hit()`,
|
|
64
|
+
par le même `hit()`, et il est refoulé de la même façon : un `429`, avant que le `101` ne parte.
|
|
65
65
|
|
|
66
66
|
## 📖 Lexique
|
|
67
67
|
|
|
@@ -75,7 +75,7 @@ Trois idées à retenir :
|
|
|
75
75
|
| `429` | _Too Many Requests_ (RFC 6585 §4) : le code HTTP du refoulement. |
|
|
76
76
|
| `Retry-After` | En-tête indiquant au client **combien de secondes** attendre avant de réessayer. |
|
|
77
77
|
| `X-RateLimit-*` | Famille d'en-têtes de facto (`Limit`, `Remaining`, `Reset`) qui expose l'état du quota au client. |
|
|
78
|
-
|
|
|
78
|
+
| Handshake refusé | Refus d'un upgrade WebSocket par un statut HTTP AVANT le `101` (RFC 6455 §4.2.2) : la socket ne s'ouvre jamais. |
|
|
79
79
|
| IP forwarded-aware | IP cliente réelle reconstituée derrière un proxy via `X-Forwarded-For` / `Forwarded` (RFC 7239), sous contrôle de trust. |
|
|
80
80
|
| `trustProxy` | Réglage qui décide si l'on croit les en-têtes de proxy pour établir l'IP. `false` par défaut (non falsifiable). |
|
|
81
81
|
| `maxTracked` | Borne mémoire : nombre max d'IP suivies simultanément. Au cap → purge des expirées puis éviction FIFO. |
|
|
@@ -89,9 +89,9 @@ processus : famine de l'event-loop, mémoire qui gonfle, latence p99 qui explose
|
|
|
89
89
|
C'est le cœur d'un **déni de service applicatif** (DoS), volontaire ou accidentel (un script en boucle,
|
|
90
90
|
un crawler mal réglé).
|
|
91
91
|
|
|
92
|
-
Le rate-limit **refoule** l'IP fautive avant qu'elle ne coûte cher : elle reçoit un `429` (
|
|
93
|
-
|
|
94
|
-
intactes. Le quota est **isolé par IP** (`rateLimit.test.ts
|
|
92
|
+
Le rate-limit **refoule** l'IP fautive avant qu'elle ne coûte cher : elle reçoit un `429` (au handshake,
|
|
93
|
+
pour un WebSocket) tant qu'elle dépasse son quota, pendant que les autres IP passent
|
|
94
|
+
intactes. Le quota est **isolé par IP** (`rateLimit.test.ts`) : une IP saturée n'affecte jamais ses
|
|
95
95
|
voisines.
|
|
96
96
|
|
|
97
97
|
> [!IMPORTANT]
|
|
@@ -106,20 +106,20 @@ Trois choix structurent l'implémentation, et chacun est un compromis assumé.
|
|
|
106
106
|
|
|
107
107
|
**Désactivé par défaut — opt-in explicite.** En cloud-native, le plafond par IP est souvent mieux placé
|
|
108
108
|
à l'**ingress/gateway** (il voit tout le trafic, tous les pods, et rejette avant le coût TLS). Le module
|
|
109
|
-
laisse donc `rateLimit` désarmé par défaut (`config.ts
|
|
109
|
+
laisse donc `rateLimit` désarmé par défaut (`config.ts#httpConfigSchema`) : `null` tant qu'on ne l'active pas → **0
|
|
110
110
|
coût** sur le chemin chaud. On l'active quand on n'a **pas** d'edge devant soi (bare-metal, VPS), ou en
|
|
111
111
|
défense en profondeur.
|
|
112
112
|
|
|
113
113
|
**Fenêtre fixe, en mémoire, O(1).** L'algorithme est le plus frugal possible :
|
|
114
|
-
`MemoryRateLimitStore` (`MemoryRateLimitStore.ts
|
|
114
|
+
`MemoryRateLimitStore` (`MemoryRateLimitStore.ts#MemoryRateLimitStore`) tient une entrée `{ count, resetAt }` par IP,
|
|
115
115
|
remise à zéro **en place** à l'expiration (0 allocation pour une IP récurrente). Le prix de cette
|
|
116
116
|
simplicité est connu : un pic à cheval sur deux fenêtres peut laisser passer jusqu'à `2 × max` sur un
|
|
117
|
-
court intervalle (`MemoryRateLimitStore.ts
|
|
117
|
+
court intervalle (`MemoryRateLimitStore.ts#MemoryRateLimitStore`). Acceptable pour une défense de **capacité** ; un
|
|
118
118
|
_sliding window_ viendrait en option si le besoin s'en fait sentir.
|
|
119
119
|
|
|
120
120
|
**Refoulé avant toute allocation.** Le verdict est rendu **avant** le contexte, la portée DI et l'ALS
|
|
121
|
-
(`http-kernel.ts
|
|
122
|
-
**synchrone** à dessein (`IRateLimitStore.ts
|
|
121
|
+
(`http-kernel.ts#HttpKernel.dispatchHttpRequest`) : un flood coûte un `Map.get` et rien d'autre. Le contrat `hit()` est
|
|
122
|
+
**synchrone** à dessein (`IRateLimitStore.ts#IRateLimitStore.hit`) — aucune `Promise`, aucune microtask sur le chemin de
|
|
123
123
|
chaque requête.
|
|
124
124
|
|
|
125
125
|
## 🚀 Démarrage rapide
|
|
@@ -147,7 +147,7 @@ export default defineConfig(() => ({
|
|
|
147
147
|
```
|
|
148
148
|
|
|
149
149
|
Les trois clés `enabled` / `windowS` / `max` sont **éditables à chaud** (`runtimeMutable`) : le kernel
|
|
150
|
-
reconstruit le compteur sans redémarrage (`configureRateLimit()`, `http-kernel.ts
|
|
150
|
+
reconstruit le compteur sans redémarrage (`configureRateLimit()`, `http-kernel.ts#HttpKernel.configureRateLimit`).
|
|
151
151
|
|
|
152
152
|
### 2. Observer le 429 et les en-têtes
|
|
153
153
|
|
|
@@ -178,14 +178,14 @@ X-RateLimit-Reset: 1753082460
|
|
|
178
178
|
Retry-After: 42
|
|
179
179
|
```
|
|
180
180
|
|
|
181
|
-
- `X-RateLimit-Remaining` : requêtes restantes dans la fenêtre (`http-kernel.ts
|
|
182
|
-
- `X-RateLimit-Reset` : **epoch en secondes** de la fin de fenêtre (`http-kernel.ts
|
|
181
|
+
- `X-RateLimit-Remaining` : requêtes restantes dans la fenêtre (`http-kernel.ts#HttpKernel.dispatchHttpRequest`).
|
|
182
|
+
- `X-RateLimit-Reset` : **epoch en secondes** de la fin de fenêtre (`http-kernel.ts#HttpKernel.dispatchHttpRequest`).
|
|
183
183
|
- `Retry-After` (sur le `429` seulement) : secondes à attendre, **jamais 0** — un `Retry-After: 0`
|
|
184
|
-
relancerait un client bien élevé immédiatement (`MemoryRateLimitStore.ts
|
|
184
|
+
relancerait un client bien élevé immédiatement (`MemoryRateLimitStore.ts#MemoryRateLimitStore.hit`).
|
|
185
185
|
|
|
186
186
|
## 🏗️ Architecture interne — le parcours d'un `hit`
|
|
187
187
|
|
|
188
|
-
La logique de fenêtre fixe tient dans `hit()` (`MemoryRateLimitStore.ts
|
|
188
|
+
La logique de fenêtre fixe tient dans `hit()` (`MemoryRateLimitStore.ts#MemoryRateLimitStore.hit`). Trois cas, tous O(1) :
|
|
189
189
|
|
|
190
190
|
```mermaid
|
|
191
191
|
flowchart TD
|
|
@@ -203,34 +203,34 @@ 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
|
|
208
|
-
(`http-kernel.ts
|
|
209
|
-
- **Émission HTTP** : sous le quota, les en-têtes `X-RateLimit-*` sont posés (`http-kernel.ts
|
|
210
|
-
la requête continue ; au-delà, `Retry-After` (`http-kernel.ts
|
|
211
|
-
(`http-kernel.ts
|
|
206
|
+
- **Construction / reconfiguration** : `configureRateLimit()` (`http-kernel.ts#HttpKernel.configureRateLimit`) instancie le store
|
|
207
|
+
depuis la config (`windowMs = windowS × 1000`, `http-kernel.ts#HttpKernel.onConfigChanged`) et arme un `GcScheduler`
|
|
208
|
+
(`http-kernel.ts#HttpKernel.configureRateLimit`) qui **purge les fenêtres expirées** hors du chemin chaud.
|
|
209
|
+
- **Émission HTTP** : sous le quota, les en-têtes `X-RateLimit-*` sont posés (`http-kernel.ts#HttpKernel.dispatchHttpRequest`) et
|
|
210
|
+
la requête continue ; au-delà, `Retry-After` (`http-kernel.ts#HttpKernel.dispatchHttpRequest`) puis `writeHead(429)`
|
|
211
|
+
(`http-kernel.ts#HttpKernel.dispatchHttpRequest`) — corps vide, on ne journalise pas chaque rejet (amplificateur sous flood).
|
|
212
212
|
- **Borne mémoire** : au cap `maxTracked`, le store purge les expirées puis évince en **FIFO**
|
|
213
|
-
(`#evict`, `MemoryRateLimitStore.ts
|
|
213
|
+
(`#evict`, `MemoryRateLimitStore.ts#MemoryRateLimitStore.#evict`) — la mémoire ne dérive jamais.
|
|
214
214
|
|
|
215
215
|
## ⚙️ Configuration
|
|
216
216
|
|
|
217
|
-
Table dérivée de `rateLimitSchema` (`config.ts
|
|
217
|
+
Table dérivée de `rateLimitSchema` (`config.ts#rateLimitSchema`). Tout est optionnel : ce sont les défauts du
|
|
218
218
|
schéma, écrits ici pour les montrer.
|
|
219
219
|
|
|
220
|
-
| Option | Type | Défaut | Effet
|
|
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
|
|
224
|
-
| `max` | int | `300` | Requêtes/IP/fenêtre ; au-delà `429` + `Retry-After` (`config.ts
|
|
225
|
-
| `maxTracked` | int (≥ 1000) | `100 000` | Borne mémoire : IP suivies ; au cap, purge puis éviction FIFO (`config.ts
|
|
226
|
-
| `gcIntervalS` | int (s) | `300` | Intervalle du balayage de purge des fenêtres expirées, hors hot-path.
|
|
227
|
-
| `gcJitter` | bool | `true` | Étale le tick GC d'un jitter aléatoire (anti-thundering-herd multi-pod).
|
|
220
|
+
| Option | Type | Défaut | Effet | Chaud |
|
|
221
|
+
| ------------- | ------------ | --------- | -------------------------------------------------------------------------------------------- | ----- |
|
|
222
|
+
| `enabled` | bool | `false` | Arme le rate-limit (HTTP **et** handshakes WS, même compteur) (`config.ts#rateLimitSchema`). | oui |
|
|
223
|
+
| `windowS` | int (s) | `60` | Largeur de la fenêtre fixe ; le compteur par IP repart à zéro (`config.ts#rateLimitSchema`). | oui |
|
|
224
|
+
| `max` | int | `300` | Requêtes/IP/fenêtre ; au-delà `429` + `Retry-After` (`config.ts#rateLimitSchema`). | oui |
|
|
225
|
+
| `maxTracked` | int (≥ 1000) | `100 000` | Borne mémoire : IP suivies ; au cap, purge puis éviction FIFO (`config.ts#rateLimitSchema`). | non |
|
|
226
|
+
| `gcIntervalS` | int (s) | `300` | Intervalle du balayage de purge des fenêtres expirées, hors hot-path. | non |
|
|
227
|
+
| `gcJitter` | bool | `true` | Étale le tick GC d'un jitter aléatoire (anti-thundering-herd multi-pod). | non |
|
|
228
228
|
|
|
229
229
|
Et un réglage **séparé**, propre au WebSocket, à la racine du module :
|
|
230
230
|
|
|
231
|
-
| Option | Type | Défaut | Effet
|
|
232
|
-
| ----------------------- | ------------- | ------ |
|
|
233
|
-
| `wsMaxConnectionsPerIp` | int \| `null` | `null` | Cap de connexions WS **concurrentes** par IP ; au-delà, upgrade
|
|
231
|
+
| Option | Type | Défaut | Effet | Chaud |
|
|
232
|
+
| ----------------------- | ------------- | ------ | --------------------------------------------------------------------------------------------------------------- | ----- |
|
|
233
|
+
| `wsMaxConnectionsPerIp` | int \| `null` | `null` | Cap de connexions WS **concurrentes** par IP ; au-delà, upgrade refusé en `429` (`config.ts#httpConfigSchema`). | 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
|
|
@@ -239,22 +239,22 @@ Et un réglage **séparé**, propre au WebSocket, à la racine du module :
|
|
|
239
239
|
|
|
240
240
|
## 🔌 Rate-limit côté WebSocket
|
|
241
241
|
|
|
242
|
-
Un WebSocket
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
242
|
+
Un WebSocket est refoulé comme une requête HTTP : par un **`429` au handshake**, AVANT que le
|
|
243
|
+
`101 Switching Protocols` ne parte (RFC 6455 §4.2.2) — aucune socket ne s'ouvre, le client voit un
|
|
244
|
+
échec d'ouverture. Le refus se décide en tête de `onWebsocketUpgrade()` (`http-kernel.ts#HttpKernel.onWebsocketUpgrade`) —
|
|
245
|
+
**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
|
-
| Plafond | Ce qu'il borne | Source de config | Refus
|
|
250
|
-
| ---------------------- | ---------------------------------------------- | ----------------------- |
|
|
251
|
-
| Débit de handshakes | Ouvertures/seconde (le **même** compteur HTTP) | `rateLimit` |
|
|
252
|
-
| Connexions simultanées | Sockets **ouvertes** en même temps par IP | `wsMaxConnectionsPerIp` |
|
|
249
|
+
| Plafond | Ce qu'il borne | Source de config | Refus |
|
|
250
|
+
| ---------------------- | ---------------------------------------------- | ----------------------- | -------------------------------------------------------------------------------------------- |
|
|
251
|
+
| Débit de handshakes | Ouvertures/seconde (le **même** compteur HTTP) | `rateLimit` | `429` au handshake (`http-kernel.ts#HttpKernel.onConfigChanged`) |
|
|
252
|
+
| Connexions simultanées | Sockets **ouvertes** en même temps par IP | `wsMaxConnectionsPerIp` | `429` au handshake — `tryAcquire` refuse (`http-kernel.ts#HttpKernel.websocketQuotaRefusal`) |
|
|
253
253
|
|
|
254
|
-
Le cap concurrent est porté par un compteur dédié, `WsConnectionCounter` (`WsConnectionCounter.ts
|
|
255
|
-
`tryAcquire(ip)` (`WsConnectionCounter.ts
|
|
256
|
-
(`WsConnectionCounter.ts
|
|
257
|
-
(`http-kernel.ts
|
|
254
|
+
Le cap concurrent est porté par un compteur dédié, `WsConnectionCounter` (`WsConnectionCounter.ts#WsConnectionCounter`) :
|
|
255
|
+
`tryAcquire(ip)` (`WsConnectionCounter.ts#WsConnectionCounter.tryAcquire`) réserve un créneau à l'upgrade, `release(ip)`
|
|
256
|
+
(`WsConnectionCounter.ts#WsConnectionCounter.release`) le rend à la fermeture — branché sur `ws.once("close", …)`
|
|
257
|
+
(`http-kernel.ts#HttpKernel.createWebsocketContext`), donc jamais de fuite de compteur, même sur un `terminate` de heartbeat.
|
|
258
258
|
|
|
259
259
|
> [!WARNING]
|
|
260
260
|
> `wsMaxConnectionsPerIp` a une **portée par process** (1 pod) : il ne voit que le trafic de son propre
|
|
@@ -268,26 +268,26 @@ bornes **par message** (taille `maxPayload` → close `1009`, backpressure), dé
|
|
|
268
268
|
|
|
269
269
|
## 🧩 Étendre — un store distribué
|
|
270
270
|
|
|
271
|
-
Tout passe par le contrat `IRateLimitStore` (`IRateLimitStore.ts
|
|
271
|
+
Tout passe par le contrat `IRateLimitStore` (`IRateLimitStore.ts#IRateLimitStore`). L'implémentation par défaut est
|
|
272
272
|
en mémoire (par process), mais le contrat est pensé pour un futur backend **distribué** (Redis,
|
|
273
273
|
multi-pod) : `hit()` reste synchrone (le hot-path ne tolère pas de `Promise`), tandis que l'introspection
|
|
274
|
-
`listPage()` (`IRateLimitStore.ts
|
|
274
|
+
`listPage()` (`IRateLimitStore.ts#IRateLimitStore.listPage`) est asynchrone — un store distribué la servira par `SCAN`.
|
|
275
275
|
|
|
276
276
|
Un adapter doit fournir : `hit(key)` (verdict de fenêtre), `gc()` (purge), `listPage(query)`
|
|
277
|
-
(introspection admin), plus les métriques `trackedCount` et `rejectedTotal` (`IRateLimitStore.ts
|
|
277
|
+
(introspection admin), plus les métriques `trackedCount` et `rejectedTotal` (`IRateLimitStore.ts#IRateLimitStore.rejectedTotal`).
|
|
278
278
|
|
|
279
279
|
## 📜 Normes appliquées
|
|
280
280
|
|
|
281
|
-
| Domaine
|
|
282
|
-
|
|
|
283
|
-
| `429 Too Many Requests`
|
|
284
|
-
| `Retry-After` (delta-seconds)
|
|
285
|
-
| IP cliente derrière proxy
|
|
286
|
-
|
|
|
281
|
+
| Domaine | Norme | Ancrage |
|
|
282
|
+
| ------------------------------ | ---------------- | --------------------------------------------------------------------------- |
|
|
283
|
+
| `429 Too Many Requests` | RFC 6585 §4 | `writeHead(429)` (`http-kernel.ts#HttpKernel.dispatchHttpRequest`) |
|
|
284
|
+
| `Retry-After` (delta-seconds) | RFC 9110 §10.2.3 | en-tête posé sur le `429` (`http-kernel.ts#HttpKernel.dispatchHttpRequest`) |
|
|
285
|
+
| IP cliente derrière proxy | RFC 7239 | `resolveForwarded()` (`http-kernel.ts#HttpKernel.dispatchHttpRequest`) |
|
|
286
|
+
| Refus d'un handshake WebSocket | RFC 6455 §4.2.2 | `429` avant le `101` (`http-kernel.ts#HttpKernel.onWebsocketUpgrade`) |
|
|
287
287
|
|
|
288
288
|
> [!NOTE]
|
|
289
289
|
> Les en-têtes émis sont la famille **de facto** `X-RateLimit-Limit/Remaining/Reset`
|
|
290
|
-
> (`http-kernel.ts
|
|
290
|
+
> (`http-kernel.ts#HttpKernel.dispatchHttpRequest`), largement déployée et lue par les clients. Le brouillon IETF
|
|
291
291
|
> `draft-ietf-httpapi-ratelimit-headers` (en-têtes `RateLimit` / `RateLimit-Policy`) n'est **pas** encore
|
|
292
292
|
> émis — une évolution possible, pas une régression : rien ne le promet aujourd'hui.
|
|
293
293
|
|
|
@@ -297,13 +297,13 @@ Le rate-limit vit sur le **chemin chaud absolu** — il s'exécute avant tout le
|
|
|
297
297
|
Les choix visibles dans le code :
|
|
298
298
|
|
|
299
299
|
- **Synchrone, O(1)** : `hit()` = 1 `Map.get` + arithmétique, zéro `Promise`, zéro microtask
|
|
300
|
-
(`IRateLimitStore.ts
|
|
301
|
-
- **Lazy** : la `Map` interne n'est allouée qu'au **premier** hit (`MemoryRateLimitStore.ts
|
|
300
|
+
(`IRateLimitStore.ts`).
|
|
301
|
+
- **Lazy** : la `Map` interne n'est allouée qu'au **premier** hit (`MemoryRateLimitStore.ts#MemoryRateLimitStore`) →
|
|
302
302
|
quand le rate-limit est désarmé (défaut), le coût mémoire est **nul**.
|
|
303
303
|
- **0 alloc pour une IP connue** : la fenêtre expirée se réinitialise **en place**, pas de nouvel objet.
|
|
304
304
|
- **Rejet avant allocation** : un flood est refoulé avant le contexte / la portée DI / l'ALS
|
|
305
|
-
(`http-kernel.ts
|
|
306
|
-
- **Résolution d'IP seulement si un limiteur est armé** côté WS (`http-kernel.ts
|
|
305
|
+
(`http-kernel.ts#HttpKernel.prepareFrontController`) → un attaquant paie une recherche de hachage, pas un pipeline complet.
|
|
306
|
+
- **Résolution d'IP seulement si un limiteur est armé** côté WS (`http-kernel.ts#HttpKernel.teardownHttp`).
|
|
307
307
|
- **Mémoire bornée** : `maxTracked` + éviction FIFO ; purge périodique `unref` hors hot-path.
|
|
308
308
|
|
|
309
309
|
Rejouer la pression sous charge : skill `nodefony-load-test`. Gate mémoire avant tout commit touchant le
|
|
@@ -311,28 +311,28 @@ pipeline : `npm run test:memory` (skill `nodefony-check-memory-health`).
|
|
|
311
311
|
|
|
312
312
|
## 📡 Observabilité — Studio
|
|
313
313
|
|
|
314
|
-
Le data plane admin expose **qui martèle** via `createHttpAdminApi()` (`HttpAdminApi.ts
|
|
314
|
+
Le data plane admin expose **qui martèle** via `createHttpAdminApi()` (`HttpAdminApi.ts#createHttpAdminApi`) :
|
|
315
315
|
|
|
316
|
-
- **`GET /nodefony/http/api/rate-limit/list`** (`HttpAdminApi.ts
|
|
316
|
+
- **`GET /nodefony/http/api/rate-limit/list`** (`HttpAdminApi.ts#createHttpAdminApi`) — les IP suivies, **les plus
|
|
317
317
|
bruyantes d'abord** (tri `count` décroissant), paginé **côté serveur** (`?limited&q&limit&offset`).
|
|
318
318
|
`q` filtre par **préfixe** d'IP (un sous-réseau `10.0.`), pas par sous-chaîne.
|
|
319
|
-
- **Réservé `ROLE_NODEFONY_ADMIN`** (`HttpAdminApi.ts
|
|
319
|
+
- **Réservé `ROLE_NODEFONY_ADMIN`** (`HttpAdminApi.ts#createHttpAdminApi`) : une IP est une **donnée personnelle** →
|
|
320
320
|
seul l'état du compteur sort d'ici, jamais l'URL, l'en-tête ou le corps des requêtes.
|
|
321
321
|
- **État honnête quand désarmé** : rate-limit désactivé (le défaut) → `enabled: false` + liste vide, pas
|
|
322
|
-
un `503` (`HttpAdminApi.ts
|
|
322
|
+
un `503` (`HttpAdminApi.ts#createHttpAdminApi`). La console affiche « désarmé » plutôt qu'une erreur.
|
|
323
323
|
- Métriques exposées : `trackedCount` (IP suivies) et `rejectedTotal` (429 cumulés depuis le boot).
|
|
324
324
|
|
|
325
325
|
## ⚠️ Pièges (symptôme → cause → correction)
|
|
326
326
|
|
|
327
|
-
| Symptôme | Cause
|
|
328
|
-
| ------------------------------------------------------ |
|
|
329
|
-
| Le rate-limit « ne fait rien » | Désactivé par défaut (opt-in)
|
|
330
|
-
| Toutes les IP derrière le proxy comptent comme **une** | `trustProxy: false` → l'IP vue est celle du proxy, pas du client
|
|
331
|
-
| Un burst laisse passer près de `2 × max` | Limite **assumée** de la fenêtre fixe (pic à cheval sur deux fenêtres)
|
|
332
|
-
| Un WebSocket
|
|
333
|
-
| `wsMaxConnectionsPerIp` semble inefficace en cluster | Portée **par process** — chaque pod compte pour lui
|
|
334
|
-
| Confusion avec le lockout de login | `security.rateLimit` = backoff NIST **par identifiant**, brique distincte
|
|
335
|
-
| Une IP « fantôme » n'est jamais comptée | `resolveForwarded()` renvoie `null` (aucun socket fiable)
|
|
327
|
+
| Symptôme | Cause | Correction |
|
|
328
|
+
| ------------------------------------------------------ | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
|
|
329
|
+
| Le rate-limit « ne fait rien » | Désactivé par défaut (opt-in) | `rateLimit.enabled: true` dans `use("@nodefony/http", …)` |
|
|
330
|
+
| Toutes les IP derrière le proxy comptent comme **une** | `trustProxy: false` → l'IP vue est celle du proxy, pas du client | Régler `trustProxy` sur l'IP/CIDR du proxy — voir [Serveurs](servers.md) |
|
|
331
|
+
| Un burst laisse passer près de `2 × max` | Limite **assumée** de la fenêtre fixe (pic à cheval sur deux fenêtres) | Réduire `windowS`, ou attendre l'option _sliding window_ |
|
|
332
|
+
| Un WebSocket échoue à s'ouvrir sous charge (`1006`) | Débit ou cap de connexions dépassé : `429` au handshake, que le navigateur cache | Attendu : `NodefonySocket` le lit par sa sonde (`429` = transitoire, il retente) |
|
|
333
|
+
| `wsMaxConnectionsPerIp` semble inefficace en cluster | Portée **par process** — chaque pod compte pour lui | Déléguer le cap global/IP à l'ingress (nginx `limit_conn`, HAProxy) |
|
|
334
|
+
| Confusion avec le lockout de login | `security.rateLimit` = backoff NIST **par identifiant**, brique distincte | Ce sont deux étages différents — cf [@nodefony/security](../../security/docs/index.md) |
|
|
335
|
+
| Une IP « fantôme » n'est jamais comptée | `resolveForwarded()` renvoie `null` (aucun socket fiable) | Attendu : on ne compte jamais sous une clé `null` (qui deviendrait un DoS) |
|
|
336
336
|
|
|
337
337
|
## 🧪 Tests & couverture
|
|
338
338
|
|
|
@@ -351,7 +351,7 @@ Ce qui **manque** aujourd'hui :
|
|
|
351
351
|
- Aucun test d'**intégration** ne prouve, sur un serveur vivant, le `429` HTTP **et** les en-têtes
|
|
352
352
|
`X-RateLimit-*` de bout en bout (le comportement du store est prouvé unitairement, son câblage kernel
|
|
353
353
|
ne l'est pas).
|
|
354
|
-
- Aucun test d'intégration ne couvre
|
|
354
|
+
- Aucun test d'intégration ne couvre le **refus `429` au handshake** (débit de handshakes et cap concurrent) :
|
|
355
355
|
le compteur est prouvé unitairement, le refus WS de bout en bout est exercé par les bancs E2E
|
|
356
356
|
(`nodefony-load-test` → `run.sh ws-handshake-rl` / `ws-conn-cap`), pas par une suite du module.
|
|
357
357
|
- Pas de banc de **charge dédié** au coût du rate-limit sur le hot-path.
|
package/docs/reverse-proxy.md
CHANGED
|
@@ -56,11 +56,11 @@ flowchart LR
|
|
|
56
56
|
Trois idées suffisent pour raisonner :
|
|
57
57
|
|
|
58
58
|
1. **Un montage = un préfixe → une origine.** `"/billing/"` → `http://127.0.0.1:8080`. Le préfixe le
|
|
59
|
-
plus long gagne (`ReverseProxy.match()`, `reverse-proxy.ts
|
|
59
|
+
plus long gagne (`ReverseProxy.match()`, `reverse-proxy.ts#ReverseProxy.match`).
|
|
60
60
|
2. **L'aiguillage passe AVANT le routage.** Une route attrape-tout de l'application ne peut pas
|
|
61
|
-
avaler un préfixe monté (`HttpKernel.routeHttpRequest()`, `http-kernel.ts
|
|
61
|
+
avaler un préfixe monté (`HttpKernel.routeHttpRequest()`, `http-kernel.ts#HttpKernel.routeHttpRequest`).
|
|
62
62
|
3. **Sans montage, il ne coûte rien.** Le pipeline lit un champ (`mounts === null`) et passe son
|
|
63
|
-
chemin (`ReverseProxy.mounts`, `reverse-proxy.ts
|
|
63
|
+
chemin (`ReverseProxy.mounts`, `reverse-proxy.ts#ReverseProxy.mounts`).
|
|
64
64
|
|
|
65
65
|
## 📖 Lexique
|
|
66
66
|
|
|
@@ -100,17 +100,17 @@ autres. Mal écrit, un proxy ouvre ses propres failles, et c'est contre elles qu
|
|
|
100
100
|
|
|
101
101
|
- **Contournement de préfixe** — `/billing/..%2fadmin` : le proxy voit `/billing/`, l'amont décode
|
|
102
102
|
`/admin`. Un chemin ambigu est refusé en 400, jamais normalisé en silence (`isAmbiguousPath()`,
|
|
103
|
-
`rules.ts
|
|
103
|
+
`rules.ts#isAmbiguousPath`).
|
|
104
104
|
- **Désynchronisation de requête** (_request smuggling_) — proxy et amont ne s'accordent pas sur la
|
|
105
|
-
fin du corps. Le cadrage est recalculé à chaque relais (`bodyFramingOf()`, `forward.ts
|
|
105
|
+
fin du corps. Le cadrage est recalculé à chaque relais (`bodyFramingOf()`, `forward.ts#bodyFramingOf`).
|
|
106
106
|
- **Usurpation d'origine** — un client pose lui-même `X-Forwarded-For: 10.0.0.1`. Ces en-têtes ne
|
|
107
|
-
sont crus que d'un pair `trustProxy` (`requestHeadersFor()`, `forward.ts
|
|
107
|
+
sont crus que d'un pair `trustProxy` (`requestHeadersFor()`, `forward.ts#requestHeadersFor`).
|
|
108
108
|
|
|
109
109
|
## La vision Nodefony
|
|
110
110
|
|
|
111
111
|
Le proxy est un **service générique** de `@nodefony/http`, `reverse-proxy` (`ReverseProxy`,
|
|
112
|
-
`reverse-proxy.ts
|
|
113
|
-
(`proxyMountProblems()`, `rules.ts
|
|
112
|
+
`reverse-proxy.ts#ReverseProxy`). Il accepte deux sources de montages, soumises aux **mêmes règles**
|
|
113
|
+
(`proxyMountProblems()`, `rules.ts#proxyMountProblems`) :
|
|
114
114
|
|
|
115
115
|
- **la configuration** de l'application — `proxy.mounts`, validée au démarrage ;
|
|
116
116
|
- **un module**, par `mount()` — c'est ainsi que `@nodefony/frontend` sert Vite sous `/_vite/`.
|
|
@@ -130,7 +130,7 @@ Le compromis assumé :
|
|
|
130
130
|
> **Un préfixe monté est servi AVANT le pare-feu, la protection CSRF et les en-têtes de sécurité
|
|
131
131
|
> APPLICATIFS de l'application (CSP, `Referrer-Policy`, COOP).** C'est une surface publique pour qui
|
|
132
132
|
> atteint Nodefony : l'amont porte sa propre politique d'accès (`ReverseProxy.forward()`,
|
|
133
|
-
> `reverse-proxy.ts
|
|
133
|
+
> `reverse-proxy.ts#ReverseProxy.forward`). Ne montez jamais un service qui suppose être protégé par le pare-feu
|
|
134
134
|
> Nodefony.
|
|
135
135
|
|
|
136
136
|
Ce qui s'applique malgré tout, parce que posé à l'entrée, avant l'aiguillage :
|
|
@@ -212,12 +212,12 @@ via: 1.1 nodefony-3f9a1c2e
|
|
|
212
212
|
- Le préfixe a été retiré du chemin, et l'amont l'apprend par `X-Forwarded-Prefix`.
|
|
213
213
|
- `Host` est celui de l'amont ; le nom demandé par le client part dans `X-Forwarded-Host`.
|
|
214
214
|
- `Via` porte le pseudonyme de CE processus, tiré au hasard au démarrage — il ne dit rien de la
|
|
215
|
-
machine (`ReverseProxy.pseudonym`, `reverse-proxy.ts
|
|
215
|
+
machine (`ReverseProxy.pseudonym`, `reverse-proxy.ts#ReverseProxy.pseudonym`).
|
|
216
216
|
|
|
217
217
|
## ⚙️ Configuration
|
|
218
218
|
|
|
219
219
|
Section `proxy` de `use("@nodefony/http", { … })`, schéma Zod strict : une clé inconnue interrompt le
|
|
220
|
-
démarrage en la nommant (`proxySchema`, `config.ts
|
|
220
|
+
démarrage en la nommant (`proxySchema`, `config.ts#proxySchema`).
|
|
221
221
|
|
|
222
222
|
| Option | Type | Défaut | Effet |
|
|
223
223
|
| ------------------ | -------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
@@ -226,7 +226,7 @@ démarrage en la nommant (`proxySchema`, `config.ts:1085`).
|
|
|
226
226
|
| `maxSockets` | entier | `256` | Connexions simultanées vers UN amont (pool dédié par montage) ; au-delà, les requêtes attendent. Borne aussi les tunnels WebSocket, hors pool : au-delà, 503. |
|
|
227
227
|
| `mounts` | `record` | `{}` | Préfixe → montage. Préfixe le plus long d'abord. |
|
|
228
228
|
|
|
229
|
-
Chaque montage (`proxyMountSchema`, `config.ts
|
|
229
|
+
Chaque montage (`proxyMountSchema`, `config.ts#proxyMountSchema`) :
|
|
230
230
|
|
|
231
231
|
| Option | Type | Défaut | Effet |
|
|
232
232
|
| -------------- | ---------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
|
@@ -239,13 +239,13 @@ Chaque montage (`proxyMountSchema`, `config.ts:1013`) :
|
|
|
239
239
|
| `timeoutMs` | entier | `proxy.timeoutMs` | Délai d'inactivité propre au montage, handshake WebSocket compris (jusqu'au `101`). Ne s'applique pas à un WebSocket établi. |
|
|
240
240
|
| `secure` | `boolean` | `true` | Vérifie le certificat d'une cible `https`. `false` n'est accepté que vers la boucle locale. |
|
|
241
241
|
|
|
242
|
-
**Refusés au démarrage**, avec le motif (`proxyMountProblems()`, `rules.ts
|
|
242
|
+
**Refusés au démarrage**, avec le motif (`proxyMountProblems()`, `rules.ts#proxyMountProblems`) :
|
|
243
243
|
|
|
244
244
|
- le préfixe `/` — il relaierait toute l'application, routes et pare-feu compris ;
|
|
245
245
|
- un préfixe sous `/nodefony/` (console d'administration) ou, depuis la configuration, sous
|
|
246
|
-
`/_vite/` (monté par `@nodefony/frontend`) — `RESERVED_PROXY_PREFIXES` (`rules.ts
|
|
246
|
+
`/_vite/` (monté par `@nodefony/frontend`) — `RESERVED_PROXY_PREFIXES` (`rules.ts#RESERVED_PROXY_PREFIXES`) ;
|
|
247
247
|
- un préfixe qui contient `?`, `#`, `%`, `\`, un espace ou un segment `.`/`..` ;
|
|
248
|
-
- une cible qui n'est pas une origine `http(s)` nue (`parseProxyOrigin()`, `rules.ts
|
|
248
|
+
- une cible qui n'est pas une origine `http(s)` nue (`parseProxyOrigin()`, `rules.ts#parseProxyOrigin`) ;
|
|
249
249
|
- deux clés qui désignent le même préfixe (`/svc` et `/svc/`).
|
|
250
250
|
|
|
251
251
|
### Choisir les réglages — en situation
|
|
@@ -259,7 +259,7 @@ retirer.
|
|
|
259
259
|
|
|
260
260
|
**L'amont croit être à la racine** (un service existant qu'on ne modifie pas) : retirer le préfixe.
|
|
261
261
|
Ses redirections `Location: /login` sont ramenées en `/billing/login`
|
|
262
|
-
(`rewriteLocation()`, `forward.ts
|
|
262
|
+
(`rewriteLocation()`, `forward.ts#rewriteLocation`).
|
|
263
263
|
|
|
264
264
|
```ts ignore
|
|
265
265
|
"/billing/": { target: "http://127.0.0.1:8080", stripPrefix: true }
|
|
@@ -305,7 +305,7 @@ réglage.
|
|
|
305
305
|
Ce qui se règle reste côté frontend, dans `use("@nodefony/frontend", { … })` : `devHost` (défaut
|
|
306
306
|
`127.0.0.1`, à garder — le navigateur ne joint jamais Vite directement) et `devPort` (défaut `5173`,
|
|
307
307
|
chaque famille prend le port suivant). Le préfixe `/_vite/` est refusé dans `proxy.mounts` : il
|
|
308
|
-
appartient au module (`MODULE_PROXY_PREFIXES`, `rules.ts
|
|
308
|
+
appartient au module (`MODULE_PROXY_PREFIXES`, `rules.ts#MODULE_PROXY_PREFIXES`). En production, Vite ne tourne pas et
|
|
309
309
|
aucun montage n'est posé.
|
|
310
310
|
|
|
311
311
|
## 🔌 WebSocket relayé
|
|
@@ -313,10 +313,10 @@ aucun montage n'est posé.
|
|
|
313
313
|
Avec `websocket: true`, l'upgrade du préfixe est relayé au lieu d'aller au serveur WebSocket de
|
|
314
314
|
Nodefony. Un seul écouteur `upgrade` par serveur fait l'aiguillage : le proxy d'abord, puis `ws`,
|
|
315
315
|
créé en `noServer` pour garder ses propres contrôles (`path`, `verifyClient`, `maxPayload`)
|
|
316
|
-
(`attachUpgradeDispatch()`, `upgradeDispatch.ts
|
|
316
|
+
(`attachUpgradeDispatch()`, `upgradeDispatch.ts#attachUpgradeDispatch`).
|
|
317
317
|
|
|
318
318
|
Avant de relayer, chaque contrôle rend la réponse qu'aurait rendue le serveur WebSocket de Nodefony
|
|
319
|
-
(`ReverseProxy.handleUpgrade()`, `reverse-proxy.ts
|
|
319
|
+
(`ReverseProxy.handleUpgrade()`, `reverse-proxy.ts#ReverseProxy.handleUpgrade`) :
|
|
320
320
|
|
|
321
321
|
| Le handshake… | Réponse |
|
|
322
322
|
| ------------------------------------------------------------------------- | ------- |
|
|
@@ -328,7 +328,7 @@ Avant de relayer, chaque contrôle rend la réponse qu'aurait rendue le serveur
|
|
|
328
328
|
| passe tous les contrôles | relayé |
|
|
329
329
|
|
|
330
330
|
Les quotas sont ceux d'un upgrade servi par Nodefony, appliqués par la **même** règle du noyau
|
|
331
|
-
(`HttpKernel.websocketQuotaRefusal()`, `http-kernel.ts
|
|
331
|
+
(`HttpKernel.websocketQuotaRefusal()`, `http-kernel.ts#HttpKernel.websocketQuotaRefusal`) ; un tunnel ouvert garde sa place
|
|
332
332
|
jusqu'à sa fermeture. Seule la forme du refus diffère : le proxy répond avant le `101`, il peut donc
|
|
333
333
|
rendre un statut HTTP, là où le serveur WebSocket ne peut plus que fermer avec le code 1013.
|
|
334
334
|
|
|
@@ -337,9 +337,9 @@ Une fois l'upgrade transmis, un amont qui accepte la connexion puis se tait est
|
|
|
337
337
|
qu'il veut.
|
|
338
338
|
|
|
339
339
|
Seul `Upgrade: websocket` est relayé : un tunnel `h2c` ou tout autre protocole raccordé octet pour
|
|
340
|
-
octet échapperait à toute inspection (`websocketHandshakeProblem()`, `rules.ts
|
|
340
|
+
octet échapperait à toute inspection (`websocketHandshakeProblem()`, `rules.ts#websocketHandshakeProblem`). Une fois la
|
|
341
341
|
connexion établie, les deux sockets sont raccordées et détruites ensemble ; le TCP keep-alive détecte
|
|
342
|
-
un pair mort (`forwardUpgrade()`, `forward.ts
|
|
342
|
+
un pair mort (`forwardUpgrade()`, `forward.ts#forwardUpgrade`).
|
|
343
343
|
|
|
344
344
|
## 🧩 Étendre — monter depuis un module
|
|
345
345
|
|
|
@@ -359,13 +359,13 @@ proxy?.unmount("/outil/");
|
|
|
359
359
|
```
|
|
360
360
|
|
|
361
361
|
- `mount()` est **idempotent** pour un préfixe : il remplace le montage précédent. S'il remplace un
|
|
362
|
-
montage de la configuration, il le dit en `WARNING` (`ReverseProxy.mount()`, `reverse-proxy.ts
|
|
362
|
+
montage de la configuration, il le dit en `WARNING` (`ReverseProxy.mount()`, `reverse-proxy.ts#ReverseProxy.mount`).
|
|
363
363
|
- `unmount()` ferme le pool de l'amont ; le dernier retiré remet `mounts` à `null`, et le pipeline
|
|
364
|
-
cesse de le consulter (`ReverseProxy.unmount()`, `reverse-proxy.ts
|
|
364
|
+
cesse de le consulter (`ReverseProxy.unmount()`, `reverse-proxy.ts#ReverseProxy.unmount`).
|
|
365
365
|
|
|
366
366
|
**Exemple réel** : `@nodefony/frontend` monte ainsi chaque famille Vite (voir « Le cas Vite »), avec
|
|
367
367
|
une cible fonction qui ne vise un port que si le Vite de CETTE application y répond
|
|
368
|
-
(`FrontendService.mountDevProxy()`, `FrontendService.ts
|
|
368
|
+
(`FrontendService.mountDevProxy()`, `FrontendService.ts#FrontendService.mountDevProxy`).
|
|
369
369
|
|
|
370
370
|
## 🏗️ Architecture interne — le parcours d'une requête relayée
|
|
371
371
|
|
|
@@ -386,25 +386,25 @@ sequenceDiagram
|
|
|
386
386
|
|
|
387
387
|
Ce que le relais fait aux en-têtes, dans les deux sens :
|
|
388
388
|
|
|
389
|
-
| Vers l'amont (`requestHeadersFor()`, `forward.ts
|
|
390
|
-
| --------------------------------------------------------------------- |
|
|
391
|
-
| retire les en-têtes de connexion, et ceux que nomme `Connection` | retire les en-têtes de connexion, et ceux que nomme `Connection`
|
|
392
|
-
| retire `Expect` (Nodefony a déjà répondu `100`) et les `stripHeaders` | garde les `Set-Cookie` multiples
|
|
393
|
-
| réécrit `Host` sur la cible (sauf `preserveHost`) | ramène `Location` sous le préfixe (`stripPrefix`)
|
|
394
|
-
| `X-Forwarded-*` : prolongés d'un pair `trustProxy`, recommencés sinon | prolonge `Via`
|
|
395
|
-
| `X-Forwarded-Prefix` : à la suite de celui d'un pair `trustProxy` |
|
|
396
|
-
| `Forwarded` d'un pair `trustProxy` : prolongé de ce saut, jamais créé |
|
|
397
|
-
| prolonge `Via` ; recalcule le cadrage du corps |
|
|
389
|
+
| Vers l'amont (`requestHeadersFor()`, `forward.ts#requestHeadersFor`) | Vers le client (`responseHeadersFor()`, `forward.ts#responseHeadersFor`) |
|
|
390
|
+
| --------------------------------------------------------------------- | ------------------------------------------------------------------------ |
|
|
391
|
+
| retire les en-têtes de connexion, et ceux que nomme `Connection` | retire les en-têtes de connexion, et ceux que nomme `Connection` |
|
|
392
|
+
| retire `Expect` (Nodefony a déjà répondu `100`) et les `stripHeaders` | garde les `Set-Cookie` multiples |
|
|
393
|
+
| réécrit `Host` sur la cible (sauf `preserveHost`) | ramène `Location` sous le préfixe (`stripPrefix`) |
|
|
394
|
+
| `X-Forwarded-*` : prolongés d'un pair `trustProxy`, recommencés sinon | prolonge `Via` |
|
|
395
|
+
| `X-Forwarded-Prefix` : à la suite de celui d'un pair `trustProxy` | |
|
|
396
|
+
| `Forwarded` d'un pair `trustProxy` : prolongé de ce saut, jamais créé | |
|
|
397
|
+
| prolonge `Via` ; recalcule le cadrage du corps | |
|
|
398
398
|
|
|
399
|
-
Les échecs, dans l'ordre où ils peuvent survenir (`forwardRequest()`, `forward.ts
|
|
399
|
+
Les échecs, dans l'ordre où ils peuvent survenir (`forwardRequest()`, `forward.ts#forwardRequest`) :
|
|
400
400
|
|
|
401
401
|
- **508** — la requête porte déjà le pseudonyme de ce processus dans `Via`, ou au moins
|
|
402
402
|
`MAX_VIA_HOPS` (10) sauts : une boucle, l'amont n'est pas contacté (`viaLoops()`,
|
|
403
|
-
`forward.ts
|
|
403
|
+
`forward.ts#viaLoops`). Le plafond compte parce que le pseudonyme ne repère une boucle qu'au retour
|
|
404
404
|
dans ce processus — derrière un répartiteur qui tourne sur N instances, après N+1 sauts ;
|
|
405
405
|
- **501** — un `Transfer-Encoding` autre que `chunked` seul (`gzip, chunked`) : le transmettre
|
|
406
406
|
réétiqueté livrerait à l'amont un corps compressé qu'il croirait en clair (`bodyFramingOf()`,
|
|
407
|
-
`forward.ts
|
|
407
|
+
`forward.ts#bodyFramingOf`) ;
|
|
408
408
|
- **504** — connexion non établie sous `connectTimeoutMs`, ou amont muet au-delà de `timeoutMs` ;
|
|
409
409
|
- **502** — amont injoignable, ou réponse que la sortie refuse (en-tête interdit en HTTP/2) ;
|
|
410
410
|
- **coupure** — si l'amont tombe APRÈS avoir commencé à répondre, la réponse au client est
|
|
@@ -412,8 +412,8 @@ Les échecs, dans l'ordre où ils peuvent survenir (`forwardRequest()`, `forward
|
|
|
412
412
|
|
|
413
413
|
Chaque montage a son **pool keep-alive dédié**, jamais l'agent global de Node : sous
|
|
414
414
|
`NODE_USE_ENV_PROXY` (poste d'entreprise), l'agent global enverrait un amont local au proxy de
|
|
415
|
-
l'entreprise (`ReverseProxy.agentFor()`, `reverse-proxy.ts
|
|
416
|
-
noyau (`ReverseProxy.closeAll()`, `reverse-proxy.ts
|
|
415
|
+
l'entreprise (`ReverseProxy.agentFor()`, `reverse-proxy.ts#ReverseProxy.agentFor`). Les pools sont fermés à l'arrêt du
|
|
416
|
+
noyau (`ReverseProxy.closeAll()`, `reverse-proxy.ts#ReverseProxy.closeAll`) ; un relais encore en vol part ensuite sur
|
|
417
417
|
une connexion à usage unique, sans recréer de pool que personne ne fermerait.
|
|
418
418
|
|
|
419
419
|
## 🔐 Sécurité
|
|
@@ -424,9 +424,9 @@ une connexion à usage unique, sans recréer de pool que personne ne fermerait.
|
|
|
424
424
|
- **Quotas maintenus** : rate-limit par IP sur les requêtes relayées ; mêmes bornes par IP pour un
|
|
425
425
|
upgrade relayé que pour un upgrade servi, plus `maxSockets` tunnels par amont.
|
|
426
426
|
- **Barrière d'hôte maintenue** : un `Host` hors `trustedHosts` n'est jamais relayé ; il suit le
|
|
427
|
-
pipeline jusqu'au 421, comme toute requête (`HttpKernel.routeHttpRequest()`, `http-kernel.ts
|
|
427
|
+
pipeline jusqu'au 421, comme toute requête (`HttpKernel.routeHttpRequest()`, `http-kernel.ts#HttpKernel.routeHttpRequest`).
|
|
428
428
|
- **Chemins ambigus refusés**, pas normalisés : `.`, `..`, `\`, octet nul et leurs formes encodées
|
|
429
|
-
`%2e`, `%2f`, `%5c`, `%00`, `%25` (`isAmbiguousPath()`, `rules.ts
|
|
429
|
+
`%2e`, `%2f`, `%5c`, `%00`, `%25` (`isAmbiguousPath()`, `rules.ts#isAmbiguousPath`).
|
|
430
430
|
- **Chaîne de confiance** : `Forwarded`, `X-Forwarded-*` et `X-Real-IP` d'un client direct sont
|
|
431
431
|
écartés ; seul un pair `trustProxy` les prolonge.
|
|
432
432
|
- **Certificat vérifié** vers une cible `https` ; `secure: false` n'est accepté que vers
|
|
@@ -437,18 +437,18 @@ une connexion à usage unique, sans recréer de pool que personne ne fermerait.
|
|
|
437
437
|
|
|
438
438
|
## 📜 Normes appliquées
|
|
439
439
|
|
|
440
|
-
| Norme | Exigence | Comment le code s'y conforme
|
|
441
|
-
| ----------------------- | -------------------------------------------------------- |
|
|
442
|
-
| RFC 9110 §7.6.1 | Un intermédiaire retire les en-têtes de connexion | Liste fixe + jetons de `Connection`, dans les deux sens (`requestHeadersFor()`, `forward.ts
|
|
443
|
-
| RFC 9110 §7.6.3 | Un intermédiaire ajoute son entrée à `Via` | Pseudonyme par processus, prolongé à l'aller et au retour (`forward.ts
|
|
444
|
-
| RFC 9112 §6.3 | Un seul cadrage de corps ; `Transfer-Encoding` l'emporte | `Content-Length` retiré quand le corps est découpé (`bodyFramingOf()`, `forward.ts
|
|
445
|
-
| RFC 9112 §6.1 | Codage de transfert non compris → 501 | Tout autre que `chunked` seul est refusé, jamais réétiqueté (`forward.ts
|
|
446
|
-
| RFC 9113 §8.2.2 | Pas d'en-tête de connexion en HTTP/2 | Réponse refusée par la sortie h2 → 502, jamais une exception non rattrapée
|
|
447
|
-
| RFC 7239 §8.1 | Ne croire `Forwarded` que d'un relais de confiance | `trustProxy` décide (`ReverseProxy.forward()`, `reverse-proxy.ts
|
|
448
|
-
| RFC 7239 §4 · §6 | Chaque relais ajoute son élément ; IPv6 entre crochets | Élément `for=…;proto=…;host=…` ajouté, valeurs non-jeton entre guillemets
|
|
449
|
-
| RFC 6585 §4 | `429 Too Many Requests` | Quota par IP dépassé sur un upgrade relayé, avant le `101`
|
|
450
|
-
| RFC 6455 §4.2.1 · §10.2 | Handshake conforme ; contrôle d'Origin contre le CSWSH | `websocketHandshakeProblem()` (`rules.ts
|
|
451
|
-
| RFC 5842 §7.2 | `508 Loop Detected` | Emprunté à WebDAV pour une boucle de relais (`viaLoops()`, `forward.ts
|
|
440
|
+
| Norme | Exigence | Comment le code s'y conforme |
|
|
441
|
+
| ----------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
|
|
442
|
+
| RFC 9110 §7.6.1 | Un intermédiaire retire les en-têtes de connexion | Liste fixe + jetons de `Connection`, dans les deux sens (`requestHeadersFor()`, `forward.ts#requestHeadersFor`) |
|
|
443
|
+
| RFC 9110 §7.6.3 | Un intermédiaire ajoute son entrée à `Via` | Pseudonyme par processus, prolongé à l'aller et au retour (`forward.ts#requestHeadersFor`) |
|
|
444
|
+
| RFC 9112 §6.3 | Un seul cadrage de corps ; `Transfer-Encoding` l'emporte | `Content-Length` retiré quand le corps est découpé (`bodyFramingOf()`, `forward.ts#bodyFramingOf`) |
|
|
445
|
+
| RFC 9112 §6.1 | Codage de transfert non compris → 501 | Tout autre que `chunked` seul est refusé, jamais réétiqueté (`forward.ts#viaEntry`) |
|
|
446
|
+
| RFC 9113 §8.2.2 | Pas d'en-tête de connexion en HTTP/2 | Réponse refusée par la sortie h2 → 502, jamais une exception non rattrapée |
|
|
447
|
+
| RFC 7239 §8.1 | Ne croire `Forwarded` que d'un relais de confiance | `trustProxy` décide (`ReverseProxy.forward()`, `reverse-proxy.ts#ReverseProxy.forward`) |
|
|
448
|
+
| RFC 7239 §4 · §6 | Chaque relais ajoute son élément ; IPv6 entre crochets | Élément `for=…;proto=…;host=…` ajouté, valeurs non-jeton entre guillemets |
|
|
449
|
+
| RFC 6585 §4 | `429 Too Many Requests` | Quota par IP dépassé sur un upgrade relayé, avant le `101` |
|
|
450
|
+
| RFC 6455 §4.2.1 · §10.2 | Handshake conforme ; contrôle d'Origin contre le CSWSH | `websocketHandshakeProblem()` (`rules.ts#websocketHandshakeProblem`) + règle d'Origin du serveur WS |
|
|
451
|
+
| RFC 5842 §7.2 | `508 Loop Detected` | Emprunté à WebDAV pour une boucle de relais (`viaLoops()`, `forward.ts#viaLoops`) |
|
|
452
452
|
|
|
453
453
|
## ⚡ Performance & mémoire
|
|
454
454
|
|
|
@@ -472,7 +472,7 @@ en-tête `Via`.
|
|
|
472
472
|
|
|
473
473
|
| Symptôme | Cause | Correction |
|
|
474
474
|
| ---------------------------------------------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
|
|
475
|
-
| Le boot s'arrête sur `proxy.mounts` | Préfixe `/`, réservé, ou cible avec un chemin | Lire le motif : il nomme la clé et le geste (`rules.ts
|
|
475
|
+
| Le boot s'arrête sur `proxy.mounts` | Préfixe `/`, réservé, ou cible avec un chemin | Lire le motif : il nomme la clé et le geste (`rules.ts#proxyMountProblems`) |
|
|
476
476
|
| Une route de l'application sous le préfixe n'est jamais atteinte | Le proxy passe AVANT le routage | Attendu : choisir un préfixe qui n'appartient qu'à l'amont |
|
|
477
477
|
| L'amont redirige hors du montage | `stripPrefix` + liens absolus dans le HTML de l'amont | Servir l'amont sous son préfixe, ou lui faire lire `X-Forwarded-Prefix` |
|
|
478
478
|
| L'amont voit `127.0.0.1` comme client | Lecture de l'IP de la socket au lieu de `X-Forwarded-For` | Faire confiance à Nodefony côté amont (son équivalent de `trustProxy`) |
|