@nodefony/http 10.0.0-beta.1 → 10.0.0-beta.3
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 +5 -3
- package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateMetadata.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateParam.js +1 -1
- package/dist/index.js +11 -5
- package/dist/nodefony/command/certificatesCommand.js +0 -1
- package/dist/nodefony/config/config.js +48 -7
- package/dist/nodefony/config/defineModuleConfig.js +1 -1
- package/dist/nodefony/interfaces/IReverseProxy.js +1 -0
- package/dist/nodefony/service/HttpAdminApi.js +0 -1
- package/dist/nodefony/service/audit-logger.js +2 -3
- package/dist/nodefony/service/certificates.js +214 -158
- package/dist/nodefony/service/error-renderer.js +1 -1
- package/dist/nodefony/service/http-kernel.js +221 -47
- package/dist/nodefony/service/reverse-proxy.js +336 -0
- package/dist/nodefony/service/servers/server-http.js +3 -3
- package/dist/nodefony/service/servers/server-https.js +3 -3
- package/dist/nodefony/service/servers/server-static.js +4 -7
- package/dist/nodefony/service/servers/server-websocket-secure.js +12 -4
- package/dist/nodefony/service/servers/server-websocket.js +12 -4
- package/dist/nodefony/service/servers/wsHeartbeat.js +31 -15
- package/dist/nodefony/service/sessions/sessions-service.js +30 -9
- package/dist/nodefony/service/trace.js +2 -1
- package/dist/nodefony/service/upload/upload-service.js +3 -3
- package/dist/nodefony/service/x509.js +324 -0
- package/dist/nodefony/src/context/Context.js +28 -1
- package/dist/nodefony/src/context/domainMatcher.js +20 -2
- package/dist/nodefony/src/context/forwarded.js +2 -2
- package/dist/nodefony/src/context/http/HttpContext.js +91 -40
- package/dist/nodefony/src/context/http/Request.js +9 -4
- package/dist/nodefony/src/context/http/Response.js +46 -7
- package/dist/nodefony/src/context/http/SseStream.js +350 -0
- package/dist/nodefony/src/context/http/urlFastPath.js +35 -10
- package/dist/nodefony/src/context/responseEnded.js +27 -0
- package/dist/nodefony/src/context/websocket/Response.js +9 -0
- package/dist/nodefony/src/context/websocket/WebsocketContext.js +2 -3
- package/dist/nodefony/src/context/websocket/websocketUrl.js +21 -0
- package/dist/nodefony/src/profiler/Profiler.js +6 -10
- package/dist/nodefony/src/proxy/forward.js +476 -0
- package/dist/nodefony/src/proxy/rules.js +182 -0
- package/dist/nodefony/src/servers/portBinder.js +57 -2
- package/dist/nodefony/src/servers/upgradeDispatch.js +49 -0
- package/dist/nodefony/src/session/session.js +28 -12
- package/dist/types/index.d.ts +5 -0
- package/dist/types/nodefony/config/config.d.ts +17 -0
- package/dist/types/nodefony/interfaces/IContext.d.ts +2 -0
- package/dist/types/nodefony/interfaces/IErrorRenderer.d.ts +1 -1
- package/dist/types/nodefony/interfaces/IReverseProxy.d.ts +67 -0
- package/dist/types/nodefony/interfaces/IRouting.d.ts +16 -0
- package/dist/types/nodefony/interfaces/ISecurity.d.ts +6 -0
- package/dist/types/nodefony/interfaces/index.d.ts +1 -0
- package/dist/types/nodefony/service/audit-logger.d.ts +2 -1
- package/dist/types/nodefony/service/certificates.d.ts +126 -50
- package/dist/types/nodefony/service/error-renderer.d.ts +1 -1
- package/dist/types/nodefony/service/http-kernel.d.ts +111 -5
- package/dist/types/nodefony/service/reverse-proxy.d.ts +156 -0
- package/dist/types/nodefony/service/servers/server-websocket-secure.d.ts +1 -0
- package/dist/types/nodefony/service/servers/server-websocket.d.ts +1 -0
- package/dist/types/nodefony/service/servers/wsHeartbeat.d.ts +26 -0
- package/dist/types/nodefony/service/sessions/sessions-service.d.ts +19 -1
- package/dist/types/nodefony/service/x509.d.ts +86 -0
- package/dist/types/nodefony/src/context/Context.d.ts +22 -2
- package/dist/types/nodefony/src/context/domainMatcher.d.ts +11 -1
- package/dist/types/nodefony/src/context/http/HttpContext.d.ts +47 -0
- package/dist/types/nodefony/src/context/http/Response.d.ts +3 -0
- package/dist/types/nodefony/src/context/http/SseStream.d.ts +155 -0
- package/dist/types/nodefony/src/context/responseEnded.d.ts +20 -0
- package/dist/types/nodefony/src/context/websocket/websocketUrl.d.ts +16 -0
- package/dist/types/nodefony/src/profiler/Profiler.d.ts +1 -0
- package/dist/types/nodefony/src/proxy/forward.d.ts +156 -0
- package/dist/types/nodefony/src/proxy/rules.d.ts +85 -0
- package/dist/types/nodefony/src/servers/portBinder.d.ts +10 -46
- package/dist/types/nodefony/src/servers/upgradeDispatch.d.ts +39 -0
- package/dist/types/nodefony/src/session/session.d.ts +2 -0
- package/docs/cookies.md +13 -13
- package/docs/index.md +9 -3
- package/docs/observabilite.md +18 -18
- package/docs/rate-limit.md +30 -30
- package/docs/reverse-proxy.md +522 -0
- package/docs/servers.md +115 -78
- package/docs/session.md +61 -61
- package/docs/sse.md +283 -0
- package/docs/upload.md +37 -37
- package/package.json +10 -13
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import https from "node:https";
|
|
3
|
+
//#region nodefony/src/proxy/forward.ts
|
|
4
|
+
/**
|
|
5
|
+
* En-têtes de CONNEXION (RFC 9110 §7.6.1) : ils décrivent un saut, jamais le
|
|
6
|
+
* message — un intermédiaire ne les transmet pas. Les cinq premiers sont en
|
|
7
|
+
* outre INTERDITS en HTTP/2 (RFC 9113 §8.2.2) : Node lève
|
|
8
|
+
* `ERR_HTTP2_INVALID_CONNECTION_HEADERS` si une réponse les porte.
|
|
9
|
+
*/
|
|
10
|
+
const HOP_BY_HOP = /* @__PURE__ */ new Set([
|
|
11
|
+
"connection",
|
|
12
|
+
"keep-alive",
|
|
13
|
+
"proxy-connection",
|
|
14
|
+
"transfer-encoding",
|
|
15
|
+
"upgrade",
|
|
16
|
+
"te",
|
|
17
|
+
"trailer",
|
|
18
|
+
"proxy-authorization",
|
|
19
|
+
"proxy-authenticate"
|
|
20
|
+
]);
|
|
21
|
+
/**
|
|
22
|
+
* En-têtes qui décrivent la chaîne de relais. Crus d'un relais de confiance,
|
|
23
|
+
* écartés sinon : un client direct qui les pose ment sur son origine.
|
|
24
|
+
*/
|
|
25
|
+
const FORWARDING = /* @__PURE__ */ new Set([
|
|
26
|
+
"forwarded",
|
|
27
|
+
"x-forwarded-for",
|
|
28
|
+
"x-forwarded-proto",
|
|
29
|
+
"x-forwarded-host",
|
|
30
|
+
"x-forwarded-port",
|
|
31
|
+
"x-forwarded-prefix",
|
|
32
|
+
"x-forwarded-scheme",
|
|
33
|
+
"x-real-ip"
|
|
34
|
+
]);
|
|
35
|
+
/** Corps d'erreur du proxy — courts, sans détail interne. */
|
|
36
|
+
const BAD_GATEWAY = "Bad Gateway";
|
|
37
|
+
const GATEWAY_TIMEOUT = "Gateway Timeout";
|
|
38
|
+
const LOOP_DETECTED = "Loop Detected";
|
|
39
|
+
const NOT_IMPLEMENTED = "Not Implemented";
|
|
40
|
+
/**
|
|
41
|
+
* Sauts `Via` au-delà desquels un message est tenu pour une boucle. Notre
|
|
42
|
+
* pseudonyme ne repère une boucle qu'au RETOUR dans ce processus : derrière un
|
|
43
|
+
* répartiteur qui tourne sur N instances, cela peut prendre N+1 sauts, chacun
|
|
44
|
+
* tenant une connexion. Une chaîne légitime (CDN, répartiteur, ingress) en
|
|
45
|
+
* compte rarement plus de cinq.
|
|
46
|
+
*/
|
|
47
|
+
const MAX_VIA_HOPS = 10;
|
|
48
|
+
/**
|
|
49
|
+
* Noms listés par l'en-tête `Connection` d'un message : eux aussi ne valent
|
|
50
|
+
* que pour ce saut (RFC 9110 §7.6.1).
|
|
51
|
+
*
|
|
52
|
+
* @param value - valeur brute de `Connection`
|
|
53
|
+
* @returns noms en minuscules, ou `null` s'il n'y en a aucun
|
|
54
|
+
*/
|
|
55
|
+
function connectionTokens(value) {
|
|
56
|
+
if (value === void 0) return null;
|
|
57
|
+
const raw = Array.isArray(value) ? value.join(",") : value;
|
|
58
|
+
let tokens = null;
|
|
59
|
+
for (const t of raw.split(",")) {
|
|
60
|
+
const name = t.trim().toLowerCase();
|
|
61
|
+
if (name === "") continue;
|
|
62
|
+
(tokens ??= /* @__PURE__ */ new Set()).add(name);
|
|
63
|
+
}
|
|
64
|
+
return tokens;
|
|
65
|
+
}
|
|
66
|
+
/** Valeur d'en-tête aplatie (`a, b` pour un tableau). */
|
|
67
|
+
function flat(value) {
|
|
68
|
+
return Array.isArray(value) ? value.join(", ") : value;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Le message est-il déjà passé par CE processus ? Un `Via` qui porte notre
|
|
72
|
+
* pseudonyme signe une boucle (RFC 9110 §7.6 : un proxy ne se renvoie pas un
|
|
73
|
+
* message sans s'en protéger).
|
|
74
|
+
*
|
|
75
|
+
* @param via - en-tête `Via` reçu
|
|
76
|
+
* @param pseudonym - pseudonyme de ce processus
|
|
77
|
+
*/
|
|
78
|
+
function viaLoops(via, pseudonym) {
|
|
79
|
+
const value = flat(via);
|
|
80
|
+
if (value === void 0) return false;
|
|
81
|
+
const hops = value.split(",");
|
|
82
|
+
if (hops.length >= 10) return true;
|
|
83
|
+
for (const hop of hops) if (hop.trim().split(/\s+/)[1] === pseudonym) return true;
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
/** Entrée `Via` de ce saut : `<version> <pseudonyme>` (RFC 9110 §7.6.3). */
|
|
87
|
+
function viaEntry(httpVersion, pseudonym) {
|
|
88
|
+
return `${httpVersion.startsWith("2") ? "2" : httpVersion || "1.1"} ${pseudonym}`;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Cadrage du corps reçu — décidé UNE fois, pour que l'amont lise exactement
|
|
92
|
+
* les octets que le proxy lui envoie (RFC 9112 §6.3). Un `Transfer-Encoding`
|
|
93
|
+
* l'emporte sur un `Content-Length` (que l'on retire alors) ; en HTTP/2, un
|
|
94
|
+
* flux sans fin à la tête porte un corps même sans longueur annoncée.
|
|
95
|
+
*
|
|
96
|
+
* @param req - requête reçue
|
|
97
|
+
* @returns le cadrage à appliquer vers l'amont
|
|
98
|
+
*/
|
|
99
|
+
function bodyFramingOf(req) {
|
|
100
|
+
const coding = req.headers["transfer-encoding"];
|
|
101
|
+
if (coding !== void 0) {
|
|
102
|
+
if (coding === "chunked") return "chunked";
|
|
103
|
+
return coding.trim().toLowerCase() === "chunked" ? "chunked" : "unsupported";
|
|
104
|
+
}
|
|
105
|
+
if (req.headers["content-length"] !== void 0) return "length";
|
|
106
|
+
const stream = req.stream;
|
|
107
|
+
if (stream !== void 0 && !stream.endAfterHeaders) return "chunked";
|
|
108
|
+
return "none";
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* En-têtes de la requête à transmettre à l'amont.
|
|
112
|
+
*
|
|
113
|
+
* - écartés : pseudo-en-têtes HTTP/2, en-têtes de connexion et ceux que nomme
|
|
114
|
+
* `Connection`, `Expect` (le serveur a déjà répondu `100`), en-têtes retirés
|
|
115
|
+
* par le montage, chaîne de relais venue d'un pair NON fiable ;
|
|
116
|
+
* - `Host` réécrit sur la cible (sauf `preserveHost`) ;
|
|
117
|
+
* - `X-Forwarded-For` prolongé (pair fiable) ou recommencé ;
|
|
118
|
+
* `X-Forwarded-Proto`/`-Host` hérités d'un pair fiable, posés sinon ;
|
|
119
|
+
* `X-Forwarded-Prefix` quand le préfixe est retiré — à la suite de celui
|
|
120
|
+
* d'un pair fiable ; `Forwarded` d'un pair fiable prolongé de ce saut ;
|
|
121
|
+
* - `Via` prolongé de ce saut ;
|
|
122
|
+
* - cadrage du corps rendu cohérent ({@link bodyFramingOf}).
|
|
123
|
+
*
|
|
124
|
+
* @returns un objet neuf, prêt pour `http.request`
|
|
125
|
+
*/
|
|
126
|
+
function requestHeadersFor(headers, mount, target, from, pseudonym, framing) {
|
|
127
|
+
const listed = connectionTokens(headers.connection);
|
|
128
|
+
const stripped = mount.stripHeaders;
|
|
129
|
+
const out = {};
|
|
130
|
+
for (const name of Object.keys(headers)) {
|
|
131
|
+
const lower = name.toLowerCase();
|
|
132
|
+
if (lower.charCodeAt(0) === 58) continue;
|
|
133
|
+
if (HOP_BY_HOP.has(lower) || listed?.has(lower)) continue;
|
|
134
|
+
if (lower === "host" || lower === "expect" || lower === "via") continue;
|
|
135
|
+
if (lower === "content-length" && framing !== "length") continue;
|
|
136
|
+
if (FORWARDING.has(lower) && !from.trustedPeer) continue;
|
|
137
|
+
if (stripped?.includes(lower)) continue;
|
|
138
|
+
const value = headers[name];
|
|
139
|
+
if (value !== void 0) out[lower] = value;
|
|
140
|
+
}
|
|
141
|
+
out.host = mount.preserveHost === true && from.host ? from.host : target.host;
|
|
142
|
+
const prior = from.trustedPeer ? flat(headers["x-forwarded-for"]) : void 0;
|
|
143
|
+
if (from.remoteAddress) out["x-forwarded-for"] = prior ? `${prior}, ${from.remoteAddress}` : from.remoteAddress;
|
|
144
|
+
out["x-forwarded-proto"] ??= from.scheme;
|
|
145
|
+
if (from.host) out["x-forwarded-host"] ??= from.host;
|
|
146
|
+
if (mount.stripPrefix === true) {
|
|
147
|
+
let outer = from.trustedPeer ? flat(headers["x-forwarded-prefix"]) : void 0;
|
|
148
|
+
if (outer?.endsWith("/")) outer = outer.slice(0, -1);
|
|
149
|
+
out["x-forwarded-prefix"] = (outer ?? "") + mount.prefix.slice(0, -1);
|
|
150
|
+
}
|
|
151
|
+
const forwarded = from.trustedPeer ? flat(headers.forwarded) : void 0;
|
|
152
|
+
if (forwarded) out.forwarded = `${forwarded}, ${forwardedElement(from)}`;
|
|
153
|
+
const via = flat(headers.via);
|
|
154
|
+
const hop = viaEntry(from.httpVersion, pseudonym);
|
|
155
|
+
out.via = via ? `${via}, ${hop}` : hop;
|
|
156
|
+
if (framing === "chunked") out["transfer-encoding"] = "chunked";
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
/** Caractères d'un `token` (RFC 9110 §5.6.2) — compilé une fois. */
|
|
160
|
+
const TOKEN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
|
|
161
|
+
/** Valeur d'un paramètre `Forwarded` : jeton nu, sinon chaîne entre guillemets (RFC 7239 §4). */
|
|
162
|
+
function forwardedValue(value) {
|
|
163
|
+
return TOKEN.test(value) ? value : `"${value.replace(/["\\]/g, "\\$&")}"`;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Élément `Forwarded` de ce saut : `for=` (une IPv6 entre crochets et
|
|
167
|
+
* guillemets, RFC 7239 §6), `proto=`, `host=`.
|
|
168
|
+
*/
|
|
169
|
+
function forwardedElement(from) {
|
|
170
|
+
const address = from.remoteAddress;
|
|
171
|
+
let element = `for=${forwardedValue(address === void 0 ? "unknown" : address.includes(":") ? `[${address}]` : address)};proto=${forwardedValue(from.scheme)}`;
|
|
172
|
+
if (from.host) element += `;host=${forwardedValue(from.host)}`;
|
|
173
|
+
return element;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Ramène sous le préfixe un `Location` émis par un amont qui ignore son
|
|
177
|
+
* montage (`stripPrefix`) — l'équivalent de `proxy_redirect default` de
|
|
178
|
+
* nginx. Un chemin absolu (`/login`) et une URL sur l'origine de l'amont sont
|
|
179
|
+
* réécrits ; toute autre URL (un tiers) part intacte.
|
|
180
|
+
*
|
|
181
|
+
* @returns la valeur réécrite, ou celle reçue
|
|
182
|
+
*/
|
|
183
|
+
function rewriteLocation(location, mount, target) {
|
|
184
|
+
if (mount.stripPrefix !== true) return location;
|
|
185
|
+
if (location.startsWith("/") && !location.startsWith("//")) return mount.prefix + location.slice(1);
|
|
186
|
+
if (location.startsWith(target.origin + "/") || location === target.origin) return mount.prefix + location.slice(target.origin.length + 1);
|
|
187
|
+
return location;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* En-têtes de la réponse de l'amont à renvoyer au client : en-têtes de
|
|
191
|
+
* connexion écartés — y compris ceux que nomme `Connection` —, `Via`
|
|
192
|
+
* prolongé, `Location` ramené sous le préfixe (`stripPrefix`) ; le reste
|
|
193
|
+
* intact (un `set-cookie` multiple reste un tableau).
|
|
194
|
+
*
|
|
195
|
+
* @returns un objet neuf, valable en HTTP/1.1 comme en HTTP/2
|
|
196
|
+
*/
|
|
197
|
+
function responseHeadersFor(headers, mount, target, hop) {
|
|
198
|
+
const listed = connectionTokens(headers.connection);
|
|
199
|
+
const out = {};
|
|
200
|
+
for (const name of Object.keys(headers)) {
|
|
201
|
+
if (HOP_BY_HOP.has(name) || listed?.has(name)) continue;
|
|
202
|
+
const value = headers[name];
|
|
203
|
+
if (value !== void 0) out[name] = value;
|
|
204
|
+
}
|
|
205
|
+
const via = flat(headers.via);
|
|
206
|
+
out.via = via ? `${via}, ${hop}` : hop;
|
|
207
|
+
if (typeof out.location === "string") out.location = rewriteLocation(out.location, mount, target);
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Chemin transmis à l'amont : tel quel, ou sans le préfixe (`stripPrefix`).
|
|
212
|
+
*
|
|
213
|
+
* @param url - cible de la requête (chemin + requête), qui commence par le préfixe
|
|
214
|
+
* @param mount - montage qui relaie
|
|
215
|
+
* @returns le chemin, toujours avec un `/` en tête
|
|
216
|
+
*/
|
|
217
|
+
function upstreamPath(url, mount) {
|
|
218
|
+
if (mount.stripPrefix !== true) return url;
|
|
219
|
+
return `/${url.slice(mount.prefix.length)}`;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Options de `http.request` communes aux deux relais : cible, pool, TLS.
|
|
223
|
+
* La vérification du certificat suit `secure` (refusé hors boucle locale
|
|
224
|
+
* au montage).
|
|
225
|
+
*/
|
|
226
|
+
function requestOptions(target, mount, relay) {
|
|
227
|
+
const options = {
|
|
228
|
+
protocol: target.protocol,
|
|
229
|
+
hostname: target.hostname,
|
|
230
|
+
port: target.port
|
|
231
|
+
};
|
|
232
|
+
if (relay.agent !== void 0) options.agent = relay.agent;
|
|
233
|
+
if (target.protocol === "https:") options.rejectUnauthorized = mount.secure !== false;
|
|
234
|
+
return options;
|
|
235
|
+
}
|
|
236
|
+
/** Module client selon le scheme de la cible. */
|
|
237
|
+
function requestFn(target) {
|
|
238
|
+
return target.protocol === "https:" ? https.request : http.request;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Arme le délai d'ÉTABLISSEMENT de la connexion amont — distinct du délai
|
|
242
|
+
* d'inactivité : un amont qui n'accepte pas la connexion ne doit pas tenir le
|
|
243
|
+
* client pendant tout `timeoutMs`. Un socket réutilisé (pool) est déjà
|
|
244
|
+
* connecté : rien à armer.
|
|
245
|
+
*
|
|
246
|
+
* @param upstream - requête vers l'amont
|
|
247
|
+
* @param ms - délai
|
|
248
|
+
* @param onTimeout - appelé si la connexion n'est pas établie à temps
|
|
249
|
+
*/
|
|
250
|
+
function armConnectTimeout(upstream, ms, onTimeout) {
|
|
251
|
+
upstream.once("socket", (socket) => {
|
|
252
|
+
if (!socket.connecting) return;
|
|
253
|
+
const timer = setTimeout(onTimeout, ms);
|
|
254
|
+
const clear = () => {
|
|
255
|
+
clearTimeout(timer);
|
|
256
|
+
socket.removeListener("connect", clear);
|
|
257
|
+
socket.removeListener("close", clear);
|
|
258
|
+
};
|
|
259
|
+
socket.once("connect", clear);
|
|
260
|
+
socket.once("close", clear);
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Relaie une requête HTTP vers l'amont et renvoie sa réponse au client.
|
|
265
|
+
*
|
|
266
|
+
* Le corps est transmis avec un cadrage cohérent ({@link bodyFramingOf}). Un
|
|
267
|
+
* message qui porte déjà notre `Via` rend 508 sans contacter l'amont. Un
|
|
268
|
+
* client parti coupe l'échange amont ; un amont injoignable rend 502 ; une
|
|
269
|
+
* connexion non établie à temps ou un amont muet rendent 504 — à condition
|
|
270
|
+
* que rien n'ait encore été envoyé, sinon la réponse est interrompue (un
|
|
271
|
+
* statut ne se rattrape pas). Un amont coupé en plein corps interrompt la
|
|
272
|
+
* réponse au client au lieu de la laisser pendre.
|
|
273
|
+
*
|
|
274
|
+
* @returns une promesse résolue à la fermeture de la réponse — jamais rejetée
|
|
275
|
+
*/
|
|
276
|
+
function forwardRequest(req, res, mount, target, from, relay) {
|
|
277
|
+
return new Promise((resolve) => {
|
|
278
|
+
const out = res;
|
|
279
|
+
let closed = false;
|
|
280
|
+
let failed = false;
|
|
281
|
+
const fail = (status, body) => {
|
|
282
|
+
if (failed || closed) return;
|
|
283
|
+
failed = true;
|
|
284
|
+
if (out.headersSent) {
|
|
285
|
+
out.destroy();
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
out.writeHead(status, {
|
|
289
|
+
"content-type": "text/plain; charset=utf-8",
|
|
290
|
+
"content-length": Buffer.byteLength(body),
|
|
291
|
+
"cache-control": "no-store"
|
|
292
|
+
});
|
|
293
|
+
out.end(body);
|
|
294
|
+
};
|
|
295
|
+
let upstream = null;
|
|
296
|
+
out.once("close", () => {
|
|
297
|
+
closed = true;
|
|
298
|
+
if (!out.writableFinished) upstream?.destroy();
|
|
299
|
+
resolve();
|
|
300
|
+
});
|
|
301
|
+
if (viaLoops(req.headers.via, relay.pseudonym)) {
|
|
302
|
+
fail(508, LOOP_DETECTED);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const framing = bodyFramingOf(req);
|
|
306
|
+
if (framing === "unsupported") {
|
|
307
|
+
fail(501, NOT_IMPLEMENTED);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
const options = requestOptions(target, mount, relay);
|
|
311
|
+
options.method = req.method ?? "GET";
|
|
312
|
+
options.path = upstreamPath(req.url ?? "/", mount);
|
|
313
|
+
options.headers = requestHeadersFor(req.headers, mount, target, from, relay.pseudonym, framing);
|
|
314
|
+
if (mount.timeoutMs !== void 0) options.timeout = mount.timeoutMs;
|
|
315
|
+
const request = requestFn(target)(options);
|
|
316
|
+
upstream = request;
|
|
317
|
+
let timedOut = false;
|
|
318
|
+
const giveUp = () => {
|
|
319
|
+
timedOut = true;
|
|
320
|
+
fail(504, GATEWAY_TIMEOUT);
|
|
321
|
+
request.destroy();
|
|
322
|
+
};
|
|
323
|
+
armConnectTimeout(request, relay.connectTimeoutMs, giveUp);
|
|
324
|
+
request.on("timeout", giveUp);
|
|
325
|
+
request.on("error", () => {
|
|
326
|
+
if (!timedOut) fail(502, BAD_GATEWAY);
|
|
327
|
+
});
|
|
328
|
+
const hop = viaEntry(from.httpVersion, relay.pseudonym);
|
|
329
|
+
request.on("response", (answer) => {
|
|
330
|
+
if (closed || failed) {
|
|
331
|
+
answer.resume();
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
try {
|
|
335
|
+
out.writeHead(answer.statusCode ?? 502, responseHeadersFor(answer.headers, mount, target, hop));
|
|
336
|
+
} catch {
|
|
337
|
+
answer.resume();
|
|
338
|
+
fail(502, BAD_GATEWAY);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
answer.once("close", () => {
|
|
342
|
+
if (!answer.complete) out.destroy();
|
|
343
|
+
});
|
|
344
|
+
answer.pipe(out);
|
|
345
|
+
});
|
|
346
|
+
if (framing === "none") {
|
|
347
|
+
request.end();
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
const body = req;
|
|
351
|
+
const onClientError = () => {
|
|
352
|
+
request.destroy();
|
|
353
|
+
};
|
|
354
|
+
body.once("error", onClientError);
|
|
355
|
+
request.once("close", () => {
|
|
356
|
+
body.removeListener("error", onClientError);
|
|
357
|
+
body.unpipe(request);
|
|
358
|
+
if (!body.readableEnded) body.resume();
|
|
359
|
+
});
|
|
360
|
+
body.pipe(request);
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Sérialise une tête de réponse HTTP/1.1 depuis des en-têtes BRUTS (paires
|
|
365
|
+
* nom/valeur), sans réordonner ni fusionner, en écartant les noms donnés —
|
|
366
|
+
* une réponse `101` doit rendre au client exactement ce que l'amont a négocié.
|
|
367
|
+
*
|
|
368
|
+
* @param status - ligne de statut, sans CRLF
|
|
369
|
+
* @param rawHeaders - `IncomingMessage.rawHeaders`
|
|
370
|
+
* @param skip - noms à écarter (minuscules)
|
|
371
|
+
* @param extra - lignes ajoutées à la fin (`Nom: valeur`)
|
|
372
|
+
* @returns la tête complète, CRLF final compris
|
|
373
|
+
*/
|
|
374
|
+
function rawHead(status, rawHeaders, skip = null, extra = []) {
|
|
375
|
+
let head = `${status}\r\n`;
|
|
376
|
+
for (let i = 0; i + 1 < rawHeaders.length; i += 2) {
|
|
377
|
+
const name = rawHeaders[i] ?? "";
|
|
378
|
+
if (skip?.has(name.toLowerCase())) continue;
|
|
379
|
+
head += `${name}: ${rawHeaders[i + 1]}\r\n`;
|
|
380
|
+
}
|
|
381
|
+
for (const line of extra) head += `${line}\r\n`;
|
|
382
|
+
return `${head}\r\n`;
|
|
383
|
+
}
|
|
384
|
+
/** Réponse brute minimale sur un socket d'upgrade, puis fermeture. */
|
|
385
|
+
function refuseUpgrade(socket, status, reason) {
|
|
386
|
+
if (!socket.writable) {
|
|
387
|
+
socket.destroy();
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
socket.end(`HTTP/1.1 ${status} ${reason}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
|
|
391
|
+
}
|
|
392
|
+
/** En-têtes d'une réponse d'amont REFUSANT l'upgrade, à ne pas recopier tels quels. */
|
|
393
|
+
const REFUSAL_SKIP = /* @__PURE__ */ new Set([
|
|
394
|
+
"transfer-encoding",
|
|
395
|
+
"content-length",
|
|
396
|
+
"connection",
|
|
397
|
+
"keep-alive"
|
|
398
|
+
]);
|
|
399
|
+
/**
|
|
400
|
+
* Relaie un upgrade WebSocket DÉJÀ validé (méthode, `Upgrade`, clé, version,
|
|
401
|
+
* `Origin` — cf `ReverseProxy.handleUpgrade`) : rejoue le handshake vers
|
|
402
|
+
* l'amont, renvoie sa réponse `101` au client puis raccorde les deux sockets
|
|
403
|
+
* octet pour octet. Un refus de l'amont (`400`, `403`…) est rendu avec un
|
|
404
|
+
* cadrage refait (fin de corps = fermeture) ; un amont injoignable rend `502`,
|
|
405
|
+
* une connexion non établie à temps — ou un amont qui accepte puis se tait
|
|
406
|
+
* au-delà de `timeoutMs` — `504`, une boucle `508`. Le délai d'inactivité
|
|
407
|
+
* cesse au `101` : un tunnel établi peut rester muet. Une erreur d'un côté
|
|
408
|
+
* détruit l'autre.
|
|
409
|
+
*/
|
|
410
|
+
function forwardUpgrade(req, socket, head, mount, target, from, relay) {
|
|
411
|
+
if (viaLoops(req.headers.via, relay.pseudonym)) {
|
|
412
|
+
refuseUpgrade(socket, 508, LOOP_DETECTED);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
const options = requestOptions(target, mount, relay);
|
|
416
|
+
options.agent = false;
|
|
417
|
+
options.method = "GET";
|
|
418
|
+
options.path = upstreamPath(req.url ?? "/", mount);
|
|
419
|
+
const headers = requestHeadersFor(req.headers, mount, target, from, relay.pseudonym, "none");
|
|
420
|
+
headers.connection = "Upgrade";
|
|
421
|
+
headers.upgrade = "websocket";
|
|
422
|
+
options.headers = headers;
|
|
423
|
+
if (mount.timeoutMs !== void 0) options.timeout = mount.timeoutMs;
|
|
424
|
+
if (head.length > 0) socket.unshift(head);
|
|
425
|
+
const upstream = requestFn(target)(options);
|
|
426
|
+
let state = "pending";
|
|
427
|
+
const onClientError = () => {
|
|
428
|
+
upstream.destroy();
|
|
429
|
+
};
|
|
430
|
+
socket.once("error", onClientError);
|
|
431
|
+
armConnectTimeout(upstream, relay.connectTimeoutMs, () => {
|
|
432
|
+
state = "refused";
|
|
433
|
+
refuseUpgrade(socket, 504, GATEWAY_TIMEOUT);
|
|
434
|
+
upstream.destroy();
|
|
435
|
+
});
|
|
436
|
+
upstream.on("timeout", () => {
|
|
437
|
+
if (state === "open") return;
|
|
438
|
+
if (state === "pending") refuseUpgrade(socket, 504, GATEWAY_TIMEOUT);
|
|
439
|
+
else socket.destroy();
|
|
440
|
+
state = "refused";
|
|
441
|
+
upstream.destroy();
|
|
442
|
+
});
|
|
443
|
+
upstream.on("upgrade", (answer, upSocket, upHead) => {
|
|
444
|
+
state = "open";
|
|
445
|
+
upSocket.setTimeout(0);
|
|
446
|
+
socket.removeListener("error", onClientError);
|
|
447
|
+
const destroyBoth = () => {
|
|
448
|
+
socket.destroy();
|
|
449
|
+
upSocket.destroy();
|
|
450
|
+
};
|
|
451
|
+
socket.on("error", destroyBoth);
|
|
452
|
+
upSocket.on("error", destroyBoth);
|
|
453
|
+
upSocket.setKeepAlive(true);
|
|
454
|
+
socket.setKeepAlive(true);
|
|
455
|
+
if (upHead.length > 0) upSocket.unshift(upHead);
|
|
456
|
+
socket.write(rawHead(`HTTP/1.1 101 ${answer.statusMessage ?? "Switching Protocols"}`, answer.rawHeaders));
|
|
457
|
+
upSocket.pipe(socket).pipe(upSocket);
|
|
458
|
+
});
|
|
459
|
+
upstream.on("response", (answer) => {
|
|
460
|
+
state = "refused";
|
|
461
|
+
socket.write(rawHead(`HTTP/1.1 ${answer.statusCode ?? 502} ${answer.statusMessage ?? ""}`, answer.rawHeaders, REFUSAL_SKIP, ["Connection: close"]));
|
|
462
|
+
answer.pipe(socket);
|
|
463
|
+
});
|
|
464
|
+
upstream.on("error", () => {
|
|
465
|
+
if (state === "refused") return;
|
|
466
|
+
if (state === "open") {
|
|
467
|
+
socket.destroy();
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
state = "refused";
|
|
471
|
+
refuseUpgrade(socket, 502, BAD_GATEWAY);
|
|
472
|
+
});
|
|
473
|
+
upstream.end();
|
|
474
|
+
}
|
|
475
|
+
//#endregion
|
|
476
|
+
export { MAX_VIA_HOPS, bodyFramingOf, forwardRequest, forwardUpgrade, rawHead, refuseUpgrade, requestHeadersFor, responseHeadersFor, rewriteLocation, upstreamPath, viaLoops };
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { isLoopbackHostname } from "nodefony";
|
|
2
|
+
//#region nodefony/src/proxy/rules.ts
|
|
3
|
+
/**
|
|
4
|
+
* Règles d'un montage du proxy inverse — UNIQUE source, lue par le schéma de
|
|
5
|
+
* configuration (`proxy.mounts`) ET par `ReverseProxy.mount()` : un montage
|
|
6
|
+
* écrit dans la config et un montage posé par un module obéissent aux mêmes
|
|
7
|
+
* refus, avec les mêmes mots.
|
|
8
|
+
*
|
|
9
|
+
* Pur : n'importe que le cœur, importable depuis `config/config.ts` sans
|
|
10
|
+
* cycle.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Préfixes que le framework se réserve : un montage n'y est JAMAIS accepté.
|
|
14
|
+
* `/nodefony/` porte la console d'administration et les API de Studio — les
|
|
15
|
+
* relayer ailleurs détournerait une surface protégée par le pare-feu.
|
|
16
|
+
*/
|
|
17
|
+
const RESERVED_PROXY_PREFIXES = ["/nodefony/"];
|
|
18
|
+
/**
|
|
19
|
+
* Préfixes réservés aux montages POSÉS PAR UN MODULE : la configuration ne
|
|
20
|
+
* peut pas les prendre. `/_vite/` appartient à `@nodefony/frontend`, qui y
|
|
21
|
+
* relaie ses serveurs Vite de développement.
|
|
22
|
+
*/
|
|
23
|
+
const MODULE_PROXY_PREFIXES = ["/_vite/"];
|
|
24
|
+
/** Jeton de méthode HTTP (RFC 9110 §9.1 → `token`, §5.6.2). */
|
|
25
|
+
const METHOD_TOKEN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
|
|
26
|
+
/** Nom de champ d'en-tête (RFC 9110 §5.1 → `token`). */
|
|
27
|
+
const HEADER_TOKEN = METHOD_TOKEN;
|
|
28
|
+
/**
|
|
29
|
+
* Normalise un préfixe d'URL : `/` en tête et en fin, sans `/` doublé. Règle
|
|
30
|
+
* UNIQUE des montages statiques et du proxy inverse — deux écritures du même
|
|
31
|
+
* préfixe doivent désigner la même entrée.
|
|
32
|
+
*
|
|
33
|
+
* @param prefix - préfixe tel qu'écrit (`"svc"`, `"/svc"`, `"/svc/"`)
|
|
34
|
+
* @returns le préfixe normalisé (`"/svc/"`)
|
|
35
|
+
*/
|
|
36
|
+
function normalizePrefix(prefix) {
|
|
37
|
+
let p = prefix.trim();
|
|
38
|
+
if (!p.startsWith("/")) p = `/${p}`;
|
|
39
|
+
if (!p.endsWith("/")) p = `${p}/`;
|
|
40
|
+
return p.replace(/\/{2,}/g, "/");
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Analyse une origine d'amont. Seule une origine NUE est acceptée : un chemin,
|
|
44
|
+
* une requête, un fragment ou des identifiants dans la cible déplaceraient en
|
|
45
|
+
* silence toutes les URL relayées, ou feraient voyager un secret dans la
|
|
46
|
+
* configuration.
|
|
47
|
+
*
|
|
48
|
+
* @param origin - `scheme://hôte[:port]`
|
|
49
|
+
* @returns l'URL analysée, ou `null` si ce n'est pas une origine http(s) nue
|
|
50
|
+
*/
|
|
51
|
+
function parseProxyOrigin(origin) {
|
|
52
|
+
let url;
|
|
53
|
+
try {
|
|
54
|
+
url = new URL(origin);
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
|
59
|
+
if (!/^https?:\/\/[^/?#]+\/?$/i.test(origin.trim())) return null;
|
|
60
|
+
if (url.search !== "" || url.hash !== "") return null;
|
|
61
|
+
if (url.username !== "" || url.password !== "") return null;
|
|
62
|
+
return url;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Examine un montage. Rend TOUS les refus, pas le premier : une configuration
|
|
66
|
+
* se corrige en une passe.
|
|
67
|
+
*
|
|
68
|
+
* @param prefix - préfixe tel qu'écrit
|
|
69
|
+
* @param mount - réglages du montage
|
|
70
|
+
* @param source - `"config"` (déclaré dans `proxy.mounts`) ou `"module"`
|
|
71
|
+
* (posé par un module) — seuls les préfixes réservés aux modules diffèrent
|
|
72
|
+
* @returns la liste des refus, vide si le montage est sain
|
|
73
|
+
*/
|
|
74
|
+
function proxyMountProblems(prefix, mount, source) {
|
|
75
|
+
const problems = [];
|
|
76
|
+
const raw = prefix.trim();
|
|
77
|
+
const p = normalizePrefix(raw);
|
|
78
|
+
if (p === "/") problems.push({
|
|
79
|
+
field: "prefix",
|
|
80
|
+
message: "le préfixe « / » relaierait TOUTE l'application, routes et pare-feu compris — monter un préfixe dédié (`/svc/`)"
|
|
81
|
+
});
|
|
82
|
+
else if (/[?#\s\\%]/.test(raw) || p.split("/").some((s) => s === "." || s === "..")) problems.push({
|
|
83
|
+
field: "prefix",
|
|
84
|
+
message: `préfixe « ${raw} » invalide — un chemin littéral, sans « ? », « # », « % », « \\ », espace ni segment « . » / « .. »`
|
|
85
|
+
});
|
|
86
|
+
else if (RESERVED_PROXY_PREFIXES.some((r) => p.startsWith(r))) problems.push({
|
|
87
|
+
field: "prefix",
|
|
88
|
+
message: `le préfixe « ${p} » est réservé au framework (console d'administration)`
|
|
89
|
+
});
|
|
90
|
+
else if (source === "config" && MODULE_PROXY_PREFIXES.some((r) => p.startsWith(r))) problems.push({
|
|
91
|
+
field: "prefix",
|
|
92
|
+
message: `le préfixe « ${p} » est réservé à @nodefony/frontend (serveurs Vite de développement), qui le monte lui-même`
|
|
93
|
+
});
|
|
94
|
+
let target = null;
|
|
95
|
+
if (typeof mount.target === "string") {
|
|
96
|
+
target = parseProxyOrigin(mount.target);
|
|
97
|
+
if (target === null) problems.push({
|
|
98
|
+
field: "target",
|
|
99
|
+
message: `cible « ${mount.target} » invalide — attendu une origine http(s) nue (\`scheme://hôte[:port]\`), sans chemin, requête ni identifiants`
|
|
100
|
+
});
|
|
101
|
+
} else if (typeof mount.target !== "function") problems.push({
|
|
102
|
+
field: "target",
|
|
103
|
+
message: "cible absente — une origine `scheme://hôte[:port]`"
|
|
104
|
+
});
|
|
105
|
+
if (mount.secure === false && target !== null) {
|
|
106
|
+
if (target.protocol !== "https:") problems.push({
|
|
107
|
+
field: "secure",
|
|
108
|
+
message: "`secure: false` ne concerne qu'une cible https: — le retirer"
|
|
109
|
+
});
|
|
110
|
+
else if (!isLoopbackHostname(target.hostname)) problems.push({
|
|
111
|
+
field: "secure",
|
|
112
|
+
message: `\`secure: false\` refusé vers « ${target.hostname} » : sans vérification de certificat, n'importe qui sur le chemin réseau se fait passer pour l'amont. Réservé à la boucle locale — sinon, fournir l'autorité de certification de l'amont au processus (\`http.certificates.extraCaFiles\`, ou NODE_EXTRA_CA_CERTS)`
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
if (mount.methods !== void 0) {
|
|
116
|
+
if (mount.methods.length === 0) problems.push({
|
|
117
|
+
field: "methods",
|
|
118
|
+
message: "liste de méthodes vide : rien ne serait relayé — retirer `methods` pour toutes"
|
|
119
|
+
});
|
|
120
|
+
for (const m of mount.methods) if (!METHOD_TOKEN.test(m)) problems.push({
|
|
121
|
+
field: "methods",
|
|
122
|
+
message: `méthode « ${m} » invalide (RFC 9110 §9.1)`
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
for (const h of mount.stripHeaders ?? []) if (!HEADER_TOKEN.test(h)) problems.push({
|
|
126
|
+
field: "stripHeaders",
|
|
127
|
+
message: `nom d'en-tête « ${h} » invalide`
|
|
128
|
+
});
|
|
129
|
+
else if (h.toLowerCase() === "host") problems.push({
|
|
130
|
+
field: "stripHeaders",
|
|
131
|
+
message: "« host » ne se retire pas : il est toujours posé — voir `preserveHost`"
|
|
132
|
+
});
|
|
133
|
+
if (mount.timeoutMs !== void 0 && (!Number.isInteger(mount.timeoutMs) || mount.timeoutMs <= 0)) problems.push({
|
|
134
|
+
field: "timeoutMs",
|
|
135
|
+
message: `délai « ${mount.timeoutMs} » invalide — un entier de millisecondes > 0`
|
|
136
|
+
});
|
|
137
|
+
return problems;
|
|
138
|
+
}
|
|
139
|
+
/** Encodages qui changent le SENS d'un chemin selon qui le décode. */
|
|
140
|
+
const AMBIGUOUS_ENCODING = /%(?:2e|2f|5c|00|25)/i;
|
|
141
|
+
/**
|
|
142
|
+
* Le chemin d'une requête relayée est-il ambigu ? Un chemin que le proxy et
|
|
143
|
+
* l'amont pourraient lire différemment — segment `.`/`..`, barre inverse,
|
|
144
|
+
* octet nul, ou leur forme encodée (`%2e`, `%2f`, `%5c`, `%00`, `%25`) — est
|
|
145
|
+
* REFUSÉ (400), jamais normalisé en silence : c'est le défaut de Traefik, et
|
|
146
|
+
* la parade aux contournements de préfixe (`/svc/..%2fadmin`).
|
|
147
|
+
*
|
|
148
|
+
* @param url - cible brute de la requête (chemin + requête éventuelle)
|
|
149
|
+
* @returns `true` si le chemin doit être refusé
|
|
150
|
+
*/
|
|
151
|
+
function isAmbiguousPath(url) {
|
|
152
|
+
const q = url.indexOf("?");
|
|
153
|
+
const path = q === -1 ? url : url.slice(0, q);
|
|
154
|
+
if (path.includes("\\") || path.includes("\0")) return true;
|
|
155
|
+
if (AMBIGUOUS_ENCODING.test(path)) return true;
|
|
156
|
+
for (const segment of path.split("/")) if (segment === "." || segment === "..") return true;
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
/** Clé de handshake : 16 octets en base64 (RFC 6455 §4.1, §4.2.1). */
|
|
160
|
+
const WS_KEY = /^[A-Za-z0-9+/]{22}==$/;
|
|
161
|
+
/**
|
|
162
|
+
* Un upgrade est-il un handshake WebSocket conforme (RFC 6455 §4.2.1) ? Seul
|
|
163
|
+
* un tel handshake est relayé : un `Upgrade` quelconque (`h2c`…) raccordé
|
|
164
|
+
* octet pour octet ouvrirait un tunnel que rien n'inspecte.
|
|
165
|
+
*
|
|
166
|
+
* @param method - méthode de la requête
|
|
167
|
+
* @param headers - en-têtes reçus
|
|
168
|
+
* @returns la raison du refus, ou `null` si le handshake est conforme
|
|
169
|
+
*/
|
|
170
|
+
function websocketHandshakeProblem(method, headers) {
|
|
171
|
+
if (method !== "GET") return "méthode autre que GET";
|
|
172
|
+
const upgrade = headers.upgrade;
|
|
173
|
+
if (typeof upgrade !== "string" || upgrade.trim().toLowerCase() !== "websocket") return "Upgrade autre que websocket";
|
|
174
|
+
const connection = headers.connection;
|
|
175
|
+
if (!(Array.isArray(connection) ? connection.join(",") : connection ?? "").toLowerCase().split(",").map((t) => t.trim()).includes("upgrade")) return "Connection sans upgrade";
|
|
176
|
+
if (headers["sec-websocket-version"] !== "13") return "Sec-WebSocket-Version ≠ 13";
|
|
177
|
+
const key = headers["sec-websocket-key"];
|
|
178
|
+
if (typeof key !== "string" || !WS_KEY.test(key.trim())) return "Sec-WebSocket-Key invalide";
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
//#endregion
|
|
182
|
+
export { MODULE_PROXY_PREFIXES, RESERVED_PROXY_PREFIXES, isAmbiguousPath, normalizePrefix, parseProxyOrigin, proxyMountProblems, websocketHandshakeProblem };
|