@hostwebhook/node-sdk 0.1.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.
- package/dist/code-runner.d.ts +20 -0
- package/dist/code-runner.js +138 -0
- package/dist/contratos.d.ts +121 -0
- package/dist/contratos.js +24 -0
- package/dist/dto/output-node.dto.d.ts +19 -0
- package/dist/dto/output-node.dto.js +96 -0
- package/dist/ensure-meta.d.ts +22 -0
- package/dist/ensure-meta.js +35 -0
- package/dist/execute-with-iteration.d.ts +18 -0
- package/dist/execute-with-iteration.js +66 -0
- package/dist/filter-utils.d.ts +22 -0
- package/dist/filter-utils.js +178 -0
- package/dist/handler-helpers.d.ts +21 -0
- package/dist/handler-helpers.js +53 -0
- package/dist/index.d.ts +51 -0
- package/dist/index.js +73 -0
- package/dist/log-metadata.d.ts +191 -0
- package/dist/log-metadata.js +375 -0
- package/dist/node-dispatch.registry.d.ts +32 -0
- package/dist/node-dispatch.registry.js +45 -0
- package/dist/node-executors.d.ts +299 -0
- package/dist/node-executors.js +555 -0
- package/dist/node-lifecycle.d.ts +399 -0
- package/dist/node-lifecycle.js +782 -0
- package/dist/normalize-nodes.d.ts +18 -0
- package/dist/normalize-nodes.js +22 -0
- package/dist/output-node-ref.schema.d.ts +82 -0
- package/dist/output-node-ref.schema.js +90 -0
- package/dist/output-webhook-scope.d.ts +36 -0
- package/dist/output-webhook-scope.js +42 -0
- package/dist/payload-preview.d.ts +10 -0
- package/dist/payload-preview.js +39 -0
- package/dist/pipeline.constants.d.ts +29 -0
- package/dist/pipeline.constants.js +51 -0
- package/dist/pre-request-pool.d.ts +58 -0
- package/dist/pre-request-pool.js +308 -0
- package/dist/pre-request-runner-source.d.ts +28 -0
- package/dist/pre-request-runner-source.js +411 -0
- package/dist/regex-de-inquilino.d.ts +15 -0
- package/dist/regex-de-inquilino.js +98 -0
- package/dist/request-context.d.ts +18 -0
- package/dist/request-context.js +34 -0
- package/dist/retry-transient.d.ts +54 -0
- package/dist/retry-transient.js +67 -0
- package/dist/retry-utils.d.ts +17 -0
- package/dist/retry-utils.js +23 -0
- package/dist/schema-validator-utils.d.ts +9 -0
- package/dist/schema-validator-utils.js +140 -0
- package/dist/ssrf-guard.d.ts +202 -0
- package/dist/ssrf-guard.js +917 -0
- package/dist/swallow.d.ts +52 -0
- package/dist/swallow.js +55 -0
- package/dist/template-render.d.ts +33 -0
- package/dist/template-render.js +43 -0
- package/dist/try-parse.d.ts +41 -0
- package/dist/try-parse.js +69 -0
- package/dist/workspace-payloads.d.ts +66 -0
- package/dist/workspace-payloads.js +496 -0
- package/package.json +35 -0
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.permissionTier = permissionTier;
|
|
4
|
+
exports.refillPool = refillPool;
|
|
5
|
+
exports.runInChild = runInChild;
|
|
6
|
+
exports.runCodeNodeInChild = runCodeNodeInChild;
|
|
7
|
+
exports.shutdownPreRequestPool = shutdownPreRequestPool;
|
|
8
|
+
/**
|
|
9
|
+
* Runs tenant pre-request scripts in a separate, disposable Node process.
|
|
10
|
+
*
|
|
11
|
+
* Why a process and not a better sandbox: `node:vm` is not a security
|
|
12
|
+
* boundary, and making it into one means replacing every intrinsic the script
|
|
13
|
+
* touches — Buffer, URL, crypto — with a shim of our own. Those shims then
|
|
14
|
+
* have to reproduce Node's behaviour exactly, forever, and they never quite
|
|
15
|
+
* do. Moving the script into a process that holds nothing worth stealing lets
|
|
16
|
+
* us hand it the real intrinsics instead, so a script computes exactly what it
|
|
17
|
+
* computed before, and breaking out of the vm buys the attacker an empty
|
|
18
|
+
* environment with no filesystem.
|
|
19
|
+
*
|
|
20
|
+
* Each execution gets a child that has never run anyone else's script and is
|
|
21
|
+
* killed afterwards, so one tenant cannot leave anything behind — a patched
|
|
22
|
+
* prototype, a hook — for the next tenant's script to find. To keep that off
|
|
23
|
+
* the request path, spare children are started ahead of time: taking a warm
|
|
24
|
+
* one costs well under a millisecond, while starting one costs ~60 ms in the
|
|
25
|
+
* production image.
|
|
26
|
+
*/
|
|
27
|
+
const child_process_1 = require("child_process");
|
|
28
|
+
const pre_request_runner_source_1 = require("./pre-request-runner-source");
|
|
29
|
+
/** How long the child is allowed to keep the parent waiting past the vm's own timeout. */
|
|
30
|
+
const DEADLINE_GRACE_MS = 3_000;
|
|
31
|
+
/** How long a freshly started child has to report itself ready. */
|
|
32
|
+
const READY_TIMEOUT_MS = 15_000;
|
|
33
|
+
function poolSize() {
|
|
34
|
+
const raw = Number(process.env.PRE_REQUEST_POOL_SIZE);
|
|
35
|
+
return Number.isFinite(raw) && raw >= 0 ? Math.floor(raw) : 2;
|
|
36
|
+
}
|
|
37
|
+
function heapLimitMb() {
|
|
38
|
+
const raw = Number(process.env.PRE_REQUEST_HEAP_MB);
|
|
39
|
+
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 256;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The permission model's flag was renamed between the Node this runs on in
|
|
43
|
+
* development (22, `--permission`) and the one the image ships (20,
|
|
44
|
+
* `--experimental-permission`, where `--permission` is a fatal `bad option`).
|
|
45
|
+
* Probing rather than checking a version number means a future rename cannot
|
|
46
|
+
* silently take every child down with it.
|
|
47
|
+
*
|
|
48
|
+
* The empty candidate is the degraded tier: no permission model, but the child
|
|
49
|
+
* is still a separate process with an empty environment, which is what removes
|
|
50
|
+
* the secrets the finding was about.
|
|
51
|
+
*/
|
|
52
|
+
const FLAG_CANDIDATES = [
|
|
53
|
+
['--permission'],
|
|
54
|
+
['--experimental-permission'],
|
|
55
|
+
[],
|
|
56
|
+
];
|
|
57
|
+
let resolvedFlags = null;
|
|
58
|
+
let resolvedTier = null;
|
|
59
|
+
let spawnUsable = true;
|
|
60
|
+
function resolveFlags() {
|
|
61
|
+
if (resolvedFlags)
|
|
62
|
+
return resolvedFlags;
|
|
63
|
+
for (const candidate of FLAG_CANDIDATES) {
|
|
64
|
+
const probe = (0, child_process_1.spawnSync)(process.execPath, [...candidate, '-e', '0'], {
|
|
65
|
+
env: {},
|
|
66
|
+
timeout: READY_TIMEOUT_MS,
|
|
67
|
+
windowsHide: true,
|
|
68
|
+
});
|
|
69
|
+
if (probe.error === undefined && probe.status === 0) {
|
|
70
|
+
resolvedFlags = candidate;
|
|
71
|
+
resolvedTier = candidate[0] ?? 'none';
|
|
72
|
+
return candidate;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
spawnUsable = false;
|
|
76
|
+
resolvedFlags = [];
|
|
77
|
+
resolvedTier = 'unavailable';
|
|
78
|
+
return resolvedFlags;
|
|
79
|
+
}
|
|
80
|
+
/** Which isolation tier the probe settled on, for logging and tests. */
|
|
81
|
+
function permissionTier() {
|
|
82
|
+
resolveFlags();
|
|
83
|
+
return resolvedTier;
|
|
84
|
+
}
|
|
85
|
+
const warm = [];
|
|
86
|
+
/** Every child this process has started and not yet reaped — spares and the ones still starting. */
|
|
87
|
+
const live = new Set();
|
|
88
|
+
let pending = 0;
|
|
89
|
+
let stopped = false;
|
|
90
|
+
function startChild() {
|
|
91
|
+
const flags = resolveFlags();
|
|
92
|
+
if (!spawnUsable) {
|
|
93
|
+
return Promise.reject(new Error('Pre-request scripts are unavailable: this host cannot start a worker process'));
|
|
94
|
+
}
|
|
95
|
+
return new Promise((resolve, reject) => {
|
|
96
|
+
const child = (0, child_process_1.spawn)(process.execPath, [
|
|
97
|
+
...flags,
|
|
98
|
+
`--max-old-space-size=${heapLimitMb()}`,
|
|
99
|
+
'-e',
|
|
100
|
+
pre_request_runner_source_1.PRE_REQUEST_RUNNER_SOURCE,
|
|
101
|
+
], {
|
|
102
|
+
// An empty environment is the point: the child cannot read API_SECRET,
|
|
103
|
+
// MONGO_URI or any provider key, because it was never given them.
|
|
104
|
+
env: {},
|
|
105
|
+
// Nothing reads the child's output, and a pipe nobody drains is both an
|
|
106
|
+
// open handle that outlives the run and a way for a chatty child to
|
|
107
|
+
// block on a full buffer.
|
|
108
|
+
stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
|
|
109
|
+
serialization: 'advanced',
|
|
110
|
+
windowsHide: true,
|
|
111
|
+
});
|
|
112
|
+
live.add(child);
|
|
113
|
+
child.once('exit', () => live.delete(child));
|
|
114
|
+
// Deliberately still referenced here: until it reports ready there is
|
|
115
|
+
// nothing else holding the event loop, and unreferencing at this point
|
|
116
|
+
// lets a process with no other work exit in the middle of starting one.
|
|
117
|
+
// A spare is unreferenced later, when it is parked.
|
|
118
|
+
let settled = false;
|
|
119
|
+
const timer = setTimeout(() => {
|
|
120
|
+
if (settled)
|
|
121
|
+
return;
|
|
122
|
+
settled = true;
|
|
123
|
+
// kill() on a child that already exited returns false rather than throwing.
|
|
124
|
+
child.kill('SIGKILL');
|
|
125
|
+
reject(new Error('Pre-request script worker did not start in time'));
|
|
126
|
+
}, READY_TIMEOUT_MS);
|
|
127
|
+
child.once('message', (msg) => {
|
|
128
|
+
if (settled || !msg?.ready)
|
|
129
|
+
return;
|
|
130
|
+
settled = true;
|
|
131
|
+
clearTimeout(timer);
|
|
132
|
+
resolve(child);
|
|
133
|
+
});
|
|
134
|
+
const fail = (err) => {
|
|
135
|
+
if (settled)
|
|
136
|
+
return;
|
|
137
|
+
settled = true;
|
|
138
|
+
clearTimeout(timer);
|
|
139
|
+
reject(err);
|
|
140
|
+
};
|
|
141
|
+
child.once('error', fail);
|
|
142
|
+
child.once('exit', (code, signal) => fail(new Error(`Pre-request script worker exited before starting (${signal ?? code})`)));
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
/** Top the spare pool back up, in the background. Failures are not fatal: the next run starts one itself. */
|
|
146
|
+
function refillPool() {
|
|
147
|
+
if (!spawnUsable || stopped)
|
|
148
|
+
return;
|
|
149
|
+
const want = poolSize();
|
|
150
|
+
while (warm.length + pending < want) {
|
|
151
|
+
pending += 1;
|
|
152
|
+
startChild().then((child) => {
|
|
153
|
+
pending -= 1;
|
|
154
|
+
if (stopped) {
|
|
155
|
+
child.kill('SIGKILL');
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
child.once('exit', () => {
|
|
159
|
+
const at = warm.indexOf(child);
|
|
160
|
+
if (at >= 0)
|
|
161
|
+
warm.splice(at, 1);
|
|
162
|
+
});
|
|
163
|
+
// Parked: a spare must not be the reason this process stays alive.
|
|
164
|
+
child.unref();
|
|
165
|
+
child.channel?.unref?.();
|
|
166
|
+
warm.push(child);
|
|
167
|
+
}, () => {
|
|
168
|
+
pending -= 1;
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
async function takeChild() {
|
|
173
|
+
let child = warm.shift();
|
|
174
|
+
while (child && (child.killed || child.exitCode !== null))
|
|
175
|
+
child = warm.shift();
|
|
176
|
+
if (!child)
|
|
177
|
+
child = await startChild();
|
|
178
|
+
refillPool();
|
|
179
|
+
// Taken back out of the pool: it is doing real work now.
|
|
180
|
+
child.ref();
|
|
181
|
+
child.channel?.ref?.();
|
|
182
|
+
return child;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Run one script in one throwaway child.
|
|
186
|
+
*
|
|
187
|
+
* `ctx.payload` must already be a plain, cloneable value — the caller does the
|
|
188
|
+
* same JSON round-trip the in-process sandbox always did, which both preserves
|
|
189
|
+
* that behaviour and guarantees the context can cross.
|
|
190
|
+
*/
|
|
191
|
+
async function runInChild(code, ctx, timeoutMs) {
|
|
192
|
+
const child = await takeChild();
|
|
193
|
+
return new Promise((resolve, reject) => {
|
|
194
|
+
let settled = false;
|
|
195
|
+
const finish = (fn, value) => {
|
|
196
|
+
if (settled)
|
|
197
|
+
return;
|
|
198
|
+
settled = true;
|
|
199
|
+
clearTimeout(deadline);
|
|
200
|
+
child.removeAllListeners('message');
|
|
201
|
+
child.removeAllListeners('error');
|
|
202
|
+
child.removeAllListeners('exit');
|
|
203
|
+
child.kill('SIGKILL');
|
|
204
|
+
refillPool();
|
|
205
|
+
fn(value);
|
|
206
|
+
};
|
|
207
|
+
const deadline = setTimeout(() => finish(reject, new Error(`Pre-request script did not finish within ${timeoutMs + DEADLINE_GRACE_MS}ms`)), timeoutMs + DEADLINE_GRACE_MS);
|
|
208
|
+
child.on('message', (msg) => {
|
|
209
|
+
if (!msg || typeof msg !== 'object')
|
|
210
|
+
return;
|
|
211
|
+
if (msg.ok === true) {
|
|
212
|
+
finish(resolve, { url: msg.url, headers: msg.headers, body: msg.body });
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (msg.ok === false) {
|
|
216
|
+
const err = new Error(String(msg.message ?? 'Pre-request script failed'));
|
|
217
|
+
if (msg.name)
|
|
218
|
+
err.name = String(msg.name);
|
|
219
|
+
finish(reject, err);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
child.on('error', (err) => finish(reject, err));
|
|
223
|
+
child.on('exit', (code, signal) => finish(reject, new Error(`Pre-request script worker stopped unexpectedly (${signal ?? code})`)));
|
|
224
|
+
try {
|
|
225
|
+
child.send({ code, ctx, timeoutMs });
|
|
226
|
+
}
|
|
227
|
+
catch (err) {
|
|
228
|
+
finish(reject, err);
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Ejecutar el código de un nodo de código en un hijo.
|
|
234
|
+
*
|
|
235
|
+
* Mismo pool y mismas garantías que el script pre-request: hijo desechable con
|
|
236
|
+
* entorno vacío, sin ficheros ni spawn donde el runtime lo soporte, y muerto
|
|
237
|
+
* después. Lo que cambia respecto a correrlo en proceso es dónde aterriza
|
|
238
|
+
* quien se salga del vm — y salirse del vm es fácil, nunca fue una frontera.
|
|
239
|
+
* Dentro del proceso de la API eso daba `process.env`, o sea `MONGO_URI` y el
|
|
240
|
+
* `API_SECRET` con el que se descifran las credenciales de todos los
|
|
241
|
+
* inquilinos. Aquí no da nada.
|
|
242
|
+
*
|
|
243
|
+
* Un fallo del propio código —una excepción, un timeout— vuelve como `Error`,
|
|
244
|
+
* con los logs que alcanzó a emitir enganchados en `logs`, porque el usuario
|
|
245
|
+
* los necesita justamente cuando algo revienta.
|
|
246
|
+
*/
|
|
247
|
+
async function runCodeNodeInChild(code, ctx, timeoutMs) {
|
|
248
|
+
const child = await takeChild();
|
|
249
|
+
return new Promise((resolve, reject) => {
|
|
250
|
+
let settled = false;
|
|
251
|
+
const finish = (fn, value) => {
|
|
252
|
+
if (settled)
|
|
253
|
+
return;
|
|
254
|
+
settled = true;
|
|
255
|
+
clearTimeout(deadline);
|
|
256
|
+
child.removeAllListeners('message');
|
|
257
|
+
child.removeAllListeners('error');
|
|
258
|
+
child.removeAllListeners('exit');
|
|
259
|
+
child.kill('SIGKILL');
|
|
260
|
+
refillPool();
|
|
261
|
+
fn(value);
|
|
262
|
+
};
|
|
263
|
+
const deadline = setTimeout(() => finish(reject, new Error(`Code node did not finish within ${timeoutMs + DEADLINE_GRACE_MS}ms`)), timeoutMs + DEADLINE_GRACE_MS);
|
|
264
|
+
child.on('message', (msg) => {
|
|
265
|
+
if (!msg || typeof msg !== 'object')
|
|
266
|
+
return;
|
|
267
|
+
if (msg.ok === true) {
|
|
268
|
+
finish(resolve, {
|
|
269
|
+
output: msg.output ?? null,
|
|
270
|
+
logs: Array.isArray(msg.logs) ? msg.logs.map(String) : [],
|
|
271
|
+
});
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
if (msg.ok === false) {
|
|
275
|
+
const err = new Error(String(msg.message ?? 'Code node failed'));
|
|
276
|
+
if (msg.name)
|
|
277
|
+
err.name = String(msg.name);
|
|
278
|
+
// Los logs de una ejecución que falló son justo los que hacen falta.
|
|
279
|
+
err.logs = Array.isArray(msg.logs)
|
|
280
|
+
? msg.logs.map(String)
|
|
281
|
+
: [];
|
|
282
|
+
finish(reject, err);
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
child.on('error', (err) => finish(reject, err));
|
|
286
|
+
child.on('exit', (code2, signal) => finish(reject, new Error(`Code node worker stopped unexpectedly (${signal ?? code2})`)));
|
|
287
|
+
try {
|
|
288
|
+
child.send({ kind: 'code', code, ctx, timeoutMs });
|
|
289
|
+
}
|
|
290
|
+
catch (err) {
|
|
291
|
+
finish(reject, err);
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Stop every child, including the ones still starting, and refuse to start
|
|
297
|
+
* more. Registered on exit, and used by tests so jest sees no stray handles —
|
|
298
|
+
* a spare that finished starting after the last test would otherwise keep its
|
|
299
|
+
* IPC channel open.
|
|
300
|
+
*/
|
|
301
|
+
function shutdownPreRequestPool() {
|
|
302
|
+
stopped = true;
|
|
303
|
+
warm.length = 0;
|
|
304
|
+
for (const child of live)
|
|
305
|
+
child.kill('SIGKILL');
|
|
306
|
+
live.clear();
|
|
307
|
+
}
|
|
308
|
+
process.once('exit', shutdownPreRequestPool);
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source of the child process that runs tenant code.
|
|
3
|
+
*
|
|
4
|
+
* Dos clases de trabajo, un solo hijo: el script pre-request de siempre
|
|
5
|
+
* (`kind: 'pre-request'`, el de por defecto) y el nodo de código
|
|
6
|
+
* (`kind: 'code'`). Comparten proceso porque comparten exactamente el motivo
|
|
7
|
+
* de estar aquí — son JS que escribe un inquilino — y porque el pool caliente
|
|
8
|
+
* ya montado es lo que hace que sacarlos del proceso no se note.
|
|
9
|
+
*
|
|
10
|
+
* Kept as a string, not a file, on purpose: the child is started with
|
|
11
|
+
* `node -e <this>`, so there is no path to resolve and the same code works
|
|
12
|
+
* under ts-node, jest and `dist/` alike. A file would have to exist at three
|
|
13
|
+
* different paths depending on how the API was started.
|
|
14
|
+
*
|
|
15
|
+
* The globals below are seeded from the child's own realm, and they are the
|
|
16
|
+
* same list, in the same order, that the in-process sandbox seeded before —
|
|
17
|
+
* including the cross-realm quirks that come with it (`[] instanceof Array` is
|
|
18
|
+
* false in here, because the literal's prototype is the vm context's while
|
|
19
|
+
* `Array` is the child's). Keeping the list identical is what makes a tenant
|
|
20
|
+
* script compute exactly what it computed before.
|
|
21
|
+
*
|
|
22
|
+
* These are real Node intrinsics, not shims. That is only safe because the
|
|
23
|
+
* realm they come from is worth nothing: the child is started with an empty
|
|
24
|
+
* environment and, where the runtime supports it, with the filesystem and
|
|
25
|
+
* process spawning denied. A script that breaks out of the vm — which is easy,
|
|
26
|
+
* vm was never a boundary — lands in a process that holds no secret.
|
|
27
|
+
*/
|
|
28
|
+
export declare const PRE_REQUEST_RUNNER_SOURCE = "\n'use strict';\n\n/* La red se cierra ANTES que nada, y por eso est\u00E1 en la primera l\u00EDnea.\n *\n * El modelo de permisos de Node \u2014`--permission`\u2014 cubre ficheros, spawn,\n * worker_threads y addons. NO cubre la red: est\u00E1 medido, y un escape del vm\n * dentro de este hijo encontraba `require('http')` y `fetch` disponibles.\n * Y este hijo corre DENTRO de la red privada, as\u00ED que desde ah\u00ED se alcanza\n * Mongo, Redis, el servicio de metadatos de la nube y la propia API.\n *\n * El escape no es hipot\u00E9tico ni dif\u00EDcil: `payload` es un objeto del realm\n * del hijo que entra al sandbox, as\u00ED que\n * `payload.constructor.constructor('return process')()` sale de golpe. El\n * comentario de abajo ya lo dec\u00EDa \u2014\u00ABvm nunca fue una frontera\u00BB\u2014; lo que\n * faltaba era que el realm al que se sale tampoco tuviera red.\n *\n * Esto NO convierte al hijo en un sandbox. La respuesta de verdad es la\n * POSICI\u00D3N \u2014el worker de Cloudflare, donde no hay red privada que alcanzar\u2014,\n * y sigue siendo el camino preferido; ver `code-runner.ts`. Esto es la capa\n * que queda puesta mientras tanto y el d\u00EDa que el worker no responda y se\n * caiga al hijo, que es justo cuando hace falta.\n *\n * Se quitan tambi\u00E9n `child_process` y `worker_threads`, que el modelo de\n * permisos ya niega, porque el escal\u00F3n degradado \u2014cuando el runtime no\n * soporta el flag\u2014 se queda sin esa negaci\u00F3n y con \u00E9stas no.\n */\n(function cerrarLaRed() {\n const Module = require('module');\n\n /* LISTA BLANCA, no lista negra \u2014 y el cambio importa m\u00E1s que su contenido.\n *\n * La primera versi\u00F3n enumeraba lo peligroso: http, https, net, tls, dns\u2026\n * Se rodeaba sin esfuerzo, porque Node expone los mismos transportes con\n * otros nombres: `_http_client`, `_tls_wrap`, `_http_common` y\n * `_stream_wrap` cargan los cuatro, y ninguno estaba en la lista.\n *\n * Enumerar lo prohibido es apostar a conocer TODOS los nombres, hoy y en la\n * versi\u00F3n de Node de dentro de un a\u00F1o. Enumerar lo permitido es apostar a\n * conocer lo que este hijo usa, que son tres m\u00F3dulos y est\u00E1n cinco l\u00EDneas\n * m\u00E1s abajo. La segunda apuesta se puede ganar.\n */\n const PERMITIDOS = new Set(['vm', 'crypto', 'module']);\n\n const cargar = Module._load;\n Module._load = function (peticion, padre, esPrincipal) {\n const nombre = String(peticion).replace(/^node:/, '');\n if (!PERMITIDOS.has(nombre)) {\n throw new Error('module \"' + peticion + '\" is not available to node code');\n }\n return cargar.call(this, peticion, padre, esPrincipal);\n };\n\n /* `process.binding` devuelve los bindings nativos SIN pasar por el cargador\n de m\u00F3dulos, as\u00ED que la lista blanca de arriba no lo ve. Es una API vieja y\n desaconsejada, y este hijo no la usa para nada. `_linkedBinding` es su\n hermana.\n\n `getBuiltinModule` es la misma idea con ropa nueva \u2014Node 20.16 / 22.3\u2014:\n devuelve el builtin ya resuelto sin tocar `Module._load`, as\u00ED que la\n lista blanca tampoco lo ve\u00EDa. Est\u00E1 medido en este hijo:\n `getBuiltinModule('net')` daba un socket de verdad, y los bytes llegaban\n al otro extremo. `dlopen` carga un addon nativo, que trae red y todo lo\n dem\u00E1s; el modelo de permisos ya lo niega, pero el escal\u00F3n degradado \u2014el\n que se queda sin esa negaci\u00F3n\u2014 no. Ninguna de las cuatro la usa este\n hijo. */\n for (const nombre of ['binding', '_linkedBinding', 'getBuiltinModule', 'dlopen']) {\n if (typeof process[nombre] === 'function') {\n process[nombre] = function () {\n throw new Error('process.' + nombre + '() is not available to node code');\n };\n }\n }\n\n /* Registrar hooks de ESM abre un realm nuevo \u2014otro hilo\u2014 donde\n `Module._load` NO est\u00E1 parcheado, y el m\u00F3dulo de hooks importa lo que\n quiera: basta una `data:` URL. Tambi\u00E9n est\u00E1 medido, y tambi\u00E9n en el\n escal\u00F3n degradado, que es donde el modelo de permisos no lo niega por su\n cuenta. `registerHooks` es la variante s\u00EDncrona, que no existe en todas\n las versiones; de ah\u00ED el `typeof`. */\n for (const nombre of ['register', 'registerHooks']) {\n if (typeof Module[nombre] === 'function') {\n Module[nombre] = function () {\n throw new Error('module.' + nombre + '() is not available to node code');\n };\n }\n }\n\n /* El \u00FAltimo cargador que no pasa por `Module._load` es el `import()`\n din\u00E1mico del contexto principal, y la llave la regala `vm`, que s\u00ED est\u00E1\n permitido porque este hijo lo necesita: pasando\n `vm.constants.USE_MAIN_CONTEXT_DEFAULT_LOADER` como\n `importModuleDynamically` a `Script`, `compileFunction`,\n `runInThisContext` o `runInNewContext`, `import('net')` resuelve \u2014\n medido igual que las de arriba, con bytes al otro lado. Sin esa llave no\n queda camino: una callback propia exige `--experimental-vm-modules`, que\n este hijo no lleva.\n\n El objeto `constants` viene congelado, as\u00ED que el s\u00EDmbolo no se puede\n borrar de ah\u00ED; lo que s\u00ED se puede es dejar en `vm.constants` una copia\n sin la llave. La copia conserva el resto de constantes y el prototipo\n nulo que tra\u00EDa el original, para que lo que un script vea ah\u00ED sea lo\n mismo de antes menos la llave. Si un d\u00EDa ninguna de las dos formas\n funcionara no habr\u00EDa d\u00F3nde avisar \u2014este hijo corre con stdio cerrado\u2014: lo\n que cubre ese caso es el test de este fichero. */\n const vmParaCerrar = require('vm');\n const constantes = vmParaCerrar.constants;\n if (constantes && constantes.USE_MAIN_CONTEXT_DEFAULT_LOADER) {\n const copia = Object.create(null);\n for (const clave of Object.getOwnPropertyNames(constantes)) {\n if (clave !== 'USE_MAIN_CONTEXT_DEFAULT_LOADER') copia[clave] = constantes[clave];\n }\n Object.freeze(copia);\n if (!Reflect.defineProperty(vmParaCerrar, 'constants', {\n value: copia,\n configurable: false,\n writable: false,\n enumerable: true,\n })) {\n Reflect.set(vmParaCerrar, 'constants', copia);\n }\n }\n\n /* Los globales de red no pasan por `require`, as\u00ED que hay que quitarlos\n aparte. Con `Reflect` en vez de `delete` + asignaci\u00F3n porque estos\n globales son accesores perezosos: en modo estricto la asignaci\u00F3n LANZA, y\n `Reflect` devuelve `false` en vez de lanzar. Si las dos formas fallaran\n \u2014una versi\u00F3n de Node que los clave\u2014 no hay d\u00F3nde avisar: este hijo corre\n con stdio cerrado. Lo que cubre ese caso es el test de este fichero. */\n for (const nombre of ['fetch', 'WebSocket', 'EventSource', 'XMLHttpRequest']) {\n if (!Reflect.deleteProperty(globalThis, nombre)) {\n Reflect.defineProperty(globalThis, nombre, {\n value: undefined,\n configurable: false,\n writable: false,\n });\n }\n }\n})();\n\nconst vm = require('vm');\nconst crypto = require('crypto');\n\n/* Una promesa rechazada que el inquilino deja suelta NO puede matar al hijo.\n *\n * Desde Node 15, el comportamiento por defecto de `unhandledRejection` es\n * matar el proceso con c\u00F3digo 1. Aqu\u00ED eso es una carrera: el script termina,\n * mandamos el resultado por IPC y, si Node recoge el rechazo antes de que el\n * mensaje salga, el padre no ve respuesta \u2014 ve morir al hijo y contesta\n * \"Pre-request script worker stopped unexpectedly (1)\". El mismo script, la\n * misma petici\u00F3n, funciona o no seg\u00FAn lo cargada que est\u00E9 la m\u00E1quina. En una\n * port\u00E1til gana el env\u00EDo casi siempre; en el runner del CI, no.\n *\n * Y basta con `import('fs')` \u2014bloqueado, as\u00ED que la promesa se rechaza\u2014 para\n * dejar una suelta sin querer.\n *\n * El manejador est\u00E1 vac\u00EDo a prop\u00F3sito. Lo tentador es recogerlo en los\n * `logs` que el usuario ve, pero el rechazo aflora DESPU\u00C9S de que el\n * resultado ya sali\u00F3: el script corre y se contesta dentro del mismo turno del\n * bucle, y el evento llega en uno posterior. Un log escrito ah\u00ED no viajar\u00EDa en\n * esta ejecuci\u00F3n, y como el pool reutiliza el proceso, acabar\u00EDa contado en la\n * siguiente \u2014 un mensaje falso sobre el script de otra persona. Se queda en lo\n * que s\u00ED se puede prometer: el rechazo no mata al hijo.\n */\nprocess.on('unhandledRejection', function () {});\n\n// A header value the structured clone cannot carry is replaced by the text the\n// caller would have put on the wire anyway, so the send cannot fail on it.\nfunction cloneable(headers) {\n if (!headers || typeof headers !== 'object') return headers;\n const out = Array.isArray(headers) ? [] : {};\n for (const key of Object.keys(headers)) {\n const value = headers[key];\n const type = typeof value;\n out[key] = type === 'function' || type === 'symbol' ? String(value) : value;\n }\n return out;\n}\n\n/**\n * El sandbox del nodo de c\u00F3digo.\n *\n * Misma lista de globales, en el mismo orden, que sembraba `buildSandbox` en\n * proceso \u2014 incluidos los recortes deliberados: `JSON`, `Array` y `Object`\n * entran con s\u00F3lo unos m\u00E9todos, no enteros. Mantenerla id\u00E9ntica es lo que hace\n * que el c\u00F3digo de un inquilino calcule exactamente lo que calculaba antes.\n *\n * Aqu\u00ED s\u00ED son los intr\u00EDnsecos de verdad del hijo, y da igual: el realm del que\n * salen no vale nada.\n */\nfunction buildCodeSandbox(ctx, logs) {\n function stringify(v) {\n if (v === undefined) return 'undefined';\n if (v === null) return 'null';\n if (typeof v === 'object') {\n try { return JSON.stringify(v, null, 2); } catch (e) { return String(v); }\n }\n return String(v);\n }\n\n // `$(\"nombre\")` \u2014 la salida de otro nodo del lienzo. Misma forma que en\n // proceso: un iterable devuelve su campo, y `_meta` no se ve.\n function nodeLookup(workspacePayloads) {\n return function (nodeName) {\n if (!workspacePayloads) throw new Error('$() requires workspace context');\n const p = workspacePayloads[nodeName];\n if (!p) throw new Error('Node \"' + nodeName + '\" not found');\n const cloned = JSON.parse(JSON.stringify(p));\n const m = cloned._meta;\n if (m && m.iterable && m.iterateField) return cloned[m.iterateField] || [];\n delete cloned._meta;\n return cloned;\n };\n }\n\n const sandbox = {\n payload: ctx.payload,\n $: nodeLookup(ctx.workspacePayloads),\n headers: Object.assign({}, ctx.headers || {}),\n meta: Object.assign({}, ctx.meta || {}),\n JSON: { parse: JSON.parse, stringify: JSON.stringify },\n Math: Math,\n Date: Date,\n Array: { isArray: Array.isArray, from: Array.from, of: Array.of },\n Object: {\n keys: Object.keys, values: Object.values, entries: Object.entries,\n assign: Object.assign, freeze: Object.freeze,\n },\n String: String,\n Number: Number,\n Boolean: Boolean,\n RegExp: RegExp,\n parseInt: parseInt,\n parseFloat: parseFloat,\n isNaN: isNaN,\n isFinite: isFinite,\n encodeURIComponent: encodeURIComponent,\n decodeURIComponent: decodeURIComponent,\n Map: Map,\n Set: Set,\n undefined: undefined,\n console: {\n log: function () { logs.push(Array.prototype.map.call(arguments, stringify).join(' ')); },\n warn: function () { logs.push('[warn] ' + Array.prototype.map.call(arguments, stringify).join(' ')); },\n error: function () { logs.push('[error] ' + Array.prototype.map.call(arguments, stringify).join(' ')); },\n },\n };\n\n return vm.createContext(sandbox);\n}\n\nfunction runCodeNode(msg) {\n const logs = [];\n let output;\n try {\n const sandbox = buildCodeSandbox(msg.ctx, logs);\n // El envoltorio en funci\u00F3n es lo que hace que `return` funcione, igual\n // que en proceso.\n output = new vm.Script('(function() {\\n' + msg.code + '\\n})()', {\n filename: 'code-node.js',\n }).runInContext(sandbox, { timeout: msg.timeoutMs });\n } catch (err) {\n process.send({\n ok: false,\n logs: logs,\n name: err && err.name ? String(err.name) : 'Error',\n message: err && err.message ? String(err.message) : String(err),\n });\n return;\n }\n\n // Lo que devuelva el inquilino cruza por structured clone. Lo que el clon no\n // pueda llevar \u2014una funci\u00F3n, un s\u00EDmbolo\u2014 habr\u00EDa reventado el env\u00EDo y matado\n // la ejecuci\u00F3n entera; el round-trip por JSON deja pasar lo que s\u00ED es\n // representable y descarta el resto, que es lo que el llamante iba a\n // serializar de todas formas.\n try {\n process.send({ ok: true, output: output === undefined ? null : output, logs: logs });\n } catch (err) {\n try {\n process.send({\n ok: true,\n output: output === undefined || output === null\n ? null\n : JSON.parse(JSON.stringify(output)),\n logs: logs,\n });\n } catch (again) {\n process.send({\n ok: false,\n logs: logs,\n name: 'TypeError',\n message: 'Code node returned a value that cannot be returned: ' +\n (again && again.message ? again.message : String(again)),\n });\n }\n }\n}\n\nprocess.on('message', function (msg) {\n if (msg && msg.kind === 'code') {\n runCodeNode(msg);\n return;\n }\n\n const ctx = msg.ctx;\n const req = {\n url: ctx.url,\n method: ctx.method,\n headers: Object.assign({}, ctx.headers),\n body: ctx.body,\n };\n\n const sandbox = vm.createContext({\n req,\n payload: Object.freeze(ctx.payload),\n // Crypto \u2014 needed for HMAC-SHA256 (S3, custom signatures)\n crypto: {\n createHmac: crypto.createHmac.bind(crypto),\n createHash: crypto.createHash.bind(crypto),\n randomUUID: crypto.randomUUID.bind(crypto),\n },\n // Standard globals\n URL: URL,\n URLSearchParams: URLSearchParams,\n Buffer: Buffer,\n Date: Date,\n JSON: JSON,\n Math: Math,\n parseInt: parseInt,\n parseFloat: parseFloat,\n encodeURIComponent: encodeURIComponent,\n decodeURIComponent: decodeURIComponent,\n encodeURI: encodeURI,\n decodeURI: decodeURI,\n atob: globalThis.atob,\n btoa: globalThis.btoa,\n console: { log: function () {}, warn: function () {}, error: function () {} },\n // String/Array/Object utilities\n String: String,\n Number: Number,\n Boolean: Boolean,\n Array: Array,\n Object: Object,\n RegExp: RegExp,\n Map: Map,\n Set: Set,\n });\n\n try {\n new vm.Script(msg.code, { filename: 'pre-request-script.js' })\n .runInContext(sandbox, { timeout: msg.timeoutMs });\n } catch (err) {\n process.send({\n ok: false,\n name: err && err.name ? String(err.name) : 'Error',\n message: err && err.message ? String(err.message) : String(err),\n });\n return;\n }\n\n const result = { ok: true, url: req.url, headers: req.headers, body: req.body };\n try {\n process.send(result);\n } catch (err) {\n try {\n result.headers = cloneable(req.headers);\n process.send(result);\n } catch (again) {\n process.send({\n ok: false,\n name: 'TypeError',\n message: 'Pre-request script produced a result that cannot be returned: ' +\n (again && again.message ? again.message : String(again)),\n });\n }\n }\n});\n\nprocess.send({ ready: true });\n";
|