@elding/cli 0.3.0 → 0.8.0

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.
@@ -5,51 +5,149 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.startProxy = startProxy;
7
7
  const http_1 = __importDefault(require("http"));
8
+ const https_1 = __importDefault(require("https"));
9
+ const net_1 = __importDefault(require("net"));
10
+ const dns_1 = require("dns");
8
11
  const crypto_1 = require("crypto");
9
12
  const PLACEHOLDER = /\{\{([A-Z0-9_]+)\}\}/g;
10
- // Bloque loopback + plages privées + métadata cloud — empêche le proxy de servir de pivot SSRF
11
- function isBlockedHost(hostname) {
12
- const h = hostname.replace(/^\[|\]$/g, "");
13
- if (h === "localhost" || h === "::1")
14
- return true;
15
- if (/^127\./.test(h))
16
- return true;
17
- if (/^10\./.test(h))
18
- return true;
19
- if (/^192\.168\./.test(h))
20
- return true;
21
- if (/^172\.(1[6-9]|2\d|3[01])\./.test(h))
22
- return true;
23
- if (/^169\.254\./.test(h))
24
- return true; // link-local + metadata (169.254.169.254)
25
- if (/^fe80:/i.test(h) || /^fc00:/i.test(h) || /^fd/i.test(h))
13
+ const PROXY_TIMEOUT_MS = 60_000;
14
+ const MAX_HOST_LENGTH = 253;
15
+ const HOP_BY_HOP_HEADERS = new Set([
16
+ "connection",
17
+ "content-length",
18
+ "keep-alive",
19
+ "proxy-authenticate",
20
+ "proxy-authorization",
21
+ "proxy-connection",
22
+ "te",
23
+ "trailer",
24
+ "transfer-encoding",
25
+ "upgrade",
26
+ ]);
27
+ // Valide une IP résolue : bloque loopback + plages privées + link-local + ULA + métadata cloud.
28
+ function isBlockedIp(ip) {
29
+ const normalized = stripBrackets(ip).toLowerCase();
30
+ const v4 = ipv4FromMappedIpv6(normalized) ?? normalized;
31
+ if (net_1.default.isIPv4(v4)) {
32
+ const [a, b, c] = v4.split(".").map(Number);
33
+ if (a === 0 || a === 10 || a === 127)
34
+ return true;
35
+ if (a === 100 && b >= 64 && b <= 127)
36
+ return true; // carrier-grade NAT
37
+ if (a === 192 && b === 168)
38
+ return true;
39
+ if (a === 192 && b === 0)
40
+ return true;
41
+ if (a === 172 && b >= 16 && b <= 31)
42
+ return true;
43
+ if (a === 169 && b === 254)
44
+ return true; // link-local + metadata 169.254.169.254
45
+ if (a === 198 && (b === 18 || b === 19))
46
+ return true; // benchmarking
47
+ if (a === 192 && b === 0 && c === 2)
48
+ return true;
49
+ if (a === 198 && b === 51 && c === 100)
50
+ return true;
51
+ if (a === 203 && b === 0 && c === 113)
52
+ return true;
53
+ if (a >= 224)
54
+ return true; // multicast + reserved
55
+ return false;
56
+ }
57
+ const l = normalized;
58
+ if (l === "::1" || l === "::")
26
59
  return true;
60
+ if (l.startsWith("fe8") || l.startsWith("fe9") || l.startsWith("fea") || l.startsWith("feb"))
61
+ return true; // fe80::/10
62
+ if (l.startsWith("fc") || l.startsWith("fd"))
63
+ return true; // ULA
64
+ if (l.startsWith("ff"))
65
+ return true; // multicast
66
+ if (l.startsWith("2001:db8"))
67
+ return true; // documentation
27
68
  return false;
28
69
  }
70
+ function ipv4FromMappedIpv6(ip) {
71
+ if (!ip.startsWith("::ffff:"))
72
+ return null;
73
+ const tail = ip.slice("::ffff:".length);
74
+ if (net_1.default.isIPv4(tail))
75
+ return tail;
76
+ const parts = tail.split(":");
77
+ if (parts.length !== 2)
78
+ return null;
79
+ const high = Number.parseInt(parts[0], 16);
80
+ const low = Number.parseInt(parts[1], 16);
81
+ if (!Number.isInteger(high) || !Number.isInteger(low) || high < 0 || high > 0xffff || low < 0 || low > 0xffff) {
82
+ return null;
83
+ }
84
+ return `${(high >> 8) & 255}.${high & 255}.${(low >> 8) & 255}.${low & 255}`;
85
+ }
86
+ function stripBrackets(hostname) {
87
+ return hostname.startsWith("[") && hostname.endsWith("]")
88
+ ? hostname.slice(1, -1)
89
+ : hostname;
90
+ }
91
+ function normalizeHostname(hostname) {
92
+ return stripBrackets(hostname).toLowerCase().replace(/\.$/, "");
93
+ }
94
+ // Résout le hostname et exige que TOUTES les adresses soient publiques.
95
+ async function resolvePublicAddresses(hostname) {
96
+ const normalized = normalizeHostname(hostname);
97
+ if (!normalized || normalized.length > MAX_HOST_LENGTH || normalized === "localhost")
98
+ return [];
99
+ const directIp = net_1.default.isIP(normalized);
100
+ if (directIp)
101
+ return isBlockedIp(normalized) ? [] : [{ address: normalized, family: directIp }];
102
+ try {
103
+ const addrs = await dns_1.promises.lookup(normalized, { all: true, verbatim: true });
104
+ if (addrs.length === 0 || addrs.some((a) => isBlockedIp(a.address)))
105
+ return [];
106
+ return addrs.map((a) => ({ address: a.address, family: a.family }));
107
+ }
108
+ catch {
109
+ return [];
110
+ }
111
+ }
112
+ // Comparaison constant-time du token de session.
113
+ function tokenMatches(provided, expected) {
114
+ if (typeof provided !== "string" || provided.length !== expected.length)
115
+ return false;
116
+ return (0, crypto_1.timingSafeEqual)(Buffer.from(provided), Buffer.from(expected));
117
+ }
29
118
  // hosts: map nom_secret -> domaine autorisé. Si défini, le secret ne peut être envoyé qu'à ce host.
30
- function startProxy(secrets, hosts = {}, verbose = false) {
119
+ function startProxy(secrets, hosts = {}, verbose = false, onLog) {
31
120
  const token = (0, crypto_1.randomBytes)(24).toString("hex");
32
121
  // Log une ligne par requête — jamais les valeurs, seulement les placeholders référencés
33
- const log = (method, host, path, status, ms, note = "") => {
34
- if (!verbose)
35
- return;
36
- const code = status === "BLOCKED" ? "BLOCKED" : String(status);
37
- console.error(`[elding] ${method} ${host}${path} → ${code} ${ms}ms${note ? " " + note : ""}`);
122
+ const log = (method, host, path, status, ms, note = "", secretNames = "") => {
123
+ const blocked = status === "BLOCKED";
124
+ const code = blocked ? "BLOCKED" : String(status);
125
+ if (verbose)
126
+ console.error(`[elding] ${method} ${host}${path} → ${code} ${ms}ms${note ? " " + note : ""}`);
127
+ onLog?.({ method, host, path, status: blocked ? 403 : status, latencyMs: ms, blocked, secretNames });
38
128
  };
39
129
  const placeholdersIn = (headers) => {
40
130
  const names = new Set();
41
- for (const v of Object.values(headers))
42
- for (const m of v.matchAll(PLACEHOLDER))
43
- names.add(m[1]);
131
+ for (const v of Object.values(headers)) {
132
+ const values = Array.isArray(v) ? v : [v];
133
+ for (const item of values)
134
+ for (const m of item.matchAll(PLACEHOLDER))
135
+ names.add(m[1]);
136
+ }
44
137
  return names.size ? `{{${[...names].join(",")}}}` : "";
45
138
  };
46
- // Vérifie que chaque placeholder utilisé est autorisé pour ce host. Retourne le nom fautif, ou null.
139
+ // Vérifie que chaque placeholder utilisé est autorisé pour ce host. Retourne une raison de blocage, ou null.
47
140
  const findHostViolation = (val, targetHost) => {
48
141
  for (const m of val.matchAll(PLACEHOLDER)) {
49
142
  const name = m[1];
143
+ if (!Object.prototype.hasOwnProperty.call(secrets, name))
144
+ return `${name} inconnu`;
50
145
  const allowed = hosts[name];
51
- if (allowed && allowed !== targetHost)
52
- return name;
146
+ if (!allowed)
147
+ return `${name} sans domaine autorise`;
148
+ const allowedHost = normalizeAllowedHost(allowed);
149
+ if (!allowedHost || allowedHost !== targetHost)
150
+ return `${name} non autorise`;
53
151
  }
54
152
  return null;
55
153
  };
@@ -57,7 +155,7 @@ function startProxy(secrets, hosts = {}, verbose = false) {
57
155
  const server = http_1.default.createServer(async (req, res) => {
58
156
  const started = Date.now();
59
157
  try {
60
- if (req.headers["x-elding-token"] !== token) {
158
+ if (!tokenMatches(req.headers["x-elding-token"], token)) {
61
159
  res.writeHead(401);
62
160
  res.end("unauthorized");
63
161
  return;
@@ -82,24 +180,33 @@ function startProxy(secrets, hosts = {}, verbose = false) {
82
180
  res.end("https only");
83
181
  return;
84
182
  }
85
- if (isBlockedHost(targetUrl.hostname)) {
86
- log(req.method ?? "?", targetUrl.hostname, req.url ?? "/", "BLOCKED", Date.now() - started, "host bloqué");
183
+ if (targetUrl.username || targetUrl.password) {
184
+ res.writeHead(400);
185
+ res.end("credentials not allowed");
186
+ return;
187
+ }
188
+ const targetHost = normalizeHostname(targetUrl.hostname);
189
+ const resolved = await resolvePublicAddresses(targetHost);
190
+ if (resolved.length === 0) {
191
+ log(req.method ?? "?", targetHost, req.url ?? "/", "BLOCKED", Date.now() - started, "host bloqué");
87
192
  res.writeHead(403);
88
193
  res.end("blocked host");
89
194
  return;
90
195
  }
196
+ const pinned = resolved[0];
91
197
  const upstream = new URL(req.url ?? "/", targetUrl);
92
198
  upstream.protocol = "https:";
93
199
  upstream.host = targetUrl.host;
94
200
  // Enforce host binding : un secret lié à un domaine ne peut partir que vers celui-ci
95
201
  for (const v of Object.values(req.headers)) {
96
- if (typeof v !== "string")
97
- continue;
98
- const bad = findHostViolation(v, targetUrl.hostname);
99
- if (bad) {
100
- log(req.method ?? "?", targetUrl.hostname, upstream.pathname, "BLOCKED", Date.now() - started, `${bad} non autorisé`);
202
+ const values = Array.isArray(v) ? v : typeof v === "string" ? [v] : [];
203
+ for (const item of values) {
204
+ const bad = findHostViolation(item, targetHost);
205
+ if (!bad)
206
+ continue;
207
+ log(req.method ?? "?", targetHost, upstream.pathname, "BLOCKED", Date.now() - started, bad);
101
208
  res.writeHead(403);
102
- res.end(`secret "${bad}" non autorisé pour ${targetUrl.hostname}`);
209
+ res.end(`secret bloque: ${bad}`);
103
210
  return;
104
211
  }
105
212
  }
@@ -109,34 +216,22 @@ function startProxy(secrets, hosts = {}, verbose = false) {
109
216
  const lk = k.toLowerCase();
110
217
  if (lk.startsWith("x-elding-"))
111
218
  continue;
112
- if (lk === "host" || lk === "connection" || lk === "content-length")
219
+ if (HOP_BY_HOP_HEADERS.has(lk))
113
220
  continue;
114
221
  if (typeof v === "string") {
115
222
  rawHeaders[k] = v;
116
223
  headers[k] = substitute(v);
117
224
  }
118
- }
119
- const chunks = [];
120
- for await (const c of req)
121
- chunks.push(c);
122
- const hasBody = chunks.length > 0 && req.method !== "GET" && req.method !== "HEAD";
123
- const upstreamRes = await fetch(upstream.toString(), {
124
- method: req.method,
125
- headers,
126
- body: hasBody ? Buffer.concat(chunks) : undefined,
127
- });
128
- log(req.method ?? "?", targetUrl.hostname, upstream.pathname, upstreamRes.status, Date.now() - started, placeholdersIn(rawHeaders));
129
- res.writeHead(upstreamRes.status, Object.fromEntries(upstreamRes.headers));
130
- if (upstreamRes.body) {
131
- const reader = upstreamRes.body.getReader();
132
- for (;;) {
133
- const { done, value } = await reader.read();
134
- if (done)
135
- break;
136
- res.write(value);
225
+ else if (Array.isArray(v)) {
226
+ rawHeaders[k] = v;
227
+ headers[k] = v.map(substitute);
137
228
  }
138
229
  }
139
- res.end();
230
+ // Streame le corps vers l'upstream sans le bufferiser en mémoire (évite un DoS sur gros upload)
231
+ const hasBody = req.method !== "GET" && req.method !== "HEAD";
232
+ const used = placeholdersIn(rawHeaders);
233
+ const status = await forwardHttps(req, res, upstream, headers, pinned, hasBody);
234
+ log(req.method ?? "?", targetHost, upstream.pathname, status, Date.now() - started, used, used);
140
235
  }
141
236
  catch {
142
237
  if (!res.headersSent)
@@ -152,3 +247,58 @@ function startProxy(secrets, hosts = {}, verbose = false) {
152
247
  });
153
248
  });
154
249
  }
250
+ function normalizeAllowedHost(value) {
251
+ const raw = value.trim();
252
+ if (!raw || raw.length > MAX_HOST_LENGTH + 8)
253
+ return null;
254
+ try {
255
+ const url = new URL(raw.includes("://") ? raw : `https://${raw}`);
256
+ if (url.username || url.password)
257
+ return null;
258
+ return normalizeHostname(url.hostname);
259
+ }
260
+ catch {
261
+ return null;
262
+ }
263
+ }
264
+ function filterResponseHeaders(headers) {
265
+ const out = {};
266
+ for (const [name, value] of Object.entries(headers)) {
267
+ if (HOP_BY_HOP_HEADERS.has(name.toLowerCase()))
268
+ continue;
269
+ out[name] = value;
270
+ }
271
+ return out;
272
+ }
273
+ function forwardHttps(req, res, upstream, headers, pinned, hasBody) {
274
+ return new Promise((resolve, reject) => {
275
+ const upstreamHostname = normalizeHostname(upstream.hostname);
276
+ const upstreamReq = https_1.default.request({
277
+ protocol: "https:",
278
+ hostname: upstreamHostname,
279
+ port: upstream.port ? Number(upstream.port) : 443,
280
+ path: `${upstream.pathname}${upstream.search}`,
281
+ method: req.method,
282
+ headers: { ...headers, host: upstream.host },
283
+ servername: net_1.default.isIP(upstreamHostname) ? undefined : upstreamHostname,
284
+ timeout: PROXY_TIMEOUT_MS,
285
+ lookup: (_hostname, _options, callback) => {
286
+ callback(null, pinned.address, pinned.family);
287
+ },
288
+ }, (upstreamRes) => {
289
+ const status = upstreamRes.statusCode ?? 502;
290
+ res.writeHead(status, filterResponseHeaders(upstreamRes.headers));
291
+ upstreamRes.pipe(res);
292
+ upstreamRes.on("end", () => resolve(status));
293
+ upstreamRes.on("error", reject);
294
+ });
295
+ upstreamReq.on("timeout", () => upstreamReq.destroy(new Error("upstream timeout")));
296
+ upstreamReq.on("error", reject);
297
+ req.on("error", reject);
298
+ res.on("close", () => upstreamReq.destroy());
299
+ if (hasBody)
300
+ req.pipe(upstreamReq);
301
+ else
302
+ upstreamReq.end();
303
+ });
304
+ }
@@ -0,0 +1,2 @@
1
+ export declare function safeText(value: unknown, maxLength?: number): string;
2
+ export declare function safeError(value: unknown): string;
@@ -0,0 +1,15 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.safeText = safeText;
4
+ exports.safeError = safeError;
5
+ const ANSI_PATTERN = /[\u001b\u009b][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[a-zA-Z\d]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g;
6
+ const CONTROL_PATTERN = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g;
7
+ function safeText(value, maxLength = 500) {
8
+ return String(value ?? "")
9
+ .replace(ANSI_PATTERN, "")
10
+ .replace(CONTROL_PATTERN, "")
11
+ .slice(0, maxLength);
12
+ }
13
+ function safeError(value) {
14
+ return safeText(value instanceof Error ? value.message : value, 300) || "Erreur inconnue";
15
+ }
@@ -0,0 +1,2 @@
1
+ import { type ProjectConfig } from "./config.js";
2
+ export declare function ensureProjectTrusted(project: ProjectConfig): Promise<void>;
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.ensureProjectTrusted = ensureProjectTrusted;
7
+ const promises_1 = __importDefault(require("readline/promises"));
8
+ const process_1 = require("process");
9
+ const config_js_1 = require("./config.js");
10
+ const terminal_js_1 = require("./terminal.js");
11
+ async function ensureProjectTrusted(project) {
12
+ if ((0, config_js_1.isProjectTrusted)(project))
13
+ return;
14
+ const location = (0, config_js_1.projectTrustKey)();
15
+ const target = `${(0, terminal_js_1.safeText)(project.setName)} (${(0, terminal_js_1.safeText)(project.setId)})`;
16
+ const workspace = project.workspaceName ? ` / ${(0, terminal_js_1.safeText)(project.workspaceName)}` : "";
17
+ if (!process_1.stdin.isTTY || !process_1.stdout.isTTY) {
18
+ throw new Error(`Projet non approuve pour le set ${target}${workspace}. Lancez \`elding init\` ou \`elding use <set>\` interactivement.`);
19
+ }
20
+ const rl = promises_1.default.createInterface({ input: process_1.stdin, output: process_1.stdout });
21
+ try {
22
+ console.log(`Projet : ${(0, terminal_js_1.safeText)(location)}`);
23
+ console.log(`Set Elding : ${target}${workspace}`);
24
+ const answer = await rl.question("Approuver ce projet pour ce set ? [y/N] ");
25
+ if (!/^y(es)?$/i.test(answer.trim())) {
26
+ throw new Error("Projet non approuve.");
27
+ }
28
+ (0, config_js_1.trustProject)(project);
29
+ }
30
+ finally {
31
+ rl.close();
32
+ }
33
+ }
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@elding/cli",
3
- "version": "0.3.0",
3
+ "version": "0.8.0",
4
4
  "description": "Elding CLI — zero .env, secrets from vault",
5
5
  "bin": {
6
- "elding": "./dist/index.js"
6
+ "elding": "dist/index.js"
7
7
  },
8
8
  "main": "./dist/index.js",
9
9
  "files": [
@@ -15,19 +15,20 @@
15
15
  "scripts": {
16
16
  "build": "tsc",
17
17
  "dev": "tsc --watch",
18
+ "test": "node --test test/*.test.mjs",
18
19
  "prepublishOnly": "npm run build"
19
20
  },
20
21
  "dependencies": {
21
- "commander": "^12.1.0",
22
- "open": "^10.1.0",
23
- "ora": "^8.1.1",
24
- "chalk": "^5.3.0",
25
- "inquirer": "^10.2.2"
22
+ "@napi-rs/keyring": "1.1.7",
23
+ "chalk": "5.6.2",
24
+ "commander": "12.1.0",
25
+ "inquirer": "14.0.2",
26
+ "open": "10.2.0",
27
+ "ora": "8.2.0"
26
28
  },
27
29
  "devDependencies": {
28
- "@types/node": "^22.0.0",
29
- "@types/inquirer": "^9.0.7",
30
- "typescript": "^5.5.0"
30
+ "@types/node": "22.19.21",
31
+ "typescript": "5.9.3"
31
32
  },
32
33
  "engines": {
33
34
  "node": ">=18"