@nodefony/security 10.0.0-beta.6 → 10.0.0-beta.7
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 +24 -0
- package/dist/_virtual/{_@oxc-project_runtime@0.157.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
- package/dist/index.js +4 -2
- package/dist/nodefony/command/security-mcp-token.js +43 -5
- package/dist/nodefony/config/config.js +17 -6
- package/dist/nodefony/config/defineModuleConfig.js +52 -1
- package/dist/nodefony/errors/FetchPublicError.js +18 -0
- package/dist/nodefony/errors/SsrfError.js +9 -1
- package/dist/nodefony/errors/StaleSessionError.js +20 -0
- package/dist/nodefony/errors/index.js +3 -1
- package/dist/nodefony/keycloak/scaffold.js +31 -14
- package/dist/nodefony/service/accessTokenVerifier.js +1 -1
- package/dist/nodefony/service/auditService.js +48 -3
- package/dist/nodefony/service/authFlow.js +6 -3
- package/dist/nodefony/service/firewall.js +48 -3
- package/dist/nodefony/service/tokenService.js +8 -3
- package/dist/nodefony/service/totp.js +7 -2
- package/dist/nodefony/service/webAuthn.js +45 -6
- package/dist/nodefony/src/audit/AuditRefusalAggregator.js +139 -0
- package/dist/nodefony/src/authenticator/JwtAuthenticator.js +24 -4
- package/dist/nodefony/src/authenticator/SessionAuthenticator.js +21 -8
- package/dist/nodefony/src/authenticator/authenticatorRegistry.js +1 -1
- package/dist/nodefony/src/net/fetchPublic.js +244 -0
- package/dist/nodefony/src/net/ssrfGuard.js +2 -2
- package/dist/nodefony/src/realtime/frameAuthorizer.js +130 -39
- package/dist/nodefony/src/token/jwtRuntime.js +22 -2
- package/dist/nodefony/src/webhook/webhookDelivery.js +37 -86
- package/dist/types/index.d.ts +4 -1
- package/dist/types/nodefony/command/security-mcp-token.d.ts +12 -0
- package/dist/types/nodefony/config/config.d.ts +6 -0
- package/dist/types/nodefony/errors/FetchPublicError.d.ts +15 -0
- package/dist/types/nodefony/errors/SsrfError.d.ts +8 -1
- package/dist/types/nodefony/errors/StaleSessionError.d.ts +17 -0
- package/dist/types/nodefony/errors/index.d.ts +3 -0
- package/dist/types/nodefony/keycloak/scaffold.d.ts +12 -6
- package/dist/types/nodefony/service/authFlow.d.ts +2 -2
- package/dist/types/nodefony/service/webAuthn.d.ts +13 -0
- package/dist/types/nodefony/src/audit/AuditRefusalAggregator.d.ts +55 -0
- package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +17 -3
- package/dist/types/nodefony/src/authenticator/SessionAuthenticator.d.ts +11 -4
- package/dist/types/nodefony/src/net/fetchPublic.d.ts +73 -0
- package/dist/types/nodefony/src/realtime/frameAuthorizer.d.ts +10 -2
- package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +19 -3
- package/dist/types/nodefony/src/token/jwtRuntime.d.ts +21 -2
- package/dist/types/nodefony/src/webhook/webhookDelivery.d.ts +5 -2
- package/docs/audit.md +112 -28
- package/docs/keycloak.md +4 -4
- package/docs/obtenir-un-jeton.md +17 -10
- package/docs/tokens.md +1 -1
- package/docs/webhooks.md +15 -7
- package/package.json +13 -13
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { assertPublicUrl } from "./ssrfGuard.js";
|
|
2
|
+
import { SsrfError } from "../../errors/SsrfError.js";
|
|
3
|
+
import { FetchPublicError } from "../../errors/FetchPublicError.js";
|
|
4
|
+
import { request } from "node:http";
|
|
5
|
+
import { isIP } from "node:net";
|
|
6
|
+
import { request as request$1 } from "node:https";
|
|
7
|
+
// nodefony/src/net/fetchPublic.ts
|
|
8
|
+
const DEFAULT_MAX_BYTES = 1048576;
|
|
9
|
+
const DEFAULT_TIMEOUT_MS = 1e4;
|
|
10
|
+
/** Statuts de redirection que l'on suit (RFC 9110 §15.4). */
|
|
11
|
+
function isRedirectStatus(status) {
|
|
12
|
+
return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
|
|
13
|
+
}
|
|
14
|
+
/** En-têtes qui ne doivent jamais suivre une redirection. */
|
|
15
|
+
const REDIRECT_STRIPPED = /* @__PURE__ */ new Set([
|
|
16
|
+
"cookie",
|
|
17
|
+
"authorization",
|
|
18
|
+
"proxy-authorization",
|
|
19
|
+
"host"
|
|
20
|
+
]);
|
|
21
|
+
/** En-têtes qui décrivent le corps — retirés quand la redirection l'abandonne. */
|
|
22
|
+
const BODY_HEADERS = /* @__PURE__ */ new Set([
|
|
23
|
+
"content-type",
|
|
24
|
+
"content-length",
|
|
25
|
+
"content-encoding"
|
|
26
|
+
]);
|
|
27
|
+
/** `lookup` qui n'offre que les IP vérifiées — le nom ne sert plus qu'au SNI et à `Host`. */
|
|
28
|
+
function pinnedLookup(addresses) {
|
|
29
|
+
return (_hostname, options, callback) => {
|
|
30
|
+
const entries = addresses.map((address) => ({
|
|
31
|
+
address,
|
|
32
|
+
family: isIP(address) === 6 ? 6 : 4
|
|
33
|
+
}));
|
|
34
|
+
const first = entries[0];
|
|
35
|
+
if (first === void 0) callback(Object.assign(/* @__PURE__ */ new Error("aucune adresse vérifiée"), {
|
|
36
|
+
code: "ENOTFOUND",
|
|
37
|
+
errno: -3008
|
|
38
|
+
}), "", 4);
|
|
39
|
+
else if (options.all === true) callback(null, entries, first.family);
|
|
40
|
+
else callback(null, first.address, first.family);
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/** Concatène les morceaux reçus UNE fois dans un `Uint8Array`. */
|
|
44
|
+
function concat(chunks, total) {
|
|
45
|
+
const out = new Uint8Array(total);
|
|
46
|
+
let offset = 0;
|
|
47
|
+
for (const chunk of chunks) {
|
|
48
|
+
out.set(chunk, offset);
|
|
49
|
+
offset += chunk.length;
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Un saut HTTP(S) vers l'IP épinglée. Le corps de réponse est lu jusqu'à
|
|
55
|
+
* `maxBytes` puis la connexion est DÉTRUITE (jamais drainée).
|
|
56
|
+
*/
|
|
57
|
+
function hop(url, method, headers, payload, addresses, follow, maxBytes, state) {
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
let settled = false;
|
|
60
|
+
const finish = (outcome) => {
|
|
61
|
+
if (settled) return;
|
|
62
|
+
settled = true;
|
|
63
|
+
state.current = null;
|
|
64
|
+
resolve(outcome);
|
|
65
|
+
};
|
|
66
|
+
/** Un échec réseau ; `code` est celui d'une erreur système (`ECONNRESET`…). */
|
|
67
|
+
const fail = (error, code) => {
|
|
68
|
+
if (settled) return;
|
|
69
|
+
settled = true;
|
|
70
|
+
state.current = null;
|
|
71
|
+
reject(state.expired ?? new FetchPublicError("network", typeof code === "string" ? `${error.message} (${code})` : error.message));
|
|
72
|
+
};
|
|
73
|
+
const req = (url.protocol === "https:" ? request$1 : request)(url, {
|
|
74
|
+
method,
|
|
75
|
+
headers,
|
|
76
|
+
agent: false,
|
|
77
|
+
...addresses.length > 0 ? { lookup: pinnedLookup(addresses) } : {}
|
|
78
|
+
});
|
|
79
|
+
state.current = req;
|
|
80
|
+
const onError = (error) => fail(error, error.code);
|
|
81
|
+
req.on("error", onError);
|
|
82
|
+
req.on("response", (res) => {
|
|
83
|
+
const status = res.statusCode ?? 0;
|
|
84
|
+
const location = res.headers.location;
|
|
85
|
+
if (follow && isRedirectStatus(status) && typeof location === "string") {
|
|
86
|
+
res.destroy();
|
|
87
|
+
finish({
|
|
88
|
+
kind: "redirect",
|
|
89
|
+
status,
|
|
90
|
+
location
|
|
91
|
+
});
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const chunks = [];
|
|
95
|
+
let total = 0;
|
|
96
|
+
res.on("data", (chunk) => {
|
|
97
|
+
if (settled) return;
|
|
98
|
+
const room = maxBytes - total;
|
|
99
|
+
if (chunk.length > room) {
|
|
100
|
+
if (room > 0) chunks.push(chunk.subarray(0, room));
|
|
101
|
+
total = maxBytes;
|
|
102
|
+
finish({
|
|
103
|
+
kind: "final",
|
|
104
|
+
status,
|
|
105
|
+
headers: res.headers,
|
|
106
|
+
body: concat(chunks, total),
|
|
107
|
+
truncated: true
|
|
108
|
+
});
|
|
109
|
+
res.destroy();
|
|
110
|
+
req.destroy();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
chunks.push(chunk);
|
|
114
|
+
total += chunk.length;
|
|
115
|
+
});
|
|
116
|
+
res.on("end", () => finish({
|
|
117
|
+
kind: "final",
|
|
118
|
+
status,
|
|
119
|
+
headers: res.headers,
|
|
120
|
+
body: concat(chunks, total),
|
|
121
|
+
truncated: false
|
|
122
|
+
}));
|
|
123
|
+
res.on("error", onError);
|
|
124
|
+
res.on("close", () => {
|
|
125
|
+
if (!settled) fail(/* @__PURE__ */ new Error("connexion fermée avant la fin du corps"));
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
req.end(payload);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Lit une URL fournie par un humain sans jamais atteindre le réseau interne.
|
|
133
|
+
*
|
|
134
|
+
* À CHAQUE saut (adresse de départ comme redirections) : protocole, absence
|
|
135
|
+
* d'identifiants et IP sont jugés par `assertPublicUrl`, puis la connexion est
|
|
136
|
+
* ÉPINGLÉE sur l'IP vérifiée (aucune seconde résolution : un rebinding DNS ne
|
|
137
|
+
* mène nulle part). Le délai est TOTAL — un serveur qui goutte un octet par
|
|
138
|
+
* seconde est coupé — et la lecture s'arrête à `maxBytes` en détruisant la
|
|
139
|
+
* connexion. Aucun cookie, aucun `Authorization` n'est ajouté ni conservé à
|
|
140
|
+
* travers une redirection, et le corps n'est jamais décodé (pas de bombe de
|
|
141
|
+
* décompression).
|
|
142
|
+
*
|
|
143
|
+
* @param rawUrl - adresse à lire.
|
|
144
|
+
* @param options - méthode, corps, bornes et politique de cible.
|
|
145
|
+
* @returns la réponse finale, corps borné et non décodé.
|
|
146
|
+
* @throws SsrfError si la cible, ou une redirection, est interdite.
|
|
147
|
+
* @throws FetchPublicError si le délai total, le nombre de redirections ou le réseau échoue.
|
|
148
|
+
*/
|
|
149
|
+
async function fetchPublic(rawUrl, options = {}) {
|
|
150
|
+
const maxRedirects = options.maxRedirects ?? 0;
|
|
151
|
+
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
152
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
153
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) throw new RangeError("maxBytes doit être un entier positif ou nul");
|
|
154
|
+
if (!Number.isSafeInteger(maxRedirects) || maxRedirects < 0) throw new RangeError("maxRedirects doit être un entier positif ou nul");
|
|
155
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new RangeError("timeoutMs doit être strictement positif");
|
|
156
|
+
let payload = options.body === void 0 ? null : typeof options.body === "string" ? new TextEncoder().encode(options.body) : options.body;
|
|
157
|
+
let method = (options.method ?? (payload === null ? "GET" : "POST")).toUpperCase();
|
|
158
|
+
let headers = { "accept-encoding": "identity" };
|
|
159
|
+
if (options.headers !== void 0) for (const [name, value] of Object.entries(options.headers)) headers[name.toLowerCase()] = value;
|
|
160
|
+
if (payload !== null) headers["content-length"] = String(payload.length);
|
|
161
|
+
const allowHttp = options.allowHttp === true;
|
|
162
|
+
const state = {
|
|
163
|
+
current: null,
|
|
164
|
+
expired: null
|
|
165
|
+
};
|
|
166
|
+
let expire;
|
|
167
|
+
const deadline = new Promise((_, rejectDeadline) => {
|
|
168
|
+
expire = rejectDeadline;
|
|
169
|
+
});
|
|
170
|
+
deadline.catch(() => void 0);
|
|
171
|
+
const timer = setTimeout(() => {
|
|
172
|
+
const error = new FetchPublicError("timeout", `délai total de ${timeoutMs} ms dépassé`);
|
|
173
|
+
state.expired = error;
|
|
174
|
+
expire(error);
|
|
175
|
+
state.current?.destroy();
|
|
176
|
+
}, timeoutMs);
|
|
177
|
+
try {
|
|
178
|
+
let first = true;
|
|
179
|
+
let target = rawUrl;
|
|
180
|
+
let pinnedHost = null;
|
|
181
|
+
let pinnedAddresses = [];
|
|
182
|
+
let redirects = 0;
|
|
183
|
+
for (;;) {
|
|
184
|
+
let url;
|
|
185
|
+
let addresses;
|
|
186
|
+
if (first && options.vettedAddresses !== void 0 && options.vettedAddresses.length > 0) {
|
|
187
|
+
({url} = await Promise.race([assertPublicUrl(target, {
|
|
188
|
+
allowPrivate: true,
|
|
189
|
+
allowHttp
|
|
190
|
+
}), deadline]));
|
|
191
|
+
addresses = options.vettedAddresses;
|
|
192
|
+
} else if (!first && new URL(target).hostname === pinnedHost) {
|
|
193
|
+
({url} = await Promise.race([assertPublicUrl(target, {
|
|
194
|
+
allowPrivate: true,
|
|
195
|
+
allowHttp
|
|
196
|
+
}), deadline]));
|
|
197
|
+
addresses = pinnedAddresses;
|
|
198
|
+
} else {
|
|
199
|
+
const checked = await Promise.race([assertPublicUrl(target, {
|
|
200
|
+
allowPrivate: options.allowPrivate === true,
|
|
201
|
+
allowHttp,
|
|
202
|
+
...options.resolver !== void 0 ? { resolver: options.resolver } : {}
|
|
203
|
+
}), deadline]);
|
|
204
|
+
url = checked.url;
|
|
205
|
+
addresses = checked.addresses;
|
|
206
|
+
}
|
|
207
|
+
first = false;
|
|
208
|
+
pinnedHost = url.hostname;
|
|
209
|
+
pinnedAddresses = addresses;
|
|
210
|
+
const outcome = await Promise.race([hop(url, method, headers, payload, addresses, maxRedirects > 0, maxBytes, state), deadline]);
|
|
211
|
+
if (outcome.kind === "final") return {
|
|
212
|
+
status: outcome.status,
|
|
213
|
+
headers: outcome.headers,
|
|
214
|
+
body: outcome.body,
|
|
215
|
+
truncated: outcome.truncated,
|
|
216
|
+
url,
|
|
217
|
+
redirects
|
|
218
|
+
};
|
|
219
|
+
if (redirects >= maxRedirects) throw new FetchPublicError("too-many-redirects", `plus de ${maxRedirects} redirection(s)`);
|
|
220
|
+
redirects++;
|
|
221
|
+
try {
|
|
222
|
+
target = new URL(outcome.location, url).href;
|
|
223
|
+
} catch {
|
|
224
|
+
throw new SsrfError("en-tête Location invalide");
|
|
225
|
+
}
|
|
226
|
+
const toGet = outcome.status === 303 && method !== "GET" && method !== "HEAD" || (outcome.status === 301 || outcome.status === 302) && method === "POST";
|
|
227
|
+
const next = {};
|
|
228
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
229
|
+
if (REDIRECT_STRIPPED.has(name)) continue;
|
|
230
|
+
if (toGet && BODY_HEADERS.has(name)) continue;
|
|
231
|
+
next[name] = value;
|
|
232
|
+
}
|
|
233
|
+
headers = next;
|
|
234
|
+
if (toGet) {
|
|
235
|
+
payload = null;
|
|
236
|
+
method = "GET";
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
} finally {
|
|
240
|
+
clearTimeout(timer);
|
|
241
|
+
state.current?.destroy();
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
export { fetchPublic };
|
|
@@ -116,9 +116,9 @@ async function assertPublicUrl(rawUrl, options = {}) {
|
|
|
116
116
|
try {
|
|
117
117
|
addresses = await resolver(host);
|
|
118
118
|
} catch {
|
|
119
|
-
throw new SsrfError(`hôte non résolvable : « ${host}
|
|
119
|
+
throw new SsrfError(`hôte non résolvable : « ${host} »`, "unresolvable");
|
|
120
120
|
}
|
|
121
|
-
if (addresses.length === 0) throw new SsrfError(`hôte sans adresse : « ${host}
|
|
121
|
+
if (addresses.length === 0) throw new SsrfError(`hôte sans adresse : « ${host} »`, "unresolvable");
|
|
122
122
|
}
|
|
123
123
|
for (const ip of addresses) if (isBlockedAddress(ip)) throw new SsrfError(`cible non publique interdite : ${host} → ${ip}`);
|
|
124
124
|
return {
|
|
@@ -130,40 +130,104 @@ function buildSystemRules(prefixes) {
|
|
|
130
130
|
return rules;
|
|
131
131
|
}
|
|
132
132
|
/**
|
|
133
|
-
* `
|
|
134
|
-
* conventions transverses `:health`/`:stats` (après le namespace de module —
|
|
135
|
-
* `mymod:health` — éventuellement suffixées d'une cadence `nodefony:orm:health:5000`).
|
|
133
|
+
* `word` (en minuscules) figure-t-il à la position `at` de `s`, insensible à la casse, sans allocation ?
|
|
136
134
|
*/
|
|
137
|
-
function
|
|
138
|
-
|
|
139
|
-
for (let
|
|
140
|
-
let
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
135
|
+
function wordAtCI(s, at, word) {
|
|
136
|
+
if (at + word.length > s.length) return false;
|
|
137
|
+
for (let j = 0; j < word.length; j++) {
|
|
138
|
+
let c = s.charCodeAt(at + j);
|
|
139
|
+
if (c >= 65 && c <= 90) c += 32;
|
|
140
|
+
if (c !== word.charCodeAt(j)) return false;
|
|
141
|
+
}
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Convention transverse `:health` ou `:stats` portée par le nom, insensible à la casse et sans allocation — le suffixe trouvé (en minuscules), ou `null`. Après le namespace de module (`mymod:health`), éventuellement suffixée d'une cadence (`nodefony:orm:health:5000`).
|
|
146
|
+
*
|
|
147
|
+
* Exécuté à CHAQUE trame jugée — une action RPC n'est jamais retenue. Les deux
|
|
148
|
+
* motifs commencent par `:`, qui n'a pas de casse : on saute de `:` en `:` par
|
|
149
|
+
* `indexOf` (natif) et on ne compare les deux mots qu'à ces positions, en une
|
|
150
|
+
* passe. Le balayage naïf (chaque position, deux passes, casse abaissée à
|
|
151
|
+
* chaque caractère) pesait plus que tout le reste du verrou sur un canal libre
|
|
152
|
+
* (micro-frame-authorizer, verrou seul : 125 → 55 ns). Même verdict, prouvé
|
|
153
|
+
* contre le balayage naïf par `realtimeFrameLock.test.ts`. Les retours sont des
|
|
154
|
+
* littéraux : rien n'est alloué, et le refus expliqué
|
|
155
|
+
* ({@link explainReservedSuffix}) nomme le suffixe par la MÊME fonction.
|
|
156
|
+
*/
|
|
157
|
+
function reservedConventionAt(s) {
|
|
158
|
+
let i = s.indexOf(":");
|
|
159
|
+
while (i !== -1) {
|
|
160
|
+
if (wordAtCI(s, i + 1, "health")) return ":health";
|
|
161
|
+
if (wordAtCI(s, i + 1, "stats")) return ":stats";
|
|
162
|
+
i = s.indexOf(":", i + 1);
|
|
152
163
|
}
|
|
153
|
-
return
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Première règle système dont le préfixe couvre le canal, ou `null`. Premier
|
|
168
|
+
* préfixe qui matche gagne (la config est placée AVANT les défauts par le
|
|
169
|
+
* firewall → elle peut surcharger). Match INSENSIBLE À LA CASSE (cf
|
|
170
|
+
* {@link startsWithCI}) : le plancher ne se contourne pas en altérant la casse
|
|
171
|
+
* du namespace.
|
|
172
|
+
*/
|
|
173
|
+
function matchSystemRule(channel, rules) {
|
|
174
|
+
for (const rule of rules) if (startsWithCI(channel, rule.prefix)) return rule;
|
|
175
|
+
return null;
|
|
154
176
|
}
|
|
155
177
|
/**
|
|
156
178
|
* Politique système applicable à un canal, ou `null` si le canal n'est pas
|
|
157
|
-
* réservé.
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
* INSENSIBLE À LA CASSE (cf {@link startsWithCI}) : le plancher ne se contourne
|
|
161
|
-
* pas en altérant la casse du namespace.
|
|
179
|
+
* réservé. Une règle de préfixe ({@link matchSystemRule}) passe avant tout ; à
|
|
180
|
+
* défaut, les conventions transverses `:health`/`:stats` retombent sur la
|
|
181
|
+
* politique système par défaut.
|
|
162
182
|
*/
|
|
163
183
|
function matchSystemPolicy(channel, rules) {
|
|
164
|
-
|
|
165
|
-
if (
|
|
166
|
-
return null;
|
|
184
|
+
const rule = matchSystemRule(channel, rules);
|
|
185
|
+
if (rule !== null) return floorReserved(channel, rule.policy);
|
|
186
|
+
return reservedConventionAt(channel) !== null ? SYSTEM_CHANNEL_POLICY : null;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Explique un refus dû à la convention transverse `:health` / `:stats` — la phrase qui nomme le suffixe réservé, le rôle exigé et la surcharge, ou `null` si le refus a une autre cause.
|
|
190
|
+
*
|
|
191
|
+
* Le cas mérite sa phrase parce qu'il SURPREND : le hub ne réserve que l'espace
|
|
192
|
+
* `nodefony:`, et une application qui nomme son canal `net:health` se le voit
|
|
193
|
+
* refuser sans avoir rien déclaré — elle finissait par le renommer à l'aveugle.
|
|
194
|
+
* Les autres refus (namespace de plateforme, politique déclarée) gardent le
|
|
195
|
+
* détail générique du hub : la règle y est écrite par l'application ou nommée
|
|
196
|
+
* par l'espace lui-même.
|
|
197
|
+
*
|
|
198
|
+
* Chemin FROID : appelé seulement après un refus. Ce n'est pas un oracle de
|
|
199
|
+
* plus — la convention est un défaut public du framework, pas une politique de
|
|
200
|
+
* l'application —, et le hub ne la laisse de toute façon partir au client
|
|
201
|
+
* qu'hors production (`deniedDetail`).
|
|
202
|
+
*
|
|
203
|
+
* @param channel - canal refusé.
|
|
204
|
+
* @param token - jeton de la connexion.
|
|
205
|
+
* @param firewall - checker de rôle (hiérarchie comprise).
|
|
206
|
+
* @param rules - règles système effectives (config puis défauts).
|
|
207
|
+
* @returns la phrase, ou `null`.
|
|
208
|
+
*/
|
|
209
|
+
function explainReservedSuffix(channel, token, firewall, rules) {
|
|
210
|
+
if (matchSystemRule(channel, rules) !== null) return null;
|
|
211
|
+
const suffix = reservedConventionAt(channel);
|
|
212
|
+
if (suffix === null) return null;
|
|
213
|
+
if (satisfies(SYSTEM_CHANNEL_POLICY, token, firewall)) return null;
|
|
214
|
+
const colon = channel.indexOf(":");
|
|
215
|
+
const prefix = colon > 0 ? channel.slice(0, colon + 1) : channel;
|
|
216
|
+
return `« ${channel} » porte le suffixe « ${suffix} », que les politiques par défaut de la sécurité réservent à la plateforme dans TOUT espace de noms (${(SYSTEM_CHANNEL_POLICY.roles ?? []).join(" ou ")} exigé). Pour l'ouvrir à l'application, déclare une règle de préfixe dans \`realtimeChannels\` de la configuration de sécurité — par exemple \`{ pattern: "${prefix}", authenticated: true }\` : placée avant les défauts, elle gagne`;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Canal visé par une trame — celui d'un `subscribe`, ou le nom d'un canal
|
|
220
|
+
* entrant — ; `null` pour un `api.request` ou une trame sans nom lisible.
|
|
221
|
+
*/
|
|
222
|
+
function frameChannel(frame) {
|
|
223
|
+
if (typeof frame !== "object" || frame === null || !("method" in frame)) return null;
|
|
224
|
+
const method = frame.method;
|
|
225
|
+
if (method === "subscribe") {
|
|
226
|
+
const params = "params" in frame ? frame.params : void 0;
|
|
227
|
+
const channel = typeof params === "object" && params !== null && "channel" in params ? params.channel : void 0;
|
|
228
|
+
return typeof channel === "string" ? channel : null;
|
|
229
|
+
}
|
|
230
|
+
return typeof method === "string" && method !== "api.request" ? method : null;
|
|
167
231
|
}
|
|
168
232
|
/**
|
|
169
233
|
* F2 (revue 0.6) — applique le PLANCHER irréductible : si `channel` est dans un
|
|
@@ -236,16 +300,35 @@ function authorizeApiRequest(firewall, params, token, onDeny) {
|
|
|
236
300
|
return true;
|
|
237
301
|
}
|
|
238
302
|
/**
|
|
239
|
-
* Verrou `subscribe {channel}
|
|
240
|
-
* politique du
|
|
241
|
-
* politique métier déclarée
|
|
303
|
+
* Verrou `subscribe {channel}`, canaux entrants et actions — applique la
|
|
304
|
+
* politique du nom. PLANCHER système prioritaire (namespace réservé) ; sinon
|
|
305
|
+
* politique métier déclarée ; sinon canal libre.
|
|
306
|
+
*
|
|
307
|
+
* Deux sortes (ADR-0022 §6, décision 1 quater) : `subscribe` exige la LECTURE ;
|
|
308
|
+
* une trame entrante (`writing`) exige la lecture ET l'écriture distincte quand
|
|
309
|
+
* le nom en déclare une. L'écriture n'est jamais moins stricte que la lecture.
|
|
242
310
|
*/
|
|
243
|
-
function authorizeChannel(channel, token, firewall, resolver, systemRules, onDeny) {
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
if (
|
|
247
|
-
|
|
248
|
-
|
|
311
|
+
function authorizeChannel(channel, writing, token, firewall, resolver, systemRules, onDeny) {
|
|
312
|
+
let read = matchSystemPolicy(channel, systemRules);
|
|
313
|
+
let write = null;
|
|
314
|
+
if (read === null && resolver !== null) {
|
|
315
|
+
if (resolver.resolveChannelPolicies !== void 0) {
|
|
316
|
+
const declared = resolver.resolveChannelPolicies(channel);
|
|
317
|
+
if (declared === null) return true;
|
|
318
|
+
read = declared.policy;
|
|
319
|
+
if (writing) write = declared.write;
|
|
320
|
+
} else read = resolver.resolveChannelPolicy?.(channel) ?? null;
|
|
321
|
+
}
|
|
322
|
+
if (read === null) return true;
|
|
323
|
+
if (!satisfies(read, token, firewall)) {
|
|
324
|
+
onDeny?.("channel", channel, "channel_policy", token);
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
if (write !== null && !satisfies(write, token, firewall)) {
|
|
328
|
+
onDeny?.("channel", channel, "channel_write_policy", token);
|
|
329
|
+
return false;
|
|
330
|
+
}
|
|
331
|
+
return true;
|
|
249
332
|
}
|
|
250
333
|
/**
|
|
251
334
|
* Construit le verrou de frame WS branché sur le hub realtime par le firewall au
|
|
@@ -276,7 +359,7 @@ function buildFrameAuthorizer(firewall, options) {
|
|
|
276
359
|
const resolver = options?.channelResolver ?? null;
|
|
277
360
|
const systemRules = options?.systemRules ?? DEFAULT_SYSTEM_RULES;
|
|
278
361
|
const onDeny = options?.onDeny;
|
|
279
|
-
|
|
362
|
+
const authorizer = (frame, token) => {
|
|
280
363
|
if (typeof frame !== "object" || frame === null || !("method" in frame)) return true;
|
|
281
364
|
const method = frame.method;
|
|
282
365
|
if (method === "api.request") return authorizeApiRequest(firewall, "params" in frame ? frame.params : void 0, token, onDeny);
|
|
@@ -284,10 +367,18 @@ function buildFrameAuthorizer(firewall, options) {
|
|
|
284
367
|
const params = "params" in frame ? frame.params : void 0;
|
|
285
368
|
const channel = typeof params === "object" && params !== null && "channel" in params ? params.channel : void 0;
|
|
286
369
|
if (typeof channel !== "string") return true;
|
|
287
|
-
return authorizeChannel(channel, token, firewall, resolver, systemRules, onDeny);
|
|
370
|
+
return authorizeChannel(channel, false, token, firewall, resolver, systemRules, onDeny);
|
|
288
371
|
}
|
|
289
|
-
if (typeof method === "string") return authorizeChannel(method, token, firewall, resolver, systemRules, onDeny);
|
|
372
|
+
if (typeof method === "string") return authorizeChannel(method, true, token, firewall, resolver, systemRules, onDeny);
|
|
290
373
|
return true;
|
|
291
374
|
};
|
|
375
|
+
const explain = (frame, token) => {
|
|
376
|
+
const channel = frameChannel(frame);
|
|
377
|
+
return channel === null ? null : explainReservedSuffix(channel, token, firewall, systemRules);
|
|
378
|
+
};
|
|
379
|
+
return Object.assign(authorizer, {
|
|
380
|
+
judgesWrite: true,
|
|
381
|
+
explain
|
|
382
|
+
});
|
|
292
383
|
}
|
|
293
384
|
export { DEFAULT_SYSTEM_PREFIXES, DEFAULT_SYSTEM_RULES, RESERVED_FLOOR_PREFIXES, SECURITY_CHANNEL_POLICY, SYSTEM_CHANNEL_POLICY, UPLINK_CHANNEL_POLICY, buildFrameAuthorizer, buildFrameAuthorizer as default, buildSystemRules };
|
|
@@ -3,12 +3,32 @@
|
|
|
3
3
|
* Dérive les paramètres effectifs depuis la config sécurité. `issuer` omis →
|
|
4
4
|
* `"nodefony"` (DEVRAIT être surchargé en prod) ; `audiences` vide → l'app est sa
|
|
5
5
|
* propre audience (`[issuer]`).
|
|
6
|
+
*
|
|
7
|
+
* La ressource d'une ZONE (`areas.<zone>.resource`) entre aussi dans la liste,
|
|
8
|
+
* APRÈS les audiences déclarées : c'est une ressource que ce serveur protège, et
|
|
9
|
+
* la redéclarer dans `jwt.audiences` était une seconde écriture de la même
|
|
10
|
+
* valeur — celle qu'on oublie, et dont le seul symptôme est un `invalid_target`
|
|
11
|
+
* à l'émission. Ajoutée en fin de liste, elle ne change jamais l'audience par
|
|
12
|
+
* défaut ({@link defaultAudience}).
|
|
13
|
+
*
|
|
14
|
+
* Elle n'ouvre rien de plus à la VÉRIFICATION : chaque zone, `jwt` comme
|
|
15
|
+
* `external-jwt`, exige SA ressource (l'audience par défaut pour une zone `jwt`
|
|
16
|
+
* qui n'en déclare pas) — un jeton émis pour une zone n'en ouvre pas une autre.
|
|
17
|
+
*
|
|
18
|
+
* @param jwt - section `jwt` de la config validée
|
|
19
|
+
* @param areas - zones du pare-feu ; omises, seules les audiences déclarées comptent
|
|
20
|
+
* @returns les paramètres partagés par l'émetteur et les vérificateurs
|
|
6
21
|
*/
|
|
7
|
-
function resolveJwtRuntime(jwt) {
|
|
22
|
+
function resolveJwtRuntime(jwt, areas) {
|
|
8
23
|
const issuer = jwt.issuer && jwt.issuer.length > 0 ? jwt.issuer : "nodefony";
|
|
24
|
+
const audiences = jwt.audiences.length > 0 ? [...jwt.audiences] : [issuer];
|
|
25
|
+
if (areas !== void 0) for (const name in areas) {
|
|
26
|
+
const resource = areas[name]?.resource;
|
|
27
|
+
if (resource !== void 0 && !audiences.includes(resource)) audiences.push(resource);
|
|
28
|
+
}
|
|
9
29
|
return {
|
|
10
30
|
issuer,
|
|
11
|
-
audiences
|
|
31
|
+
audiences,
|
|
12
32
|
accessTtlS: jwt.accessTtlS,
|
|
13
33
|
refreshTtlS: jwt.refreshTtlS,
|
|
14
34
|
rotateRefresh: jwt.rotateRefresh,
|
|
@@ -1,101 +1,52 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { isIP } from "node:net";
|
|
4
|
-
import { request as request$1 } from "node:https";
|
|
1
|
+
import { fetchPublic } from "../net/fetchPublic.js";
|
|
2
|
+
import { FetchPublicError } from "../../errors/FetchPublicError.js";
|
|
5
3
|
// nodefony/src/webhook/webhookDelivery.ts
|
|
6
4
|
/**
|
|
7
|
-
* Émetteur HTTP d'une livraison webhook —
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* 1. **Pas de suivi de redirection** : `node:http(s)` ne suit JAMAIS les 3xx
|
|
11
|
-
* (contrairement à `fetch`) → un `302 → 169.254.169.254` ne contourne pas le
|
|
12
|
-
* contrôle SSRF ; le 3xx est rendu tel quel (échec de config).
|
|
13
|
-
* 2. **Pin de l'IP validée** (anti DNS-rebinding) : `lookup` force la connexion TCP
|
|
14
|
-
* vers l'IP déjà contrôlée par `assertPublicUrl`, tout en gardant le hostname
|
|
15
|
-
* pour le SNI/TLS et l'en-tête `Host` → pas de re-résolution entre contrôle et
|
|
16
|
-
* connexion.
|
|
17
|
-
* 3. **Timeout dur** : la requête est détruite au-delà du délai.
|
|
5
|
+
* Émetteur HTTP d'une livraison webhook — délègue à {@link fetchPublic}, la
|
|
6
|
+
* primitive unique des requêtes sortantes : pas de suivi de redirection, IP
|
|
7
|
+
* vérifiée épinglée (anti DNS-rebinding), délai total, lecture bornée.
|
|
18
8
|
*/
|
|
19
9
|
/** Taille max du corps de réponse capturé (observabilité, anti-mémoire). */
|
|
20
10
|
const RESPONSE_BODY_CAP = 2048;
|
|
21
11
|
/**
|
|
22
|
-
* POST le corps signé vers l'URL,
|
|
23
|
-
*
|
|
12
|
+
* POST le corps signé vers l'URL, par {@link fetchPublic} : IP vérifiée épinglée,
|
|
13
|
+
* aucune redirection suivie, délai TOTAL (un destinataire qui goutte un octet
|
|
14
|
+
* ne retient pas la livraison) et lecture du corps coupée à
|
|
15
|
+
* {@link RESPONSE_BODY_CAP} (connexion détruite, pas drainée). Ne lève jamais :
|
|
16
|
+
* tout échec est encodé dans {@link IDeliveryResult}.
|
|
24
17
|
*/
|
|
25
|
-
function deliverWebhook(url, body, headers, opts) {
|
|
26
|
-
let u;
|
|
18
|
+
async function deliverWebhook(url, body, headers, opts) {
|
|
27
19
|
try {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
20
|
+
const vetted = opts.addresses !== void 0 && opts.addresses.length > 0;
|
|
21
|
+
const r = await fetchPublic(url, {
|
|
22
|
+
method: "POST",
|
|
23
|
+
headers,
|
|
24
|
+
body,
|
|
25
|
+
maxBytes: RESPONSE_BODY_CAP,
|
|
26
|
+
timeoutMs: opts.timeoutMs,
|
|
27
|
+
allowHttp: opts.allowHttp === true,
|
|
28
|
+
allowPrivate: !vetted,
|
|
29
|
+
...vetted ? { vettedAddresses: opts.addresses } : {}
|
|
30
|
+
});
|
|
31
|
+
const ok = r.status >= 200 && r.status < 300;
|
|
32
|
+
const text = r.body.length > 0 ? new TextDecoder().decode(r.body) : "";
|
|
33
|
+
return {
|
|
34
|
+
ok,
|
|
35
|
+
status: r.status,
|
|
36
|
+
error: ok ? null : `HTTP ${r.status}`,
|
|
37
|
+
responseBody: text.length > 0 ? text : null
|
|
38
|
+
};
|
|
39
|
+
} catch (e) {
|
|
40
|
+
if (e instanceof FetchPublicError && e.reason === "timeout") return {
|
|
31
41
|
ok: false,
|
|
32
42
|
status: null,
|
|
33
|
-
error: "
|
|
34
|
-
});
|
|
35
|
-
}
|
|
36
|
-
const isHttps = u.protocol === "https:";
|
|
37
|
-
if (!isHttps && !(u.protocol === "http:" && opts.allowHttp)) return Promise.resolve({
|
|
38
|
-
ok: false,
|
|
39
|
-
status: null,
|
|
40
|
-
error: `protocole ${u.protocol} non autorisé`
|
|
41
|
-
});
|
|
42
|
-
const pinned = opts.addresses && opts.addresses.length > 0 ? opts.addresses[0] : null;
|
|
43
|
-
const lookup = pinned ? (_hostname, options, callback) => {
|
|
44
|
-
const family = isIP(pinned) === 6 ? 6 : 4;
|
|
45
|
-
if (options.all) callback(null, [{
|
|
46
|
-
address: pinned,
|
|
47
|
-
family
|
|
48
|
-
}], family);
|
|
49
|
-
else callback(null, pinned, family);
|
|
50
|
-
} : void 0;
|
|
51
|
-
const payload = Buffer.from(body, "utf8");
|
|
52
|
-
const requestFn = isHttps ? request$1 : request;
|
|
53
|
-
return new Promise((resolve) => {
|
|
54
|
-
let settled = false;
|
|
55
|
-
const done = (r) => {
|
|
56
|
-
if (!settled) {
|
|
57
|
-
settled = true;
|
|
58
|
-
resolve(r);
|
|
59
|
-
}
|
|
43
|
+
error: "timeout"
|
|
60
44
|
};
|
|
61
|
-
|
|
62
|
-
method: "POST",
|
|
63
|
-
headers: {
|
|
64
|
-
...headers,
|
|
65
|
-
"content-length": String(payload.length)
|
|
66
|
-
},
|
|
67
|
-
lookup
|
|
68
|
-
}, (res) => {
|
|
69
|
-
const status = res.statusCode ?? 0;
|
|
70
|
-
const ok = status >= 200 && status < 300;
|
|
71
|
-
let resBody = "";
|
|
72
|
-
res.on("data", (c) => {
|
|
73
|
-
if (resBody.length < RESPONSE_BODY_CAP) {
|
|
74
|
-
resBody += c.toString("utf8");
|
|
75
|
-
if (resBody.length > RESPONSE_BODY_CAP) resBody = resBody.slice(0, RESPONSE_BODY_CAP);
|
|
76
|
-
}
|
|
77
|
-
});
|
|
78
|
-
res.on("end", () => done({
|
|
79
|
-
ok,
|
|
80
|
-
status,
|
|
81
|
-
error: ok ? null : `HTTP ${status}`,
|
|
82
|
-
responseBody: resBody.length > 0 ? resBody : null
|
|
83
|
-
}));
|
|
84
|
-
res.on("error", (e) => done({
|
|
85
|
-
ok: false,
|
|
86
|
-
status,
|
|
87
|
-
error: e.message,
|
|
88
|
-
responseBody: resBody.length > 0 ? resBody : null
|
|
89
|
-
}));
|
|
90
|
-
});
|
|
91
|
-
req.setTimeout(opts.timeoutMs, () => req.destroy(/* @__PURE__ */ new Error("timeout")));
|
|
92
|
-
req.on("error", (e) => done({
|
|
45
|
+
return {
|
|
93
46
|
ok: false,
|
|
94
47
|
status: null,
|
|
95
|
-
error: e.message
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
req.end();
|
|
99
|
-
});
|
|
48
|
+
error: e instanceof Error ? e.message : String(e)
|
|
49
|
+
};
|
|
50
|
+
}
|
|
100
51
|
}
|
|
101
52
|
export { deliverWebhook };
|
package/dist/types/index.d.ts
CHANGED
|
@@ -172,6 +172,8 @@ export type { IWebhookPayload } from "./nodefony/contracts/IWebhookPayload.js";
|
|
|
172
172
|
export type { IWebhookListQuery, IWebhookStore, } from "./nodefony/contracts/IWebhookStore.js";
|
|
173
173
|
export { assertPublicUrl, isBlockedAddress, } from "./nodefony/src/net/ssrfGuard.js";
|
|
174
174
|
export type { IAssertPublicUrlOptions, IPublicUrlResult, DnsResolver, } from "./nodefony/src/net/ssrfGuard.js";
|
|
175
|
+
export { fetchPublic } from "./nodefony/src/net/fetchPublic.js";
|
|
176
|
+
export type { IFetchPublicOptions, IFetchPublicResult, } from "./nodefony/src/net/fetchPublic.js";
|
|
175
177
|
export { deriveKey, encryptSecret, decryptSecret, generateEphemeralKey, } from "./nodefony/src/crypto/secretCipher.js";
|
|
176
178
|
export type { IKeyDerivation } from "./nodefony/src/crypto/secretCipher.js";
|
|
177
179
|
export { defineSecurityConfig, securityConfigJsonSchema, } from "./nodefony/config/defineModuleConfig.js";
|
|
@@ -180,6 +182,7 @@ export { LoginThrottler } from "./nodefony/src/throttle/LoginThrottler.js";
|
|
|
180
182
|
export type { ILoginThrottleOptions } from "./nodefony/src/throttle/LoginThrottler.js";
|
|
181
183
|
export { CredentialTransportPolicy, shouldEnforceCredentialTransport, } from "./nodefony/src/transport/CredentialTransportPolicy.js";
|
|
182
184
|
export type { ICredentialTransportContext, ICredentialTransportPolicyOptions, } from "./nodefony/src/transport/CredentialTransportPolicy.js";
|
|
183
|
-
export { AuthenticationError, AccessDeniedError, ThrottledError, UnverifiableTokenError, InvalidTargetError, CsrfError, SsrfError, InsecureTransportError, } from "./nodefony/errors/index.js";
|
|
185
|
+
export { AuthenticationError, AccessDeniedError, ThrottledError, UnverifiableTokenError, InvalidTargetError, CsrfError, SsrfError, FetchPublicError, InsecureTransportError, } from "./nodefony/errors/index.js";
|
|
186
|
+
export type { FetchPublicFailure } from "./nodefony/errors/index.js";
|
|
184
187
|
export { VoterVote } from "./nodefony/contracts/index.js";
|
|
185
188
|
export type { IToken, IAuthenticator, ISecuredArea, IFirewall, IAccessVoter, IAuthorizationService, IAccessTokenRecord, ITokenStore, ITokenListQuery, ITokenUsage, IResourcePermission, TokenRevokeReason, TokenStatus, IJwtKeystore, IJwtSigningKey, } from "./nodefony/contracts/index.js";
|