@nodefony/http 10.0.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +544 -0
- package/README.md +77 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateParam.js +8 -0
- package/dist/index.js +108 -0
- package/dist/nodefony/command/assetsPublishCommand.js +102 -0
- package/dist/nodefony/command/certificatesCommand.js +47 -0
- package/dist/nodefony/command/networkCommand.js +27 -0
- package/dist/nodefony/command/proxyGenerateCommand.js +66 -0
- package/dist/nodefony/config/config.js +335 -0
- package/dist/nodefony/config/defineModuleConfig.js +93 -0
- package/dist/nodefony/interfaces/IContext.js +1 -0
- package/dist/nodefony/interfaces/ICookie.js +1 -0
- package/dist/nodefony/interfaces/IErrorRenderer.js +1 -0
- package/dist/nodefony/interfaces/IHttpConfig.js +1 -0
- package/dist/nodefony/interfaces/IHttpKernel.js +1 -0
- package/dist/nodefony/interfaces/IRequest.js +1 -0
- package/dist/nodefony/interfaces/IRequestLogger.js +1 -0
- package/dist/nodefony/interfaces/IResponse.js +1 -0
- package/dist/nodefony/interfaces/ISession.js +1 -0
- package/dist/nodefony/interfaces/IUpload.js +1 -0
- package/dist/nodefony/interfaces/index.js +1 -0
- package/dist/nodefony/service/HttpAdminApi.js +376 -0
- package/dist/nodefony/service/ProfilerAdminApi.js +73 -0
- package/dist/nodefony/service/audit-logger.js +159 -0
- package/dist/nodefony/service/certificates.js +545 -0
- package/dist/nodefony/service/error-renderer.js +320 -0
- package/dist/nodefony/service/http-kernel.js +948 -0
- package/dist/nodefony/service/pretty-request-logger.js +72 -0
- package/dist/nodefony/service/request-logger.js +54 -0
- package/dist/nodefony/service/servers/clientError.js +20 -0
- package/dist/nodefony/service/servers/server-http.js +135 -0
- package/dist/nodefony/service/servers/server-https.js +204 -0
- package/dist/nodefony/service/servers/server-static.js +192 -0
- package/dist/nodefony/service/servers/server-websocket-secure.js +104 -0
- package/dist/nodefony/service/servers/server-websocket.js +104 -0
- package/dist/nodefony/service/servers/serverShutdown.js +31 -0
- package/dist/nodefony/service/servers/wsHeartbeat.js +64 -0
- package/dist/nodefony/service/sessions/sessions-service.js +580 -0
- package/dist/nodefony/service/trace.js +72 -0
- package/dist/nodefony/service/upload/upload-service.js +171 -0
- package/dist/nodefony/src/assets/collectAssets.js +34 -0
- package/dist/nodefony/src/assets/prebuiltUi.js +125 -0
- package/dist/nodefony/src/context/Context.js +415 -0
- package/dist/nodefony/src/context/domainMatcher.js +88 -0
- package/dist/nodefony/src/context/forwarded.js +185 -0
- package/dist/nodefony/src/context/http/HttpContext.js +309 -0
- package/dist/nodefony/src/context/http/Request.js +543 -0
- package/dist/nodefony/src/context/http/Response.js +368 -0
- package/dist/nodefony/src/context/http/parser.js +188 -0
- package/dist/nodefony/src/context/http/urlFastPath.js +103 -0
- package/dist/nodefony/src/context/http2/Request.js +29 -0
- package/dist/nodefony/src/context/http2/Response.js +97 -0
- package/dist/nodefony/src/context/metaData.js +47 -0
- package/dist/nodefony/src/context/requestId.js +41 -0
- package/dist/nodefony/src/context/trustProxy.js +167 -0
- package/dist/nodefony/src/context/websocket/Response.js +181 -0
- package/dist/nodefony/src/context/websocket/WebsocketContext.js +389 -0
- package/dist/nodefony/src/context/websocket/wsBackpressure.js +56 -0
- package/dist/nodefony/src/context/websocket/wsLogContent.js +68 -0
- package/dist/nodefony/src/cookies/cookie.js +258 -0
- package/dist/nodefony/src/errors/httpError.js +69 -0
- package/dist/nodefony/src/profiler/FrameProfile.js +95 -0
- package/dist/nodefony/src/profiler/Profiler.js +139 -0
- package/dist/nodefony/src/proxy/generateProxyConfig.js +157 -0
- package/dist/nodefony/src/rateLimit/IRateLimitStore.js +1 -0
- package/dist/nodefony/src/rateLimit/MemoryRateLimitStore.js +146 -0
- package/dist/nodefony/src/rateLimit/WsConnectionCounter.js +64 -0
- package/dist/nodefony/src/rateLimit/rateLimitFilters.js +20 -0
- package/dist/nodefony/src/servers/portBinder.js +114 -0
- package/dist/nodefony/src/session/session.js +390 -0
- package/dist/nodefony/src/session/storage/MemorySessionStorage.js +185 -0
- package/dist/nodefony/src/session/storage/RevocationGuardStorage.js +137 -0
- package/dist/nodefony/src/session/storage/sessionFilters.js +83 -0
- package/dist/nodefony/src/session/storage/sessionSort.js +53 -0
- package/dist/types/index.d.ts +83 -0
- package/dist/types/nodefony/command/assetsPublishCommand.d.ts +23 -0
- package/dist/types/nodefony/command/certificatesCommand.d.ts +17 -0
- package/dist/types/nodefony/command/networkCommand.d.ts +8 -0
- package/dist/types/nodefony/command/proxyGenerateCommand.d.ts +19 -0
- package/dist/types/nodefony/config/config.d.ts +197 -0
- package/dist/types/nodefony/config/defineModuleConfig.d.ts +39 -0
- package/dist/types/nodefony/interfaces/IContext.d.ts +138 -0
- package/dist/types/nodefony/interfaces/ICookie.d.ts +47 -0
- package/dist/types/nodefony/interfaces/IErrorRenderer.d.ts +55 -0
- package/dist/types/nodefony/interfaces/IHttpConfig.d.ts +12 -0
- package/dist/types/nodefony/interfaces/IHttpKernel.d.ts +10 -0
- package/dist/types/nodefony/interfaces/IRequest.d.ts +35 -0
- package/dist/types/nodefony/interfaces/IRequestLogger.d.ts +31 -0
- package/dist/types/nodefony/interfaces/IResponse.d.ts +39 -0
- package/dist/types/nodefony/interfaces/ISession.d.ts +283 -0
- package/dist/types/nodefony/interfaces/IUpload.d.ts +66 -0
- package/dist/types/nodefony/interfaces/index.d.ts +7 -0
- package/dist/types/nodefony/service/HttpAdminApi.d.ts +18 -0
- package/dist/types/nodefony/service/ProfilerAdminApi.d.ts +23 -0
- package/dist/types/nodefony/service/audit-logger.d.ts +143 -0
- package/dist/types/nodefony/service/certificates.d.ts +246 -0
- package/dist/types/nodefony/service/error-renderer.d.ts +74 -0
- package/dist/types/nodefony/service/http-kernel.d.ts +377 -0
- package/dist/types/nodefony/service/pretty-request-logger.d.ts +25 -0
- package/dist/types/nodefony/service/request-logger.d.ts +18 -0
- package/dist/types/nodefony/service/servers/clientError.d.ts +14 -0
- package/dist/types/nodefony/service/servers/server-http.d.ts +42 -0
- package/dist/types/nodefony/service/servers/server-https.d.ts +41 -0
- package/dist/types/nodefony/service/servers/server-static.d.ts +62 -0
- package/dist/types/nodefony/service/servers/server-websocket-secure.d.ts +29 -0
- package/dist/types/nodefony/service/servers/server-websocket.d.ts +29 -0
- package/dist/types/nodefony/service/servers/serverShutdown.d.ts +27 -0
- package/dist/types/nodefony/service/servers/wsHeartbeat.d.ts +46 -0
- package/dist/types/nodefony/service/sessions/sessions-service.d.ts +218 -0
- package/dist/types/nodefony/service/trace.d.ts +39 -0
- package/dist/types/nodefony/service/upload/upload-service.d.ts +61 -0
- package/dist/types/nodefony/src/assets/collectAssets.d.ts +35 -0
- package/dist/types/nodefony/src/assets/prebuiltUi.d.ts +99 -0
- package/dist/types/nodefony/src/context/Context.d.ts +195 -0
- package/dist/types/nodefony/src/context/domainMatcher.d.ts +67 -0
- package/dist/types/nodefony/src/context/forwarded.d.ts +95 -0
- package/dist/types/nodefony/src/context/http/HttpContext.d.ts +85 -0
- package/dist/types/nodefony/src/context/http/Request.d.ts +203 -0
- package/dist/types/nodefony/src/context/http/Response.d.ts +68 -0
- package/dist/types/nodefony/src/context/http/parser.d.ts +65 -0
- package/dist/types/nodefony/src/context/http/urlFastPath.d.ts +52 -0
- package/dist/types/nodefony/src/context/http2/Request.d.ts +14 -0
- package/dist/types/nodefony/src/context/http2/Response.d.ts +20 -0
- package/dist/types/nodefony/src/context/metaData.d.ts +58 -0
- package/dist/types/nodefony/src/context/requestId.d.ts +28 -0
- package/dist/types/nodefony/src/context/trustProxy.d.ts +77 -0
- package/dist/types/nodefony/src/context/websocket/Response.d.ts +53 -0
- package/dist/types/nodefony/src/context/websocket/WebsocketContext.d.ts +125 -0
- package/dist/types/nodefony/src/context/websocket/wsBackpressure.d.ts +73 -0
- package/dist/types/nodefony/src/context/websocket/wsLogContent.d.ts +37 -0
- package/dist/types/nodefony/src/cookies/cookie.d.ts +88 -0
- package/dist/types/nodefony/src/errors/httpError.d.ts +15 -0
- package/dist/types/nodefony/src/profiler/FrameProfile.d.ts +110 -0
- package/dist/types/nodefony/src/profiler/Profiler.d.ts +192 -0
- package/dist/types/nodefony/src/proxy/generateProxyConfig.d.ts +76 -0
- package/dist/types/nodefony/src/rateLimit/IRateLimitStore.d.ts +98 -0
- package/dist/types/nodefony/src/rateLimit/MemoryRateLimitStore.d.ts +40 -0
- package/dist/types/nodefony/src/rateLimit/WsConnectionCounter.d.ts +37 -0
- package/dist/types/nodefony/src/rateLimit/rateLimitFilters.d.ts +18 -0
- package/dist/types/nodefony/src/servers/portBinder.d.ts +102 -0
- package/dist/types/nodefony/src/session/session.d.ts +171 -0
- package/dist/types/nodefony/src/session/storage/MemorySessionStorage.d.ts +77 -0
- package/dist/types/nodefony/src/session/storage/RevocationGuardStorage.d.ts +81 -0
- package/dist/types/nodefony/src/session/storage/sessionFilters.d.ts +102 -0
- package/dist/types/nodefony/src/session/storage/sessionSort.d.ts +45 -0
- package/docs/cookies.md +365 -0
- package/docs/index.md +163 -0
- package/docs/observabilite.md +460 -0
- package/docs/rate-limit.md +372 -0
- package/docs/servers.md +935 -0
- package/docs/session.md +768 -0
- package/docs/upload.md +460 -0
- package/package.json +101 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import HttpResponse from "../http/Response.js";
|
|
2
|
+
import { extend } from "nodefony";
|
|
3
|
+
import http from "node:http";
|
|
4
|
+
import http2 from "node:http2";
|
|
5
|
+
//#region nodefony/src/context/http2/Response.ts
|
|
6
|
+
const HTTP2_HEADER_STATUS = http2.constants.HTTP2_HEADER_STATUS;
|
|
7
|
+
var Http2Response = class extends HttpResponse {
|
|
8
|
+
statusCode = 200;
|
|
9
|
+
stream = null;
|
|
10
|
+
streamId;
|
|
11
|
+
constructor(response, context) {
|
|
12
|
+
super(response, context);
|
|
13
|
+
if (response) {
|
|
14
|
+
this.stream = response.stream;
|
|
15
|
+
this.streamId = this.stream.id;
|
|
16
|
+
}
|
|
17
|
+
if (this.stream && this.stream.pushAllowed) this.context.pushAllowed = true;
|
|
18
|
+
}
|
|
19
|
+
isHeaderSent() {
|
|
20
|
+
if (this.stream) return this.stream.headersSent;
|
|
21
|
+
return super.isHeaderSent();
|
|
22
|
+
}
|
|
23
|
+
writeHead(statusCode, headers) {
|
|
24
|
+
if (this.stream) {
|
|
25
|
+
if (statusCode) this.setStatusCode(statusCode);
|
|
26
|
+
if (this.stream.destroyed || this.stream.closed) {
|
|
27
|
+
this.log("HTTP/2 stream destroyed before writeHead — skip", "DEBUG");
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (!this.stream.headersSent) {
|
|
31
|
+
if (this.statusCode) {
|
|
32
|
+
if (typeof this.statusCode === "string") this.statusCode = parseInt(this.statusCode, 10);
|
|
33
|
+
if (this.statusCode > 599) this.statusCode = 500;
|
|
34
|
+
}
|
|
35
|
+
this.statusMessage = this.getStatusMessage();
|
|
36
|
+
this.setLength();
|
|
37
|
+
this.ensureContentTypeHeader();
|
|
38
|
+
this.headers = extend(true, { "X-Status-Message": this.statusMessage }, this.getHeaders(), headers);
|
|
39
|
+
this.headers[HTTP2_HEADER_STATUS] = this.statusCode;
|
|
40
|
+
const requestId = this.context.requestId;
|
|
41
|
+
if (requestId) this.headers["x-request-id"] = requestId;
|
|
42
|
+
const traceparent = this.context.traceparent;
|
|
43
|
+
if (traceparent) this.headers["traceparent"] = traceparent;
|
|
44
|
+
this.stream.respond(this.headers, { endStream: false });
|
|
45
|
+
} else this.log("Headers already sent !!", "WARNING");
|
|
46
|
+
} else return super.writeHead(statusCode, headers);
|
|
47
|
+
}
|
|
48
|
+
async send(chunk, encoding, _flush = false) {
|
|
49
|
+
if (this.context.isRedirect) {
|
|
50
|
+
if (this.stream && !this.stream.headersSent) this.writeHead();
|
|
51
|
+
await this.end();
|
|
52
|
+
return this;
|
|
53
|
+
}
|
|
54
|
+
const stream = this.stream;
|
|
55
|
+
if (!stream) return await super.send(chunk, encoding);
|
|
56
|
+
if (stream.destroyed || !stream.writable) {
|
|
57
|
+
this.log("HTTP/2 stream not writable before send — skip", "DEBUG");
|
|
58
|
+
return this;
|
|
59
|
+
}
|
|
60
|
+
if (chunk) this.setBody(chunk);
|
|
61
|
+
if (this.body === null) this.body = Buffer.alloc(0);
|
|
62
|
+
return new Promise((resolve, reject) => {
|
|
63
|
+
stream.write(this.body, encoding || this.encoding, (error) => {
|
|
64
|
+
if (error) return reject(error);
|
|
65
|
+
return resolve(this);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
end(chunk, encoding) {
|
|
70
|
+
return new Promise((resolve, reject) => {
|
|
71
|
+
try {
|
|
72
|
+
if (this.stream) {
|
|
73
|
+
if (this.stream.destroyed || this.stream.closed) return resolve(this.stream);
|
|
74
|
+
return resolve(this.stream.end(chunk, encoding || this.encoding));
|
|
75
|
+
}
|
|
76
|
+
return resolve(super.end(chunk, encoding));
|
|
77
|
+
} catch (e) {
|
|
78
|
+
return reject(e);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
getStatusMessage(code) {
|
|
83
|
+
if (code) {
|
|
84
|
+
if (this.response) return http.STATUS_CODES[code] || this.statusMessage;
|
|
85
|
+
}
|
|
86
|
+
if (this.response) return this.statusMessage || http.STATUS_CODES[this.statusCode];
|
|
87
|
+
return this.statusMessage || http.STATUS_CODES[this.statusCode];
|
|
88
|
+
}
|
|
89
|
+
getStatus() {
|
|
90
|
+
return {
|
|
91
|
+
code: this.getStatusCode(),
|
|
92
|
+
message: ""
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
//#endregion
|
|
97
|
+
export { Http2Response as default };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
//#region nodefony/src/context/metaData.ts
|
|
2
|
+
/**
|
|
3
|
+
* Assemble l'enveloppe `metaData` d'une requête EN PLACE (builder monomorphe) :
|
|
4
|
+
* met à jour les champs de `target.nodefony` dans un ordre fixe → V8 garde une
|
|
5
|
+
* hidden class stable et inline les écritures. Remplace l'ancien
|
|
6
|
+
* `extend(true, …)` qui deep-clonait/dispatchait à chaque réponse JSON et chaque
|
|
7
|
+
* frame WS — inutile ici puisque `target` est l'objet metaData per-requête
|
|
8
|
+
* (jamais partagé).
|
|
9
|
+
*
|
|
10
|
+
* `route` est un **snapshot per-requête** `{ name, path, variablesMap }` : on ne
|
|
11
|
+
* diffuse jamais l'instance `Route` partagée (statique), dont les variables
|
|
12
|
+
* matchées seraient écrasées par toute requête/connexion concurrente (bleed).
|
|
13
|
+
*
|
|
14
|
+
* `override` (ex. frame WS `{ nodefony: { websocket } }`) est fusionné *shallow*
|
|
15
|
+
* dans l'enveloppe : le seul cas réel est l'ajout d'un sous-objet `nodefony`
|
|
16
|
+
* peu profond — pas besoin du deep-merge récursif générique. Toute clé top-level
|
|
17
|
+
* hors `nodefony` est préservée telle quelle.
|
|
18
|
+
*
|
|
19
|
+
* @param target - objet metaData per-requête à muter (et retourner)
|
|
20
|
+
* @param src - source structurelle (le `Context` réel, ou un faux en test)
|
|
21
|
+
* @param override - overrides optionnels de l'appelant
|
|
22
|
+
* @returns le même `target`, muté
|
|
23
|
+
*/
|
|
24
|
+
function buildMetaData(target, src, override) {
|
|
25
|
+
const nf = target.nodefony;
|
|
26
|
+
nf.name = src.kernel?.projectName;
|
|
27
|
+
nf.version = src.kernel?.version;
|
|
28
|
+
nf.url = src.request?.href ?? src.request?.url;
|
|
29
|
+
nf.environment = src.kernel?.environment;
|
|
30
|
+
nf.debug = src.kernel?.debug;
|
|
31
|
+
nf.scheme = src.scheme;
|
|
32
|
+
nf.requestId = src.requestId;
|
|
33
|
+
const r = src.resolver;
|
|
34
|
+
nf.route = r && r.route ? {
|
|
35
|
+
name: r.route.name,
|
|
36
|
+
path: r.route.path,
|
|
37
|
+
variablesMap: r.getMatchedParams()
|
|
38
|
+
} : void 0;
|
|
39
|
+
if (override) {
|
|
40
|
+
const ovr = override.nodefony;
|
|
41
|
+
if (ovr) Object.assign(nf, ovr);
|
|
42
|
+
for (const k in override) if (k !== "nodefony") target[k] = override[k];
|
|
43
|
+
}
|
|
44
|
+
return target;
|
|
45
|
+
}
|
|
46
|
+
//#endregion
|
|
47
|
+
export { buildMetaData };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
//#region nodefony/src/context/requestId.ts
|
|
2
|
+
/**
|
|
3
|
+
* Validation de l'identifiant de corrélation `X-Request-Id` fourni par le
|
|
4
|
+
* client, avant qu'il ne soit adopté comme `Context.requestId`.
|
|
5
|
+
*
|
|
6
|
+
* Le `requestId` finit (1) réfléchi dans la réponse (`X-Request-Id`),
|
|
7
|
+
* (2) écrit dans les logs (`ID : …`), (3) propagé en ALS à tout le pipeline.
|
|
8
|
+
* Une valeur cliente non assainie ouvre donc : log-injection (CR/LF dans les
|
|
9
|
+
* logs), forging de corrélation, et throw `setHeader` natif (→ 500/DoS) sur
|
|
10
|
+
* caractère de contrôle ou non-ASCII. Cf RFC 9110 §5.5 (field values) + Zero
|
|
11
|
+
* Trust (mémoire `feedback_security_rfc_rigor`).
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Longueur maximale acceptée pour un `X-Request-Id` client. Borne anti-abus
|
|
15
|
+
* (log flooding / header oversize). 128 couvre largement un UUID (36), un
|
|
16
|
+
* nanoid, ou un `traceparent` (55).
|
|
17
|
+
*/
|
|
18
|
+
const MAX_REQUEST_ID_LENGTH = 128;
|
|
19
|
+
/**
|
|
20
|
+
* Caractères sûrs : ASCII alphanumérique + `.`, `_`, `-`. Exclut tout caractère
|
|
21
|
+
* de contrôle (CR/LF → response splitting / log injection), espace, et
|
|
22
|
+
* non-ASCII (→ throw `setHeader` Node = DoS). Ancré + quantifieur borné : pas
|
|
23
|
+
* de backtracking catastrophique (ReDoS).
|
|
24
|
+
*/
|
|
25
|
+
const SAFE_REQUEST_ID = /^[A-Za-z0-9._-]{1,128}$/u;
|
|
26
|
+
/**
|
|
27
|
+
* Valide un `X-Request-Id` entrant. Retourne la valeur si elle est sûre, sinon
|
|
28
|
+
* `null` (l'appelant conserve alors l'UUID généré côté serveur).
|
|
29
|
+
*
|
|
30
|
+
* On REJETTE (plutôt que tronquer/nettoyer) une valeur invalide : nettoyer
|
|
31
|
+
* donnerait au client un faux contrôle sur l'identifiant et masquerait l'abus.
|
|
32
|
+
*
|
|
33
|
+
* @param raw - valeur brute du header `x-request-id` (ou `undefined`/`null`).
|
|
34
|
+
* @returns la valeur si sûre, sinon `null`.
|
|
35
|
+
*/
|
|
36
|
+
function sanitizeRequestId(raw) {
|
|
37
|
+
if (!raw || raw.length > 128) return null;
|
|
38
|
+
return SAFE_REQUEST_ID.test(raw) ? raw : null;
|
|
39
|
+
}
|
|
40
|
+
//#endregion
|
|
41
|
+
export { MAX_REQUEST_ID_LENGTH, sanitizeRequestId };
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { BlockList, isIPv4, isIPv6 } from "node:net";
|
|
2
|
+
//#region nodefony/src/context/trustProxy.ts
|
|
3
|
+
const PRESETS = {
|
|
4
|
+
loopback: [[
|
|
5
|
+
"127.0.0.0",
|
|
6
|
+
8,
|
|
7
|
+
"ipv4"
|
|
8
|
+
], [
|
|
9
|
+
"::1",
|
|
10
|
+
128,
|
|
11
|
+
"ipv6"
|
|
12
|
+
]],
|
|
13
|
+
linklocal: [[
|
|
14
|
+
"169.254.0.0",
|
|
15
|
+
16,
|
|
16
|
+
"ipv4"
|
|
17
|
+
], [
|
|
18
|
+
"fe80::",
|
|
19
|
+
10,
|
|
20
|
+
"ipv6"
|
|
21
|
+
]],
|
|
22
|
+
uniquelocal: [
|
|
23
|
+
[
|
|
24
|
+
"10.0.0.0",
|
|
25
|
+
8,
|
|
26
|
+
"ipv4"
|
|
27
|
+
],
|
|
28
|
+
[
|
|
29
|
+
"172.16.0.0",
|
|
30
|
+
12,
|
|
31
|
+
"ipv4"
|
|
32
|
+
],
|
|
33
|
+
[
|
|
34
|
+
"192.168.0.0",
|
|
35
|
+
16,
|
|
36
|
+
"ipv4"
|
|
37
|
+
],
|
|
38
|
+
[
|
|
39
|
+
"fc00::",
|
|
40
|
+
7,
|
|
41
|
+
"ipv6"
|
|
42
|
+
]
|
|
43
|
+
]
|
|
44
|
+
};
|
|
45
|
+
/** Normalise une adresse IPv4-mapped IPv6 (`::ffff:127.0.0.1`) en IPv4. */
|
|
46
|
+
const normalize = (addr) => addr.startsWith("::ffff:") && isIPv4(addr.slice(7)) ? addr.slice(7) : addr;
|
|
47
|
+
const TRUST_ALL = { isTrusted: () => true };
|
|
48
|
+
const TRUST_NONE = { isTrusted: () => false };
|
|
49
|
+
/**
|
|
50
|
+
* Ajoute une entrée (IP, CIDR ou preset) à la `BlockList`.
|
|
51
|
+
*
|
|
52
|
+
* @throws {Error} si l'entrée n'est ni une IP, ni un CIDR, ni un preset connu.
|
|
53
|
+
*/
|
|
54
|
+
function addEntry(list, raw) {
|
|
55
|
+
const entry = raw.trim();
|
|
56
|
+
if (!entry) return;
|
|
57
|
+
const preset = PRESETS[entry.toLowerCase()];
|
|
58
|
+
if (preset) {
|
|
59
|
+
for (const [net, prefix, family] of preset) list.addSubnet(net, prefix, family);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const slash = entry.indexOf("/");
|
|
63
|
+
if (slash !== -1) {
|
|
64
|
+
const net = entry.slice(0, slash);
|
|
65
|
+
const prefix = Number(entry.slice(slash + 1));
|
|
66
|
+
const family = isIPv6(net) ? "ipv6" : "ipv4";
|
|
67
|
+
list.addSubnet(net, prefix, family);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (isIPv4(entry)) {
|
|
71
|
+
list.addAddress(entry, "ipv4");
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (isIPv6(entry)) {
|
|
75
|
+
list.addAddress(entry, "ipv6");
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
throw new Error(`trustProxy: invalid IP/CIDR/preset "${raw}"`);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Compile une config `trustProxy` en un {@link TrustProxyChecker} (une fois, au
|
|
82
|
+
* boot — pas par requête). Les entrées invalides lèvent à la compilation (fail
|
|
83
|
+
* fast) plutôt que silencieusement par requête.
|
|
84
|
+
*
|
|
85
|
+
* @param config - voir {@link TrustProxyConfig}. `undefined` → aucune confiance.
|
|
86
|
+
* @returns un checker `isTrusted(remoteAddress)`.
|
|
87
|
+
*/
|
|
88
|
+
function buildTrustProxy(config) {
|
|
89
|
+
if (config === true) return TRUST_ALL;
|
|
90
|
+
if (!config) return TRUST_NONE;
|
|
91
|
+
const entries = Array.isArray(config) ? config : [config];
|
|
92
|
+
const list = new BlockList();
|
|
93
|
+
for (const entry of entries) addEntry(list, entry);
|
|
94
|
+
return { isTrusted(remoteAddress) {
|
|
95
|
+
if (!remoteAddress) return false;
|
|
96
|
+
const addr = normalize(remoteAddress);
|
|
97
|
+
const family = isIPv6(addr) ? "ipv6" : isIPv4(addr) ? "ipv4" : null;
|
|
98
|
+
return family ? list.check(addr, family) : false;
|
|
99
|
+
} };
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Cœur de la résolution **from-right** (OWASP) partagé par `X-Forwarded-For`
|
|
103
|
+
* (de-facto) et le paramètre `for` du header `Forwarded` (RFC 7239 §5.2).
|
|
104
|
+
*
|
|
105
|
+
* `chain` = liste des maillons forwarded NORMALISÉS (IP nues, gauche→droite :
|
|
106
|
+
* `[client, proxy1, proxy2]`), telle qu'écrite par les proxies par **append à
|
|
107
|
+
* droite**. La partie gauche est forgeable par le client (cf {@link extractClientIp}).
|
|
108
|
+
* La résolution part de la **connexion réelle** (le socket, non forgeable) et
|
|
109
|
+
* remonte de DROITE à GAUCHE : tant que le maillon courant est un proxy de
|
|
110
|
+
* confiance, on passe au précédent ; le **premier maillon non fiable** est l'IP
|
|
111
|
+
* cliente réelle.
|
|
112
|
+
*
|
|
113
|
+
* Un maillon `null` (identifiant obfusqué `_secret`, `unknown`, ou node illisible
|
|
114
|
+
* — RFC 7239 §6.3/§8.3) est une **barrière non franchissable** : on ne peut pas
|
|
115
|
+
* en vérifier la confiance → on s'arrête et on retourne le dernier maillon de
|
|
116
|
+
* confiance connu (une vraie IP de l'infra), jamais `null` ni la valeur obfusquée.
|
|
117
|
+
*
|
|
118
|
+
* @param chain - maillons forwarded normalisés (IP ou `null` si obfusqué).
|
|
119
|
+
* @param socketAddress - `socket.remoteAddress` (connexion TCP réelle, fiable).
|
|
120
|
+
* @param checker - politique de confiance ({@link buildTrustProxy}).
|
|
121
|
+
* @returns l'IP cliente réelle résolue (toujours une valeur fiable).
|
|
122
|
+
*/
|
|
123
|
+
function resolveFromRight(chain, socketAddress, checker) {
|
|
124
|
+
let candidate = socketAddress;
|
|
125
|
+
for (let i = chain.length - 1; i >= 0; i -= 1) {
|
|
126
|
+
if (!checker.isTrusted(candidate)) return candidate;
|
|
127
|
+
const next = chain[i];
|
|
128
|
+
if (next === null) return candidate;
|
|
129
|
+
candidate = next;
|
|
130
|
+
}
|
|
131
|
+
return candidate;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Résout l'adresse IP cliente RÉELLE à partir de la connexion socket et de la
|
|
135
|
+
* chaîne `X-Forwarded-For`, en dépouillant les proxies de confiance **de droite
|
|
136
|
+
* à gauche** (algorithme OWASP).
|
|
137
|
+
*
|
|
138
|
+
* `X-Forwarded-For` (de-facto) est construit par **append de gauche à droite** :
|
|
139
|
+
* `XFF: client, proxy1, proxy2`. Chaque proxy ajoute À DROITE l'adresse qu'il a
|
|
140
|
+
* vue. La partie **gauche** est donc entièrement **forgeable** par le client
|
|
141
|
+
* (il peut envoyer `X-Forwarded-For: 1.2.3.4` ; le 1ᵉʳ proxy ne fait qu'append
|
|
142
|
+
* l'IP réelle après). Lire `XFF[0]` revient à lire la valeur du client →
|
|
143
|
+
* **IP spoofing** (contournement de ban / rate-limit / allow-list / audit).
|
|
144
|
+
*
|
|
145
|
+
* Hot path préservé : sans `X-Forwarded-For` (cas direct usuel) la fonction
|
|
146
|
+
* retourne immédiatement le socket — 0 allocation (pas de split). Pour le header
|
|
147
|
+
* standard `Forwarded` (RFC 7239), voir `resolveForwarded` (module `forwarded`).
|
|
148
|
+
*
|
|
149
|
+
* @param xff - valeur brute de l'en-tête `X-Forwarded-For` (string, ou string[]
|
|
150
|
+
* si l'en-tête est répété), ou `undefined`.
|
|
151
|
+
* @param socketAddress - `socket.remoteAddress` (la connexion TCP réelle).
|
|
152
|
+
* @param checker - politique de confiance ({@link buildTrustProxy}).
|
|
153
|
+
* @returns l'IP cliente réelle, ou `null` si aucune connexion socket fiable.
|
|
154
|
+
*/
|
|
155
|
+
function extractClientIp(xff, socketAddress, checker) {
|
|
156
|
+
if (!socketAddress) return null;
|
|
157
|
+
if (!xff) return socketAddress;
|
|
158
|
+
const s = Array.isArray(xff) ? xff.join(",") : xff;
|
|
159
|
+
if (s.indexOf(",") === -1) {
|
|
160
|
+
const ip = s.trim();
|
|
161
|
+
if (!ip) return socketAddress;
|
|
162
|
+
return checker.isTrusted(socketAddress) ? ip : socketAddress;
|
|
163
|
+
}
|
|
164
|
+
return resolveFromRight(s.split(",").map((part) => part.trim()).filter(Boolean), socketAddress, checker);
|
|
165
|
+
}
|
|
166
|
+
//#endregion
|
|
167
|
+
export { buildTrustProxy, extractClientIp, resolveFromRight };
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import Cookie from "../../cookies/cookie.js";
|
|
2
|
+
import { decideSend, readBackpressureOptions } from "./wsBackpressure.js";
|
|
3
|
+
import Ws from "ws";
|
|
4
|
+
import http from "node:http";
|
|
5
|
+
//#region nodefony/src/context/websocket/Response.ts
|
|
6
|
+
/**
|
|
7
|
+
* Codes d'erreur d'écriture « le client est PARTI » : reload de page, onglet
|
|
8
|
+
* fermé, HMR — la socket TCP est morte côté client pendant qu'une frame était
|
|
9
|
+
* en vol. Race réseau INÉVITABLE (la garde `readyState === OPEN` ne la couvre
|
|
10
|
+
* pas : l'état bascule après l'échec d'écriture) — c'est du réseau normal, pas
|
|
11
|
+
* une erreur serveur.
|
|
12
|
+
*/
|
|
13
|
+
const PEER_GONE_CODES = /* @__PURE__ */ new Set([
|
|
14
|
+
"EPIPE",
|
|
15
|
+
"ECONNRESET",
|
|
16
|
+
"ECONNABORTED",
|
|
17
|
+
"ERR_STREAM_DESTROYED",
|
|
18
|
+
"ERR_STREAM_WRITE_AFTER_END"
|
|
19
|
+
]);
|
|
20
|
+
/**
|
|
21
|
+
* `true` si l'erreur d'écriture signifie seulement que le client s'est
|
|
22
|
+
* déconnecté (frame perdue, connexion en cours de fermeture) — à logger DEBUG,
|
|
23
|
+
* jamais ERROR + stack (vécu : un simple reload de page pendant un ping WS
|
|
24
|
+
* remplissait le journal d'« Error: write EPIPE »).
|
|
25
|
+
*/
|
|
26
|
+
function isPeerGoneError(error) {
|
|
27
|
+
const code = error?.code;
|
|
28
|
+
return typeof code === "string" && PEER_GONE_CODES.has(code);
|
|
29
|
+
}
|
|
30
|
+
const WS_CLOSE_DESCRIPTIONS = {
|
|
31
|
+
1e3: "Normal Closure",
|
|
32
|
+
1001: "Going Away",
|
|
33
|
+
1002: "Protocol Error",
|
|
34
|
+
1003: "Unsupported Data",
|
|
35
|
+
1005: "No Status Received",
|
|
36
|
+
1006: "Abnormal Closure",
|
|
37
|
+
1007: "Invalid frame payload data",
|
|
38
|
+
1008: "Policy Violation",
|
|
39
|
+
1009: "Message too big",
|
|
40
|
+
1010: "Missing Extension",
|
|
41
|
+
1011: "Internal Error",
|
|
42
|
+
1012: "Service Restart",
|
|
43
|
+
1013: "Try Again Later",
|
|
44
|
+
1015: "TLS Handshake"
|
|
45
|
+
};
|
|
46
|
+
var WebsocketResponse = class {
|
|
47
|
+
context;
|
|
48
|
+
statusCode = 1e3;
|
|
49
|
+
body = null;
|
|
50
|
+
encoding = "utf-8";
|
|
51
|
+
connection = null;
|
|
52
|
+
statusMessage = "";
|
|
53
|
+
webSocketVersion;
|
|
54
|
+
cookies = {};
|
|
55
|
+
constructor(connection, context) {
|
|
56
|
+
this.context = context;
|
|
57
|
+
this.connection = connection;
|
|
58
|
+
}
|
|
59
|
+
log(pci, severity, msgid, msg) {
|
|
60
|
+
const syslog = this.context?.container?.get("syslog");
|
|
61
|
+
if (!msgid) msgid = "WEBSOCKET RESPONSE";
|
|
62
|
+
return syslog?.log(pci, severity, msgid, msg);
|
|
63
|
+
}
|
|
64
|
+
setConnection(connection) {
|
|
65
|
+
this.connection = connection;
|
|
66
|
+
return connection;
|
|
67
|
+
}
|
|
68
|
+
async send(data, encoding) {
|
|
69
|
+
const payload = data ?? this.body;
|
|
70
|
+
if (payload == null) throw new Error("no data");
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
if (!this.connection || this.connection.readyState !== Ws.OPEN) return reject(/* @__PURE__ */ new Error("WebSocket not open"));
|
|
73
|
+
const { max, policy, closeAfterDrops } = readBackpressureOptions(this.context?.server);
|
|
74
|
+
const decision = decideSend(this.connection, max, policy, closeAfterDrops);
|
|
75
|
+
if (decision !== "send") {
|
|
76
|
+
this.logFirstDrop(this.connection, decision, max);
|
|
77
|
+
return resolve(this);
|
|
78
|
+
}
|
|
79
|
+
const sendData = encoding === "binary" && Buffer.isBuffer(payload) ? payload : payload instanceof Buffer ? payload.toString(encoding ?? this.encoding) : payload;
|
|
80
|
+
this.connection.send(sendData, (error) => {
|
|
81
|
+
if (error) {
|
|
82
|
+
if (isPeerGoneError(error)) {
|
|
83
|
+
this.log(`WS send: client déconnecté pendant l'écriture (${error.code}) — frame perdue`, "DEBUG");
|
|
84
|
+
return resolve(this);
|
|
85
|
+
}
|
|
86
|
+
this.log(error, "ERROR");
|
|
87
|
+
return reject(error);
|
|
88
|
+
}
|
|
89
|
+
return resolve(this);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
broadcast(data, type) {
|
|
94
|
+
const payload = data ?? this.body;
|
|
95
|
+
if (!payload) return;
|
|
96
|
+
const wss = this.context?.server;
|
|
97
|
+
if (!wss) return;
|
|
98
|
+
const sendData = type === "binary" && Buffer.isBuffer(payload) ? payload : payload instanceof Buffer ? payload.toString(this.encoding) : payload;
|
|
99
|
+
const { max, policy, closeAfterDrops } = readBackpressureOptions(wss);
|
|
100
|
+
const onWriteError = (error) => {
|
|
101
|
+
if (error && !isPeerGoneError(error)) this.log(error, "ERROR");
|
|
102
|
+
};
|
|
103
|
+
wss.clients.forEach((client) => {
|
|
104
|
+
if (client.readyState !== Ws.OPEN) return;
|
|
105
|
+
const decision = decideSend(client, max, policy, closeAfterDrops);
|
|
106
|
+
if (decision !== "send") {
|
|
107
|
+
this.logFirstDrop(client, decision, max);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
client.send(sendData, onWriteError);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Logge UNE seule fois par connexion (au 1er drop) qu'un client subit la
|
|
115
|
+
* backpressure — observabilité opérateur sans bruit dans le hot path.
|
|
116
|
+
*/
|
|
117
|
+
logFirstDrop(ws, decision, max) {
|
|
118
|
+
if (ws._nfDrops === 1) this.log(`WS backpressure → ${decision} (bufferedAmount > ${max} o) — client lent à recevoir`, "WARNING");
|
|
119
|
+
}
|
|
120
|
+
setBody(ele, encoding) {
|
|
121
|
+
if (typeof ele === "string") this.body = Buffer.from(ele, encoding ?? this.encoding);
|
|
122
|
+
else if (ele instanceof ArrayBuffer || ele instanceof SharedArrayBuffer) this.body = Buffer.from(ele);
|
|
123
|
+
else if ("buffer" in ele && ele.buffer instanceof ArrayBuffer) this.body = Buffer.from(ele.buffer);
|
|
124
|
+
return this.body;
|
|
125
|
+
}
|
|
126
|
+
drop(reasonCode, description) {
|
|
127
|
+
if (this.connection && this.connection.readyState === Ws.OPEN) return this.connection.close(reasonCode ?? this.statusCode, description);
|
|
128
|
+
throw new Error("Connection already closed");
|
|
129
|
+
}
|
|
130
|
+
close(reasonCode, description) {
|
|
131
|
+
if (this.connection && this.connection.readyState === Ws.OPEN) return this.connection.close(reasonCode ?? this.statusCode, description ?? "closed");
|
|
132
|
+
throw new Error("Connection already closed");
|
|
133
|
+
}
|
|
134
|
+
getStatus() {
|
|
135
|
+
return {
|
|
136
|
+
code: this.getStatusCode(),
|
|
137
|
+
message: this.getStatusMessage()
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
getStatusCode() {
|
|
141
|
+
return this.statusCode;
|
|
142
|
+
}
|
|
143
|
+
getStatusMessage() {
|
|
144
|
+
return this.statusMessage;
|
|
145
|
+
}
|
|
146
|
+
setStatusCode(status, message) {
|
|
147
|
+
if (typeof status !== "number") {
|
|
148
|
+
status = parseInt(status, 10);
|
|
149
|
+
if (isNaN(status)) status = 500;
|
|
150
|
+
}
|
|
151
|
+
if (!status) status = 500;
|
|
152
|
+
this.statusCode = status;
|
|
153
|
+
if (!message) message = WS_CLOSE_DESCRIPTIONS[this.statusCode] ?? http.STATUS_CODES[this.statusCode];
|
|
154
|
+
this.statusMessage = message ?? "";
|
|
155
|
+
return {
|
|
156
|
+
code: this.statusCode,
|
|
157
|
+
message: this.statusMessage
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
clean() {
|
|
161
|
+
this.connection = null;
|
|
162
|
+
this.body = null;
|
|
163
|
+
}
|
|
164
|
+
setEncoding(encoding) {
|
|
165
|
+
return this.encoding = encoding;
|
|
166
|
+
}
|
|
167
|
+
setHeader() {
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
setHeaders() {
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
addCookie(cookie) {
|
|
174
|
+
if (cookie instanceof Cookie) this.cookies[cookie.name] = cookie;
|
|
175
|
+
else throw new Error("Response addCookies not valid cookies");
|
|
176
|
+
}
|
|
177
|
+
setCookies() {}
|
|
178
|
+
setCookie(_cookie) {}
|
|
179
|
+
};
|
|
180
|
+
//#endregion
|
|
181
|
+
export { WebsocketResponse as default, isPeerGoneError };
|