@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,411 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.PRE_REQUEST_RUNNER_SOURCE = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* Source of the child process that runs tenant code.
|
|
6
|
+
*
|
|
7
|
+
* Dos clases de trabajo, un solo hijo: el script pre-request de siempre
|
|
8
|
+
* (`kind: 'pre-request'`, el de por defecto) y el nodo de código
|
|
9
|
+
* (`kind: 'code'`). Comparten proceso porque comparten exactamente el motivo
|
|
10
|
+
* de estar aquí — son JS que escribe un inquilino — y porque el pool caliente
|
|
11
|
+
* ya montado es lo que hace que sacarlos del proceso no se note.
|
|
12
|
+
*
|
|
13
|
+
* Kept as a string, not a file, on purpose: the child is started with
|
|
14
|
+
* `node -e <this>`, so there is no path to resolve and the same code works
|
|
15
|
+
* under ts-node, jest and `dist/` alike. A file would have to exist at three
|
|
16
|
+
* different paths depending on how the API was started.
|
|
17
|
+
*
|
|
18
|
+
* The globals below are seeded from the child's own realm, and they are the
|
|
19
|
+
* same list, in the same order, that the in-process sandbox seeded before —
|
|
20
|
+
* including the cross-realm quirks that come with it (`[] instanceof Array` is
|
|
21
|
+
* false in here, because the literal's prototype is the vm context's while
|
|
22
|
+
* `Array` is the child's). Keeping the list identical is what makes a tenant
|
|
23
|
+
* script compute exactly what it computed before.
|
|
24
|
+
*
|
|
25
|
+
* These are real Node intrinsics, not shims. That is only safe because the
|
|
26
|
+
* realm they come from is worth nothing: the child is started with an empty
|
|
27
|
+
* environment and, where the runtime supports it, with the filesystem and
|
|
28
|
+
* process spawning denied. A script that breaks out of the vm — which is easy,
|
|
29
|
+
* vm was never a boundary — lands in a process that holds no secret.
|
|
30
|
+
*/
|
|
31
|
+
exports.PRE_REQUEST_RUNNER_SOURCE = `
|
|
32
|
+
'use strict';
|
|
33
|
+
|
|
34
|
+
/* La red se cierra ANTES que nada, y por eso está en la primera línea.
|
|
35
|
+
*
|
|
36
|
+
* El modelo de permisos de Node —\`--permission\`— cubre ficheros, spawn,
|
|
37
|
+
* worker_threads y addons. NO cubre la red: está medido, y un escape del vm
|
|
38
|
+
* dentro de este hijo encontraba \`require('http')\` y \`fetch\` disponibles.
|
|
39
|
+
* Y este hijo corre DENTRO de la red privada, así que desde ahí se alcanza
|
|
40
|
+
* Mongo, Redis, el servicio de metadatos de la nube y la propia API.
|
|
41
|
+
*
|
|
42
|
+
* El escape no es hipotético ni difícil: \`payload\` es un objeto del realm
|
|
43
|
+
* del hijo que entra al sandbox, así que
|
|
44
|
+
* \`payload.constructor.constructor('return process')()\` sale de golpe. El
|
|
45
|
+
* comentario de abajo ya lo decía —«vm nunca fue una frontera»—; lo que
|
|
46
|
+
* faltaba era que el realm al que se sale tampoco tuviera red.
|
|
47
|
+
*
|
|
48
|
+
* Esto NO convierte al hijo en un sandbox. La respuesta de verdad es la
|
|
49
|
+
* POSICIÓN —el worker de Cloudflare, donde no hay red privada que alcanzar—,
|
|
50
|
+
* y sigue siendo el camino preferido; ver \`code-runner.ts\`. Esto es la capa
|
|
51
|
+
* que queda puesta mientras tanto y el día que el worker no responda y se
|
|
52
|
+
* caiga al hijo, que es justo cuando hace falta.
|
|
53
|
+
*
|
|
54
|
+
* Se quitan también \`child_process\` y \`worker_threads\`, que el modelo de
|
|
55
|
+
* permisos ya niega, porque el escalón degradado —cuando el runtime no
|
|
56
|
+
* soporta el flag— se queda sin esa negación y con éstas no.
|
|
57
|
+
*/
|
|
58
|
+
(function cerrarLaRed() {
|
|
59
|
+
const Module = require('module');
|
|
60
|
+
|
|
61
|
+
/* LISTA BLANCA, no lista negra — y el cambio importa más que su contenido.
|
|
62
|
+
*
|
|
63
|
+
* La primera versión enumeraba lo peligroso: http, https, net, tls, dns…
|
|
64
|
+
* Se rodeaba sin esfuerzo, porque Node expone los mismos transportes con
|
|
65
|
+
* otros nombres: \`_http_client\`, \`_tls_wrap\`, \`_http_common\` y
|
|
66
|
+
* \`_stream_wrap\` cargan los cuatro, y ninguno estaba en la lista.
|
|
67
|
+
*
|
|
68
|
+
* Enumerar lo prohibido es apostar a conocer TODOS los nombres, hoy y en la
|
|
69
|
+
* versión de Node de dentro de un año. Enumerar lo permitido es apostar a
|
|
70
|
+
* conocer lo que este hijo usa, que son tres módulos y están cinco líneas
|
|
71
|
+
* más abajo. La segunda apuesta se puede ganar.
|
|
72
|
+
*/
|
|
73
|
+
const PERMITIDOS = new Set(['vm', 'crypto', 'module']);
|
|
74
|
+
|
|
75
|
+
const cargar = Module._load;
|
|
76
|
+
Module._load = function (peticion, padre, esPrincipal) {
|
|
77
|
+
const nombre = String(peticion).replace(/^node:/, '');
|
|
78
|
+
if (!PERMITIDOS.has(nombre)) {
|
|
79
|
+
throw new Error('module "' + peticion + '" is not available to node code');
|
|
80
|
+
}
|
|
81
|
+
return cargar.call(this, peticion, padre, esPrincipal);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/* \`process.binding\` devuelve los bindings nativos SIN pasar por el cargador
|
|
85
|
+
de módulos, así que la lista blanca de arriba no lo ve. Es una API vieja y
|
|
86
|
+
desaconsejada, y este hijo no la usa para nada. \`_linkedBinding\` es su
|
|
87
|
+
hermana.
|
|
88
|
+
|
|
89
|
+
\`getBuiltinModule\` es la misma idea con ropa nueva —Node 20.16 / 22.3—:
|
|
90
|
+
devuelve el builtin ya resuelto sin tocar \`Module._load\`, así que la
|
|
91
|
+
lista blanca tampoco lo veía. Está medido en este hijo:
|
|
92
|
+
\`getBuiltinModule('net')\` daba un socket de verdad, y los bytes llegaban
|
|
93
|
+
al otro extremo. \`dlopen\` carga un addon nativo, que trae red y todo lo
|
|
94
|
+
demás; el modelo de permisos ya lo niega, pero el escalón degradado —el
|
|
95
|
+
que se queda sin esa negación— no. Ninguna de las cuatro la usa este
|
|
96
|
+
hijo. */
|
|
97
|
+
for (const nombre of ['binding', '_linkedBinding', 'getBuiltinModule', 'dlopen']) {
|
|
98
|
+
if (typeof process[nombre] === 'function') {
|
|
99
|
+
process[nombre] = function () {
|
|
100
|
+
throw new Error('process.' + nombre + '() is not available to node code');
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/* Registrar hooks de ESM abre un realm nuevo —otro hilo— donde
|
|
106
|
+
\`Module._load\` NO está parcheado, y el módulo de hooks importa lo que
|
|
107
|
+
quiera: basta una \`data:\` URL. También está medido, y también en el
|
|
108
|
+
escalón degradado, que es donde el modelo de permisos no lo niega por su
|
|
109
|
+
cuenta. \`registerHooks\` es la variante síncrona, que no existe en todas
|
|
110
|
+
las versiones; de ahí el \`typeof\`. */
|
|
111
|
+
for (const nombre of ['register', 'registerHooks']) {
|
|
112
|
+
if (typeof Module[nombre] === 'function') {
|
|
113
|
+
Module[nombre] = function () {
|
|
114
|
+
throw new Error('module.' + nombre + '() is not available to node code');
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/* El último cargador que no pasa por \`Module._load\` es el \`import()\`
|
|
120
|
+
dinámico del contexto principal, y la llave la regala \`vm\`, que sí está
|
|
121
|
+
permitido porque este hijo lo necesita: pasando
|
|
122
|
+
\`vm.constants.USE_MAIN_CONTEXT_DEFAULT_LOADER\` como
|
|
123
|
+
\`importModuleDynamically\` a \`Script\`, \`compileFunction\`,
|
|
124
|
+
\`runInThisContext\` o \`runInNewContext\`, \`import('net')\` resuelve —
|
|
125
|
+
medido igual que las de arriba, con bytes al otro lado. Sin esa llave no
|
|
126
|
+
queda camino: una callback propia exige \`--experimental-vm-modules\`, que
|
|
127
|
+
este hijo no lleva.
|
|
128
|
+
|
|
129
|
+
El objeto \`constants\` viene congelado, así que el símbolo no se puede
|
|
130
|
+
borrar de ahí; lo que sí se puede es dejar en \`vm.constants\` una copia
|
|
131
|
+
sin la llave. La copia conserva el resto de constantes y el prototipo
|
|
132
|
+
nulo que traía el original, para que lo que un script vea ahí sea lo
|
|
133
|
+
mismo de antes menos la llave. Si un día ninguna de las dos formas
|
|
134
|
+
funcionara no habría dónde avisar —este hijo corre con stdio cerrado—: lo
|
|
135
|
+
que cubre ese caso es el test de este fichero. */
|
|
136
|
+
const vmParaCerrar = require('vm');
|
|
137
|
+
const constantes = vmParaCerrar.constants;
|
|
138
|
+
if (constantes && constantes.USE_MAIN_CONTEXT_DEFAULT_LOADER) {
|
|
139
|
+
const copia = Object.create(null);
|
|
140
|
+
for (const clave of Object.getOwnPropertyNames(constantes)) {
|
|
141
|
+
if (clave !== 'USE_MAIN_CONTEXT_DEFAULT_LOADER') copia[clave] = constantes[clave];
|
|
142
|
+
}
|
|
143
|
+
Object.freeze(copia);
|
|
144
|
+
if (!Reflect.defineProperty(vmParaCerrar, 'constants', {
|
|
145
|
+
value: copia,
|
|
146
|
+
configurable: false,
|
|
147
|
+
writable: false,
|
|
148
|
+
enumerable: true,
|
|
149
|
+
})) {
|
|
150
|
+
Reflect.set(vmParaCerrar, 'constants', copia);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/* Los globales de red no pasan por \`require\`, así que hay que quitarlos
|
|
155
|
+
aparte. Con \`Reflect\` en vez de \`delete\` + asignación porque estos
|
|
156
|
+
globales son accesores perezosos: en modo estricto la asignación LANZA, y
|
|
157
|
+
\`Reflect\` devuelve \`false\` en vez de lanzar. Si las dos formas fallaran
|
|
158
|
+
—una versión de Node que los clave— no hay dónde avisar: este hijo corre
|
|
159
|
+
con stdio cerrado. Lo que cubre ese caso es el test de este fichero. */
|
|
160
|
+
for (const nombre of ['fetch', 'WebSocket', 'EventSource', 'XMLHttpRequest']) {
|
|
161
|
+
if (!Reflect.deleteProperty(globalThis, nombre)) {
|
|
162
|
+
Reflect.defineProperty(globalThis, nombre, {
|
|
163
|
+
value: undefined,
|
|
164
|
+
configurable: false,
|
|
165
|
+
writable: false,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
})();
|
|
170
|
+
|
|
171
|
+
const vm = require('vm');
|
|
172
|
+
const crypto = require('crypto');
|
|
173
|
+
|
|
174
|
+
/* Una promesa rechazada que el inquilino deja suelta NO puede matar al hijo.
|
|
175
|
+
*
|
|
176
|
+
* Desde Node 15, el comportamiento por defecto de \`unhandledRejection\` es
|
|
177
|
+
* matar el proceso con código 1. Aquí eso es una carrera: el script termina,
|
|
178
|
+
* mandamos el resultado por IPC y, si Node recoge el rechazo antes de que el
|
|
179
|
+
* mensaje salga, el padre no ve respuesta — ve morir al hijo y contesta
|
|
180
|
+
* "Pre-request script worker stopped unexpectedly (1)". El mismo script, la
|
|
181
|
+
* misma petición, funciona o no según lo cargada que esté la máquina. En una
|
|
182
|
+
* portátil gana el envío casi siempre; en el runner del CI, no.
|
|
183
|
+
*
|
|
184
|
+
* Y basta con \`import('fs')\` —bloqueado, así que la promesa se rechaza— para
|
|
185
|
+
* dejar una suelta sin querer.
|
|
186
|
+
*
|
|
187
|
+
* El manejador está vacío a propósito. Lo tentador es recogerlo en los
|
|
188
|
+
* \`logs\` que el usuario ve, pero el rechazo aflora DESPUÉS de que el
|
|
189
|
+
* resultado ya salió: el script corre y se contesta dentro del mismo turno del
|
|
190
|
+
* bucle, y el evento llega en uno posterior. Un log escrito ahí no viajaría en
|
|
191
|
+
* esta ejecución, y como el pool reutiliza el proceso, acabaría contado en la
|
|
192
|
+
* siguiente — un mensaje falso sobre el script de otra persona. Se queda en lo
|
|
193
|
+
* que sí se puede prometer: el rechazo no mata al hijo.
|
|
194
|
+
*/
|
|
195
|
+
process.on('unhandledRejection', function () {});
|
|
196
|
+
|
|
197
|
+
// A header value the structured clone cannot carry is replaced by the text the
|
|
198
|
+
// caller would have put on the wire anyway, so the send cannot fail on it.
|
|
199
|
+
function cloneable(headers) {
|
|
200
|
+
if (!headers || typeof headers !== 'object') return headers;
|
|
201
|
+
const out = Array.isArray(headers) ? [] : {};
|
|
202
|
+
for (const key of Object.keys(headers)) {
|
|
203
|
+
const value = headers[key];
|
|
204
|
+
const type = typeof value;
|
|
205
|
+
out[key] = type === 'function' || type === 'symbol' ? String(value) : value;
|
|
206
|
+
}
|
|
207
|
+
return out;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* El sandbox del nodo de código.
|
|
212
|
+
*
|
|
213
|
+
* Misma lista de globales, en el mismo orden, que sembraba \`buildSandbox\` en
|
|
214
|
+
* proceso — incluidos los recortes deliberados: \`JSON\`, \`Array\` y \`Object\`
|
|
215
|
+
* entran con sólo unos métodos, no enteros. Mantenerla idéntica es lo que hace
|
|
216
|
+
* que el código de un inquilino calcule exactamente lo que calculaba antes.
|
|
217
|
+
*
|
|
218
|
+
* Aquí sí son los intrínsecos de verdad del hijo, y da igual: el realm del que
|
|
219
|
+
* salen no vale nada.
|
|
220
|
+
*/
|
|
221
|
+
function buildCodeSandbox(ctx, logs) {
|
|
222
|
+
function stringify(v) {
|
|
223
|
+
if (v === undefined) return 'undefined';
|
|
224
|
+
if (v === null) return 'null';
|
|
225
|
+
if (typeof v === 'object') {
|
|
226
|
+
try { return JSON.stringify(v, null, 2); } catch (e) { return String(v); }
|
|
227
|
+
}
|
|
228
|
+
return String(v);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// \`$("nombre")\` — la salida de otro nodo del lienzo. Misma forma que en
|
|
232
|
+
// proceso: un iterable devuelve su campo, y \`_meta\` no se ve.
|
|
233
|
+
function nodeLookup(workspacePayloads) {
|
|
234
|
+
return function (nodeName) {
|
|
235
|
+
if (!workspacePayloads) throw new Error('$() requires workspace context');
|
|
236
|
+
const p = workspacePayloads[nodeName];
|
|
237
|
+
if (!p) throw new Error('Node "' + nodeName + '" not found');
|
|
238
|
+
const cloned = JSON.parse(JSON.stringify(p));
|
|
239
|
+
const m = cloned._meta;
|
|
240
|
+
if (m && m.iterable && m.iterateField) return cloned[m.iterateField] || [];
|
|
241
|
+
delete cloned._meta;
|
|
242
|
+
return cloned;
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const sandbox = {
|
|
247
|
+
payload: ctx.payload,
|
|
248
|
+
$: nodeLookup(ctx.workspacePayloads),
|
|
249
|
+
headers: Object.assign({}, ctx.headers || {}),
|
|
250
|
+
meta: Object.assign({}, ctx.meta || {}),
|
|
251
|
+
JSON: { parse: JSON.parse, stringify: JSON.stringify },
|
|
252
|
+
Math: Math,
|
|
253
|
+
Date: Date,
|
|
254
|
+
Array: { isArray: Array.isArray, from: Array.from, of: Array.of },
|
|
255
|
+
Object: {
|
|
256
|
+
keys: Object.keys, values: Object.values, entries: Object.entries,
|
|
257
|
+
assign: Object.assign, freeze: Object.freeze,
|
|
258
|
+
},
|
|
259
|
+
String: String,
|
|
260
|
+
Number: Number,
|
|
261
|
+
Boolean: Boolean,
|
|
262
|
+
RegExp: RegExp,
|
|
263
|
+
parseInt: parseInt,
|
|
264
|
+
parseFloat: parseFloat,
|
|
265
|
+
isNaN: isNaN,
|
|
266
|
+
isFinite: isFinite,
|
|
267
|
+
encodeURIComponent: encodeURIComponent,
|
|
268
|
+
decodeURIComponent: decodeURIComponent,
|
|
269
|
+
Map: Map,
|
|
270
|
+
Set: Set,
|
|
271
|
+
undefined: undefined,
|
|
272
|
+
console: {
|
|
273
|
+
log: function () { logs.push(Array.prototype.map.call(arguments, stringify).join(' ')); },
|
|
274
|
+
warn: function () { logs.push('[warn] ' + Array.prototype.map.call(arguments, stringify).join(' ')); },
|
|
275
|
+
error: function () { logs.push('[error] ' + Array.prototype.map.call(arguments, stringify).join(' ')); },
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
return vm.createContext(sandbox);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function runCodeNode(msg) {
|
|
283
|
+
const logs = [];
|
|
284
|
+
let output;
|
|
285
|
+
try {
|
|
286
|
+
const sandbox = buildCodeSandbox(msg.ctx, logs);
|
|
287
|
+
// El envoltorio en función es lo que hace que \`return\` funcione, igual
|
|
288
|
+
// que en proceso.
|
|
289
|
+
output = new vm.Script('(function() {\\n' + msg.code + '\\n})()', {
|
|
290
|
+
filename: 'code-node.js',
|
|
291
|
+
}).runInContext(sandbox, { timeout: msg.timeoutMs });
|
|
292
|
+
} catch (err) {
|
|
293
|
+
process.send({
|
|
294
|
+
ok: false,
|
|
295
|
+
logs: logs,
|
|
296
|
+
name: err && err.name ? String(err.name) : 'Error',
|
|
297
|
+
message: err && err.message ? String(err.message) : String(err),
|
|
298
|
+
});
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Lo que devuelva el inquilino cruza por structured clone. Lo que el clon no
|
|
303
|
+
// pueda llevar —una función, un símbolo— habría reventado el envío y matado
|
|
304
|
+
// la ejecución entera; el round-trip por JSON deja pasar lo que sí es
|
|
305
|
+
// representable y descarta el resto, que es lo que el llamante iba a
|
|
306
|
+
// serializar de todas formas.
|
|
307
|
+
try {
|
|
308
|
+
process.send({ ok: true, output: output === undefined ? null : output, logs: logs });
|
|
309
|
+
} catch (err) {
|
|
310
|
+
try {
|
|
311
|
+
process.send({
|
|
312
|
+
ok: true,
|
|
313
|
+
output: output === undefined || output === null
|
|
314
|
+
? null
|
|
315
|
+
: JSON.parse(JSON.stringify(output)),
|
|
316
|
+
logs: logs,
|
|
317
|
+
});
|
|
318
|
+
} catch (again) {
|
|
319
|
+
process.send({
|
|
320
|
+
ok: false,
|
|
321
|
+
logs: logs,
|
|
322
|
+
name: 'TypeError',
|
|
323
|
+
message: 'Code node returned a value that cannot be returned: ' +
|
|
324
|
+
(again && again.message ? again.message : String(again)),
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
process.on('message', function (msg) {
|
|
331
|
+
if (msg && msg.kind === 'code') {
|
|
332
|
+
runCodeNode(msg);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const ctx = msg.ctx;
|
|
337
|
+
const req = {
|
|
338
|
+
url: ctx.url,
|
|
339
|
+
method: ctx.method,
|
|
340
|
+
headers: Object.assign({}, ctx.headers),
|
|
341
|
+
body: ctx.body,
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const sandbox = vm.createContext({
|
|
345
|
+
req,
|
|
346
|
+
payload: Object.freeze(ctx.payload),
|
|
347
|
+
// Crypto — needed for HMAC-SHA256 (S3, custom signatures)
|
|
348
|
+
crypto: {
|
|
349
|
+
createHmac: crypto.createHmac.bind(crypto),
|
|
350
|
+
createHash: crypto.createHash.bind(crypto),
|
|
351
|
+
randomUUID: crypto.randomUUID.bind(crypto),
|
|
352
|
+
},
|
|
353
|
+
// Standard globals
|
|
354
|
+
URL: URL,
|
|
355
|
+
URLSearchParams: URLSearchParams,
|
|
356
|
+
Buffer: Buffer,
|
|
357
|
+
Date: Date,
|
|
358
|
+
JSON: JSON,
|
|
359
|
+
Math: Math,
|
|
360
|
+
parseInt: parseInt,
|
|
361
|
+
parseFloat: parseFloat,
|
|
362
|
+
encodeURIComponent: encodeURIComponent,
|
|
363
|
+
decodeURIComponent: decodeURIComponent,
|
|
364
|
+
encodeURI: encodeURI,
|
|
365
|
+
decodeURI: decodeURI,
|
|
366
|
+
atob: globalThis.atob,
|
|
367
|
+
btoa: globalThis.btoa,
|
|
368
|
+
console: { log: function () {}, warn: function () {}, error: function () {} },
|
|
369
|
+
// String/Array/Object utilities
|
|
370
|
+
String: String,
|
|
371
|
+
Number: Number,
|
|
372
|
+
Boolean: Boolean,
|
|
373
|
+
Array: Array,
|
|
374
|
+
Object: Object,
|
|
375
|
+
RegExp: RegExp,
|
|
376
|
+
Map: Map,
|
|
377
|
+
Set: Set,
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
try {
|
|
381
|
+
new vm.Script(msg.code, { filename: 'pre-request-script.js' })
|
|
382
|
+
.runInContext(sandbox, { timeout: msg.timeoutMs });
|
|
383
|
+
} catch (err) {
|
|
384
|
+
process.send({
|
|
385
|
+
ok: false,
|
|
386
|
+
name: err && err.name ? String(err.name) : 'Error',
|
|
387
|
+
message: err && err.message ? String(err.message) : String(err),
|
|
388
|
+
});
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const result = { ok: true, url: req.url, headers: req.headers, body: req.body };
|
|
393
|
+
try {
|
|
394
|
+
process.send(result);
|
|
395
|
+
} catch (err) {
|
|
396
|
+
try {
|
|
397
|
+
result.headers = cloneable(req.headers);
|
|
398
|
+
process.send(result);
|
|
399
|
+
} catch (again) {
|
|
400
|
+
process.send({
|
|
401
|
+
ok: false,
|
|
402
|
+
name: 'TypeError',
|
|
403
|
+
message: 'Pre-request script produced a result that cannot be returned: ' +
|
|
404
|
+
(again && again.message ? again.message : String(again)),
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
process.send({ ready: true });
|
|
411
|
+
`;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import RE2 from 're2';
|
|
2
|
+
/**
|
|
3
|
+
* Compila el patrón con RE2, o devuelve `null` si no se puede.
|
|
4
|
+
*
|
|
5
|
+
* `null` cubre las dos formas de no poder, que desde fuera se tratan igual:
|
|
6
|
+
* el patrón está mal escrito, o usa algo que RE2 no implementa.
|
|
7
|
+
*/
|
|
8
|
+
export declare function compilarRegexDeInquilino(patron: string, donde: string,
|
|
9
|
+
/** Los mismos de `RegExp`. El filtro de RSS usa `i`. */
|
|
10
|
+
flags?: string): RE2 | null;
|
|
11
|
+
/**
|
|
12
|
+
* `patron.test(cadena)` con el motor seguro. `false` cuando el patrón no
|
|
13
|
+
* compila, que es lo que estos dos sitios ya devolvían.
|
|
14
|
+
*/
|
|
15
|
+
export declare function casaRegexDeInquilino(patron: string, cadena: unknown, donde: string): boolean;
|
|
@@ -0,0 +1,98 @@
|
|
|
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.compilarRegexDeInquilino = compilarRegexDeInquilino;
|
|
7
|
+
exports.casaRegexDeInquilino = casaRegexDeInquilino;
|
|
8
|
+
const common_1 = require("@nestjs/common");
|
|
9
|
+
const re2_1 = __importDefault(require("re2"));
|
|
10
|
+
/**
|
|
11
|
+
* Las expresiones regulares que escribe un inquilino corren en RE2, no en el
|
|
12
|
+
* motor de JavaScript.
|
|
13
|
+
*
|
|
14
|
+
* ## El fallo
|
|
15
|
+
*
|
|
16
|
+
* Había dos sitios donde un patrón del inquilino se compilaba y se corría
|
|
17
|
+
* contra cadenas del payload, en el hilo de la petición:
|
|
18
|
+
*
|
|
19
|
+
* - `filter-utils.ts`, operador `matches_regex`;
|
|
20
|
+
* - `schema-validator-utils.ts`, la restricción `pattern`.
|
|
21
|
+
*
|
|
22
|
+
* El motor de JavaScript resuelve por retroceso, así que un patrón como
|
|
23
|
+
* `(a+)+$` contra una cadena de treinta letras tarda más que la vida útil del
|
|
24
|
+
* servidor. Y no hace falta ser el dueño del patrón para dispararlo: la cadena
|
|
25
|
+
* contra la que se prueba viene del payload, y el payload entra por
|
|
26
|
+
* `/api/in/:token`, que es público. Un solo evento deja un worker girando.
|
|
27
|
+
*
|
|
28
|
+
* Un `setTimeout` no lo corta: una expresión regular de JavaScript es
|
|
29
|
+
* síncrona, no cede el hilo, y no hay forma de interrumpirla desde dentro del
|
|
30
|
+
* proceso. Por eso el arreglo no puede ser «ponerle un límite de tiempo».
|
|
31
|
+
*
|
|
32
|
+
* ## Por qué RE2
|
|
33
|
+
*
|
|
34
|
+
* RE2 no retrocede: recorre el autómata en tiempo lineal sobre la longitud de
|
|
35
|
+
* la cadena, pase lo que pase con el patrón. No es un límite ni una heurística
|
|
36
|
+
* — es que la clase de fallo no existe en ese motor.
|
|
37
|
+
*
|
|
38
|
+
* ## Lo que RE2 NO acepta, que es el precio
|
|
39
|
+
*
|
|
40
|
+
* Precisamente porque no retrocede, RE2 no implementa lo que sólo se puede
|
|
41
|
+
* resolver retrocediendo: las referencias hacia atrás (`\1`) y las miradas
|
|
42
|
+
* adelante/atrás (`(?=…)`, `(?!…)`, `(?<=…)`). Un patrón que las use deja de
|
|
43
|
+
* compilar.
|
|
44
|
+
*
|
|
45
|
+
* Eso es un cambio de comportamiento y no se esconde: `compilar` devuelve
|
|
46
|
+
* `null` y deja que cada sitio decida cómo contarlo — el validador de esquema
|
|
47
|
+
* ya tenía un mensaje para «patrón inválido» y lo reutiliza; el filtro no
|
|
48
|
+
* casa, como ya hacía con cualquier patrón que no compilara, pero además lo
|
|
49
|
+
* registra, porque «este filtro dejó de casar en silencio» es un mal día para
|
|
50
|
+
* quien lo escribió.
|
|
51
|
+
*
|
|
52
|
+
* ## Sin caché, a propósito
|
|
53
|
+
*
|
|
54
|
+
* Tentaba guardar los compilados en un Map. La clave sería el patrón, y el
|
|
55
|
+
* patrón lo elige el inquilino: eso es exactamente un Map sin tope con clave
|
|
56
|
+
* que controla otro, que es otro de los hallazgos de este mismo informe. Los
|
|
57
|
+
* dos sitios ya compilaban en cada llamada antes de esto, así que no se pierde
|
|
58
|
+
* nada.
|
|
59
|
+
*/
|
|
60
|
+
const logger = new common_1.Logger('RegexDeInquilino');
|
|
61
|
+
/** Máximo del patrón. Uno más largo que esto no es un filtro, es otra cosa. */
|
|
62
|
+
const MAX_PATRON = 1_000;
|
|
63
|
+
/**
|
|
64
|
+
* Compila el patrón con RE2, o devuelve `null` si no se puede.
|
|
65
|
+
*
|
|
66
|
+
* `null` cubre las dos formas de no poder, que desde fuera se tratan igual:
|
|
67
|
+
* el patrón está mal escrito, o usa algo que RE2 no implementa.
|
|
68
|
+
*/
|
|
69
|
+
function compilarRegexDeInquilino(patron, donde,
|
|
70
|
+
/** Los mismos de `RegExp`. El filtro de RSS usa `i`. */
|
|
71
|
+
flags) {
|
|
72
|
+
if (typeof patron !== 'string' || patron.length === 0)
|
|
73
|
+
return null;
|
|
74
|
+
if (patron.length > MAX_PATRON) {
|
|
75
|
+
logger.warn(`[${donde}] patrón de ${patron.length} caracteres ignorado (máximo ${MAX_PATRON})`);
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
return flags ? new re2_1.default(patron, flags) : new re2_1.default(patron);
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
/* Se registra en vez de tragárselo porque el motivo casi siempre es
|
|
83
|
+
«RE2 no soporta esto», y sin la línea el patrón simplemente deja de
|
|
84
|
+
casar y nadie sabe por qué. */
|
|
85
|
+
logger.warn(`[${donde}] patrón rechazado: ${patron.slice(0, 120)} — ${err instanceof Error ? err.message : String(err)}`);
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* `patron.test(cadena)` con el motor seguro. `false` cuando el patrón no
|
|
91
|
+
* compila, que es lo que estos dos sitios ya devolvían.
|
|
92
|
+
*/
|
|
93
|
+
function casaRegexDeInquilino(patron, cadena, donde) {
|
|
94
|
+
if (typeof cadena !== 'string')
|
|
95
|
+
return false;
|
|
96
|
+
const re = compilarRegexDeInquilino(patron, donde);
|
|
97
|
+
return re ? re.test(cadena) : false;
|
|
98
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Request } from 'express';
|
|
2
|
+
import type { IdentidadDePeticion as UserDocument } from './contratos';
|
|
3
|
+
export declare function getUser(req: Request): UserDocument;
|
|
4
|
+
export declare function getUserId(req: Request): string;
|
|
5
|
+
export declare function getOrganizationId(req: Request): string;
|
|
6
|
+
/**
|
|
7
|
+
* ¿Tiene quien llama este permiso?
|
|
8
|
+
*
|
|
9
|
+
* Una sesión del panel actúa por la cuenta entera, así que los tiene todos —
|
|
10
|
+
* es la misma distinción que lee `ScopesGuard`, y la que marca la estrategia
|
|
11
|
+
* de API key con `__isApiKey`. Una llave sólo tiene lo que se le acuñó.
|
|
12
|
+
*
|
|
13
|
+
* Existe porque un `@RequiredScopes(...)` en la ruta es todo o nada, y hay
|
|
14
|
+
* respuestas donde un CAMPO concreto vale más que el resto: el token de
|
|
15
|
+
* ingress de un webhook deja publicar eventos en él, así que no puede viajar
|
|
16
|
+
* en una respuesta que sólo pide permiso de lectura de eventos.
|
|
17
|
+
*/
|
|
18
|
+
export declare function tienePermiso(req: Request, scope: string): boolean;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.getUser = getUser;
|
|
4
|
+
exports.getUserId = getUserId;
|
|
5
|
+
exports.getOrganizationId = getOrganizationId;
|
|
6
|
+
exports.tienePermiso = tienePermiso;
|
|
7
|
+
function getUser(req) {
|
|
8
|
+
return req.user;
|
|
9
|
+
}
|
|
10
|
+
function getUserId(req) {
|
|
11
|
+
return getUser(req)._id.toString();
|
|
12
|
+
}
|
|
13
|
+
function getOrganizationId(req) {
|
|
14
|
+
const user = getUser(req);
|
|
15
|
+
return (user.organizationId ?? user._id).toString();
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* ¿Tiene quien llama este permiso?
|
|
19
|
+
*
|
|
20
|
+
* Una sesión del panel actúa por la cuenta entera, así que los tiene todos —
|
|
21
|
+
* es la misma distinción que lee `ScopesGuard`, y la que marca la estrategia
|
|
22
|
+
* de API key con `__isApiKey`. Una llave sólo tiene lo que se le acuñó.
|
|
23
|
+
*
|
|
24
|
+
* Existe porque un `@RequiredScopes(...)` en la ruta es todo o nada, y hay
|
|
25
|
+
* respuestas donde un CAMPO concreto vale más que el resto: el token de
|
|
26
|
+
* ingress de un webhook deja publicar eventos en él, así que no puede viajar
|
|
27
|
+
* en una respuesta que sólo pide permiso de lectura de eventos.
|
|
28
|
+
*/
|
|
29
|
+
function tienePermiso(req, scope) {
|
|
30
|
+
const user = getUser(req);
|
|
31
|
+
if (!user?.__isApiKey)
|
|
32
|
+
return true;
|
|
33
|
+
return (user.__apiKeyScopes ?? []).includes(scope);
|
|
34
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retry helper for pipeline nodes that make external HTTP requests.
|
|
3
|
+
* Retries on transient status codes (429, 502, 503, 504), waiting as long as
|
|
4
|
+
* the server asked when it said (`NodeResult.retryAfterMs`) and falling back
|
|
5
|
+
* to linear backoff when it didn't, plus jitter either way.
|
|
6
|
+
* Fire-and-forget safe — no state, no side effects.
|
|
7
|
+
*
|
|
8
|
+
* IMPORTANT: 500 is NOT retryable by default — a 500 from our own
|
|
9
|
+
* catch block (import error, parse failure, etc.) is NOT transient.
|
|
10
|
+
* Only external gateway errors (502/503/504) and rate limits (429) are.
|
|
11
|
+
*
|
|
12
|
+
* If fn() throws an exception (instead of returning a result),
|
|
13
|
+
* the error is caught and returned as statusCode 520 (internal error)
|
|
14
|
+
* WITHOUT retrying — thrown errors are bugs, not transient failures.
|
|
15
|
+
*/
|
|
16
|
+
export interface NodeResult {
|
|
17
|
+
statusCode: number;
|
|
18
|
+
responseBody: string;
|
|
19
|
+
latencyMs: number;
|
|
20
|
+
/** Set to true when retryOnTransientError retried at least once */
|
|
21
|
+
_retriedTransient?: boolean;
|
|
22
|
+
/**
|
|
23
|
+
* How long the *server* said to wait before trying again, in ms — Discord's
|
|
24
|
+
* `retry_after`, a `Retry-After` header, anything of that shape. When set it
|
|
25
|
+
* replaces the computed backoff, because a guess that lands before the
|
|
26
|
+
* window reopens just burns an attempt. Control-plane only: it rides
|
|
27
|
+
* alongside the result like `_retriedTransient` and never reaches the
|
|
28
|
+
* downstream payload, which is built from `responseBody`.
|
|
29
|
+
*/
|
|
30
|
+
retryAfterMs?: number;
|
|
31
|
+
}
|
|
32
|
+
export interface RetryOptions {
|
|
33
|
+
/** Max retries after first attempt (default 2 → 3 total attempts) */
|
|
34
|
+
maxRetries?: number;
|
|
35
|
+
/** Status codes to retry on (default: 429, 502, 503, 504) */
|
|
36
|
+
retryableStatuses?: Set<number>;
|
|
37
|
+
/** Base backoff delay in ms, multiplied by attempt (default 3000) */
|
|
38
|
+
backoffMs?: number;
|
|
39
|
+
/**
|
|
40
|
+
* Ceiling for a single wait, in ms (default 15000). Applies to a
|
|
41
|
+
* server-stated `retryAfterMs` too: a node that blocks for the four minutes
|
|
42
|
+
* a global rate limit can ask for is worse than a node that gives up.
|
|
43
|
+
*/
|
|
44
|
+
maxBackoffMs?: number;
|
|
45
|
+
/** Logger for retry warnings */
|
|
46
|
+
logger?: {
|
|
47
|
+
warn: (msg: string) => void;
|
|
48
|
+
log: (msg: string) => void;
|
|
49
|
+
error?: (msg: string) => void;
|
|
50
|
+
};
|
|
51
|
+
/** Label for log messages (e.g. 'Firecrawl "Get Udemy"') */
|
|
52
|
+
label?: string;
|
|
53
|
+
}
|
|
54
|
+
export declare function retryOnTransientError(fn: () => Promise<NodeResult>, opts?: RetryOptions): Promise<NodeResult>;
|