@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,917 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.URI_SCHEMES = exports.URI_GUARD_FAMILY = void 0;
|
|
37
|
+
exports.assertSafeMongoUri = assertSafeMongoUri;
|
|
38
|
+
exports.maskMongoUri = maskMongoUri;
|
|
39
|
+
exports.assertSafeMongoUriUnlessTunneled = assertSafeMongoUriUnlessTunneled;
|
|
40
|
+
exports.assertSafePostgresUri = assertSafePostgresUri;
|
|
41
|
+
exports.assertSafePostgresUriUnlessTunneled = assertSafePostgresUriUnlessTunneled;
|
|
42
|
+
exports.isGuardedUriCredential = isGuardedUriCredential;
|
|
43
|
+
exports.assertUriSchemeMatchesType = assertUriSchemeMatchesType;
|
|
44
|
+
exports.assertSafeCredentialUri = assertSafeCredentialUri;
|
|
45
|
+
exports.assertSafeCredentialUriUnlessTunneled = assertSafeCredentialUriUnlessTunneled;
|
|
46
|
+
exports.maskPostgresUri = maskPostgresUri;
|
|
47
|
+
exports.parseFirstMongoHost = parseFirstMongoHost;
|
|
48
|
+
exports.rewriteMongoUriHost = rewriteMongoUriHost;
|
|
49
|
+
exports.parseFirstPostgresHost = parseFirstPostgresHost;
|
|
50
|
+
exports.baseDeLaUri = baseDeLaUri;
|
|
51
|
+
exports.rewritePostgresUriDatabase = rewritePostgresUriDatabase;
|
|
52
|
+
exports.rewritePostgresUriHost = rewritePostgresUriHost;
|
|
53
|
+
exports.isPrivateAddress = isPrivateAddress;
|
|
54
|
+
exports.assertSafeHttpUrl = assertSafeHttpUrl;
|
|
55
|
+
exports.agentesDeEntrega = agentesDeEntrega;
|
|
56
|
+
exports.despachadorDeSalida = despachadorDeSalida;
|
|
57
|
+
exports.fetchSeguro = fetchSeguro;
|
|
58
|
+
const common_1 = require("@nestjs/common");
|
|
59
|
+
const node_dns_1 = require("node:dns");
|
|
60
|
+
const node_net_1 = require("node:net");
|
|
61
|
+
const http = __importStar(require("node:http"));
|
|
62
|
+
const https = __importStar(require("node:https"));
|
|
63
|
+
const undici_1 = require("undici");
|
|
64
|
+
/**
|
|
65
|
+
* SSRF guard for user-supplied connection URIs (MongoDB, HTTP outbound, etc).
|
|
66
|
+
*
|
|
67
|
+
* Refuses any host that resolves to an internal / private / loopback IP
|
|
68
|
+
* range. Without this, a tenant could point a connection string at our
|
|
69
|
+
* own infra (`mongodb://localhost:27017/admin`, the metadata service IP
|
|
70
|
+
* `169.254.169.254` on cloud VMs, an internal `10.0.x.y` Mongo, etc.)
|
|
71
|
+
* and use the credential decrypt + connect path to read or write into
|
|
72
|
+
* resources that don't belong to them. We also re-resolve at runtime
|
|
73
|
+
* so a DNS-rebinding attack — host that resolves to a public IP at
|
|
74
|
+
* save time and a private one at use time — gets caught.
|
|
75
|
+
*/
|
|
76
|
+
const PRIVATE_V4_RANGES = [
|
|
77
|
+
/^10\./,
|
|
78
|
+
/^127\./,
|
|
79
|
+
/^169\.254\./,
|
|
80
|
+
/^172\.(1[6-9]|2\d|3[0-1])\./,
|
|
81
|
+
/^192\.168\./,
|
|
82
|
+
/^0\./,
|
|
83
|
+
];
|
|
84
|
+
/**
|
|
85
|
+
* The IPv6 ranges that must never be a connection target, as real CIDRs.
|
|
86
|
+
*
|
|
87
|
+
* This used to be a list of string prefixes — `['::1', 'fc', 'fd', 'fe80']`
|
|
88
|
+
* matched with `startsWith`. That let two things through. Link-local is
|
|
89
|
+
* fe80::/10, i.e. fe80 through febf, so every `fe90:`/`fea0:`/`feb0:`
|
|
90
|
+
* address read as public. And any spelling that did not literally begin
|
|
91
|
+
* with those characters missed entirely — `0:0:0:0:0:0:0:1` is the expanded
|
|
92
|
+
* form of `::1` and passed the check.
|
|
93
|
+
*/
|
|
94
|
+
const PRIVATE_V6_CIDRS = [
|
|
95
|
+
['::', 128], // unspecified — routes to localhost on most stacks
|
|
96
|
+
['::1', 128], // loopback
|
|
97
|
+
['fc00::', 7], // unique local (covers the old 'fc' and 'fd' prefixes)
|
|
98
|
+
['fe80::', 10], // link-local
|
|
99
|
+
];
|
|
100
|
+
const BLOCKED_HOSTNAMES = new Set([
|
|
101
|
+
'localhost',
|
|
102
|
+
'localhost.localdomain',
|
|
103
|
+
'ip6-localhost',
|
|
104
|
+
'ip6-loopback',
|
|
105
|
+
'metadata.google.internal',
|
|
106
|
+
'metadata',
|
|
107
|
+
]);
|
|
108
|
+
/**
|
|
109
|
+
* Expand any IPv6 spelling to its 128-bit value, so ranges can be tested by
|
|
110
|
+
* containment instead of by how the address happens to be written.
|
|
111
|
+
* Returns null when the text is not a well-formed IPv6 literal.
|
|
112
|
+
*/
|
|
113
|
+
function ipv6ToBigInt(ip) {
|
|
114
|
+
let s = ip.toLowerCase();
|
|
115
|
+
// Zone index (fe80::1%eth0) names an interface, not part of the address.
|
|
116
|
+
const zone = s.indexOf('%');
|
|
117
|
+
if (zone >= 0)
|
|
118
|
+
s = s.slice(0, zone);
|
|
119
|
+
// A trailing dotted-quad is the low 32 bits written in IPv4 notation.
|
|
120
|
+
const v4 = /(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(s);
|
|
121
|
+
if (v4) {
|
|
122
|
+
const octets = v4[1].split('.').map(Number);
|
|
123
|
+
if (octets.some((o) => !Number.isInteger(o) || o < 0 || o > 255))
|
|
124
|
+
return null;
|
|
125
|
+
const hi = ((octets[0] << 8) | octets[1]).toString(16);
|
|
126
|
+
const lo = ((octets[2] << 8) | octets[3]).toString(16);
|
|
127
|
+
s = s.slice(0, v4.index) + hi + ':' + lo;
|
|
128
|
+
}
|
|
129
|
+
const halves = s.split('::');
|
|
130
|
+
if (halves.length > 2)
|
|
131
|
+
return null;
|
|
132
|
+
const head = halves[0] ? halves[0].split(':') : [];
|
|
133
|
+
const tail = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
|
|
134
|
+
let groups;
|
|
135
|
+
if (halves.length === 2) {
|
|
136
|
+
const missing = 8 - head.length - tail.length;
|
|
137
|
+
if (missing < 0)
|
|
138
|
+
return null;
|
|
139
|
+
groups = [...head, ...Array(missing).fill('0'), ...tail];
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
if (head.length !== 8)
|
|
143
|
+
return null;
|
|
144
|
+
groups = head;
|
|
145
|
+
}
|
|
146
|
+
let out = 0n;
|
|
147
|
+
for (const g of groups) {
|
|
148
|
+
if (!/^[0-9a-f]{1,4}$/.test(g))
|
|
149
|
+
return null;
|
|
150
|
+
out = (out << 16n) | BigInt(parseInt(g, 16));
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
/** Is `addr` inside `base/bits`? */
|
|
155
|
+
function inV6Cidr(addr, base, bits) {
|
|
156
|
+
const baseInt = ipv6ToBigInt(base);
|
|
157
|
+
if (baseInt === null)
|
|
158
|
+
return false;
|
|
159
|
+
const mask = bits === 0 ? 0n : ((1n << BigInt(bits)) - 1n) << BigInt(128 - bits);
|
|
160
|
+
return (addr & mask) === (baseInt & mask);
|
|
161
|
+
}
|
|
162
|
+
function isPrivateV4(ip) {
|
|
163
|
+
return PRIVATE_V4_RANGES.some((re) => re.test(ip));
|
|
164
|
+
}
|
|
165
|
+
function intToIpv4(n) {
|
|
166
|
+
return [
|
|
167
|
+
(n >>> 24) & 0xff,
|
|
168
|
+
(n >>> 16) & 0xff,
|
|
169
|
+
(n >>> 8) & 0xff,
|
|
170
|
+
n & 0xff,
|
|
171
|
+
].join('.');
|
|
172
|
+
}
|
|
173
|
+
function isPrivateIp(ip) {
|
|
174
|
+
const family = (0, node_net_1.isIP)(ip);
|
|
175
|
+
if (family === 4)
|
|
176
|
+
return isPrivateV4(ip);
|
|
177
|
+
if (family !== 6)
|
|
178
|
+
return false;
|
|
179
|
+
const addr = ipv6ToBigInt(ip);
|
|
180
|
+
// node's isIP accepted it but this parser did not. Refuse rather than
|
|
181
|
+
// treat an address we cannot read as safe.
|
|
182
|
+
if (addr === null)
|
|
183
|
+
return true;
|
|
184
|
+
// IPv4-mapped (::ffff:a.b.c.d) and IPv4-compatible (::a.b.c.d) carry an
|
|
185
|
+
// IPv4 address in their low 32 bits and reach exactly that address, so
|
|
186
|
+
// they have to face the IPv4 test. This is done on the parsed value, not
|
|
187
|
+
// on the text: `::ffff:127.0.0.1` and `::ffff:7f00:1` are the same
|
|
188
|
+
// address, and node's URL parser hands back the second spelling — so
|
|
189
|
+
// unwrapping by regex would have missed the very form the platform sees.
|
|
190
|
+
const high96 = addr >> 32n;
|
|
191
|
+
const low32 = Number(addr & 0xffffffffn);
|
|
192
|
+
// `::` and `::1` sit in ::/96 too, but they are addresses in their own
|
|
193
|
+
// right rather than embedded IPv4 — the CIDR list below owns them.
|
|
194
|
+
if (high96 === 0xffffn || (high96 === 0n && low32 > 1)) {
|
|
195
|
+
return isPrivateV4(intToIpv4(low32));
|
|
196
|
+
}
|
|
197
|
+
return PRIVATE_V6_CIDRS.some(([base, bits]) => inV6Cidr(addr, base, bits));
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Parse the bare hostnames + the SRV flag out of a `mongodb://` or
|
|
201
|
+
* `mongodb+srv://` URI. Returns array of hostnames (no port) plus a
|
|
202
|
+
* boolean telling the caller whether to use SRV-style DNS lookup.
|
|
203
|
+
*
|
|
204
|
+
* `mongodb+srv://` URIs name a SINGLE hostname that itself has no
|
|
205
|
+
* A/AAAA record — Atlas-style. The SRV record at
|
|
206
|
+
* `_mongodb._tcp.<host>` returns the actual cluster nodes that we
|
|
207
|
+
* have to validate. Legacy `mongodb://` URIs may have a comma-
|
|
208
|
+
* separated host list for replica sets.
|
|
209
|
+
*/
|
|
210
|
+
function extractMongoHosts(uri) {
|
|
211
|
+
const match = /^mongodb(\+srv)?:\/\/(?:[^@/]*@)?([^/?]+)/i.exec(uri);
|
|
212
|
+
if (!match)
|
|
213
|
+
throw new common_1.ForbiddenException('Connection string is not a valid mongodb:// URI');
|
|
214
|
+
const srv = !!match[1];
|
|
215
|
+
const hostList = match[2];
|
|
216
|
+
const hosts = hostList
|
|
217
|
+
.split(',')
|
|
218
|
+
.map((h) => h.trim())
|
|
219
|
+
.map((h) => {
|
|
220
|
+
// Bracket-aware: `[::1]:27017` is an IPv6 literal plus a port, and the
|
|
221
|
+
// brackets are what say where the address ends. Strip the port only
|
|
222
|
+
// once they are gone — and never from a bare IPv6 literal, or `::1`
|
|
223
|
+
// would lose its last group and stop looking like an address at all.
|
|
224
|
+
const bracketed = /^\[([^\]]+)\](?::\d+)?$/.exec(h);
|
|
225
|
+
if (bracketed)
|
|
226
|
+
return bracketed[1];
|
|
227
|
+
if ((0, node_net_1.isIP)(h) === 6)
|
|
228
|
+
return h;
|
|
229
|
+
return h.replace(/:\d+$/, '');
|
|
230
|
+
})
|
|
231
|
+
.filter(Boolean);
|
|
232
|
+
return { hosts, srv };
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Resolve a hostname to its real cluster nodes. For `mongodb+srv://`
|
|
236
|
+
* the host itself (e.g. `cluster0.abc123.mongodb.net`) typically has
|
|
237
|
+
* NO A record — Atlas only publishes the SRV at
|
|
238
|
+
* `_mongodb._tcp.<host>`. Look that up first; fall back to a direct
|
|
239
|
+
* A/AAAA lookup on the bare hostname for legacy `mongodb://` URIs and
|
|
240
|
+
* self-hosted clusters that publish SRV anyway.
|
|
241
|
+
*/
|
|
242
|
+
async function resolveMongoHost(host, srv) {
|
|
243
|
+
const ips = [];
|
|
244
|
+
if (srv) {
|
|
245
|
+
try {
|
|
246
|
+
const srvRecords = await node_dns_1.promises.resolveSrv(`_mongodb._tcp.${host}`);
|
|
247
|
+
const targetHosts = srvRecords.map((r) => r.name);
|
|
248
|
+
for (const target of targetHosts) {
|
|
249
|
+
const v4 = await node_dns_1.promises.resolve4(target).catch(() => []);
|
|
250
|
+
const v6 = await node_dns_1.promises.resolve6(target).catch(() => []);
|
|
251
|
+
ips.push(...v4, ...v6);
|
|
252
|
+
}
|
|
253
|
+
// SRV worked — return what we got, even if the per-node A/AAAA
|
|
254
|
+
// lookups all failed (mongoose driver will surface a clearer
|
|
255
|
+
// error than us at connect time).
|
|
256
|
+
return ips;
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
// SRV lookup failed — fall through to direct A/AAAA below. Some
|
|
260
|
+
// self-hosted setups use mongodb+srv:// for the URI shape but
|
|
261
|
+
// don't actually publish SRV records.
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
const v4 = await node_dns_1.promises.resolve4(host).catch(() => []);
|
|
265
|
+
const v6 = await node_dns_1.promises.resolve6(host).catch(() => []);
|
|
266
|
+
return [...v4, ...v6];
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Throw if the URI's host(s) point at private / loopback / link-local
|
|
270
|
+
* addresses, OR if their DNS resolves there. mongodb+srv URIs are
|
|
271
|
+
* handled via SRV record lookup (the `<host>.mongodb.net` pattern
|
|
272
|
+
* Atlas uses doesn't have A/AAAA records — only the SRV at
|
|
273
|
+
* `_mongodb._tcp.<host>` does).
|
|
274
|
+
*/
|
|
275
|
+
async function assertSafeMongoUri(uri) {
|
|
276
|
+
const { hosts, srv } = extractMongoHosts(uri);
|
|
277
|
+
for (const host of hosts) {
|
|
278
|
+
const lowered = host.toLowerCase();
|
|
279
|
+
if (BLOCKED_HOSTNAMES.has(lowered)) {
|
|
280
|
+
throw new common_1.ForbiddenException(`Connection to ${host} is not allowed`);
|
|
281
|
+
}
|
|
282
|
+
// Literal IP in the URI — check directly, no DNS needed.
|
|
283
|
+
if ((0, node_net_1.isIP)(host)) {
|
|
284
|
+
if (isPrivateIp(host)) {
|
|
285
|
+
throw new common_1.ForbiddenException(`Connection to private IP ${host} is not allowed`);
|
|
286
|
+
}
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
// DNS lookup — guards against rebinding attacks (host resolves to
|
|
290
|
+
// a public IP at save time and a private one when we actually
|
|
291
|
+
// connect later). For mongodb+srv URIs we resolve via SRV first.
|
|
292
|
+
let resolved;
|
|
293
|
+
try {
|
|
294
|
+
resolved = await resolveMongoHost(host, srv);
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
throw new common_1.ForbiddenException(`Could not resolve host ${host}`);
|
|
298
|
+
}
|
|
299
|
+
if (resolved.length === 0) {
|
|
300
|
+
throw new common_1.ForbiddenException(`Could not resolve host ${host}${srv ? ' (no SRV record at _mongodb._tcp.' + host + ' and no A record on the host either)' : ''}`);
|
|
301
|
+
}
|
|
302
|
+
for (const ip of resolved) {
|
|
303
|
+
if (isPrivateIp(ip)) {
|
|
304
|
+
throw new common_1.ForbiddenException(`Host ${host} resolves to private IP ${ip} — refusing to connect`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
/** Mask the credentials portion of a Mongo URI for logs / UI. */
|
|
310
|
+
function maskMongoUri(uri) {
|
|
311
|
+
return uri.replace(/^(mongodb(?:\+srv)?:\/\/)([^:]+):([^@]+)@/i, '$1$2:***@');
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Tunnel-aware variant: when `tunnelId` is set, the connection is going to
|
|
315
|
+
* route through a customer-installed agent (`hostwh expose-tcp`), so the
|
|
316
|
+
* URI's host is the customer's local network — `localhost` / `127.0.0.1` /
|
|
317
|
+
* `192.168.x.x` are all expected and safe (they're THEIR localhost, not
|
|
318
|
+
* ours). Skip the SSRF check entirely.
|
|
319
|
+
*
|
|
320
|
+
* Without a tunnel, behave exactly like assertSafeMongoUri.
|
|
321
|
+
*/
|
|
322
|
+
async function assertSafeMongoUriUnlessTunneled(uri, tunnelId) {
|
|
323
|
+
if (tunnelId)
|
|
324
|
+
return;
|
|
325
|
+
await assertSafeMongoUri(uri);
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Parse the host out of a `postgres://` or `postgresql://` URI. Postgres
|
|
329
|
+
* URIs are simpler than mongo — single host, optional port, no SRV.
|
|
330
|
+
*/
|
|
331
|
+
function extractPostgresHost(uri) {
|
|
332
|
+
let url;
|
|
333
|
+
try {
|
|
334
|
+
url = new URL(uri);
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
throw new common_1.ForbiddenException('Connection string is not a valid postgres:// URI');
|
|
338
|
+
}
|
|
339
|
+
if (url.protocol !== 'postgres:' && url.protocol !== 'postgresql:') {
|
|
340
|
+
throw new common_1.ForbiddenException('Connection string is not a valid postgres:// URI');
|
|
341
|
+
}
|
|
342
|
+
if (!url.hostname) {
|
|
343
|
+
throw new common_1.ForbiddenException('Connection string is missing a host');
|
|
344
|
+
}
|
|
345
|
+
// WHATWG URL keeps the brackets on an IPv6 host (`[::1]`), and with them
|
|
346
|
+
// the string is not an IP literal any more — it fell through to the DNS
|
|
347
|
+
// path, where it could only ever fail to resolve. Strip them so an IPv6
|
|
348
|
+
// postgres host is actually classified.
|
|
349
|
+
return url.hostname.replace(/^\[(.+)\]$/, '$1');
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Throw if the postgres URI's host points at private / loopback /
|
|
353
|
+
* link-local addresses, OR if its DNS resolves there. Mirrors
|
|
354
|
+
* assertSafeMongoUri but with the simpler postgres URI grammar.
|
|
355
|
+
*/
|
|
356
|
+
async function assertSafePostgresUri(uri) {
|
|
357
|
+
const host = extractPostgresHost(uri);
|
|
358
|
+
const lowered = host.toLowerCase();
|
|
359
|
+
if (BLOCKED_HOSTNAMES.has(lowered)) {
|
|
360
|
+
throw new common_1.ForbiddenException(`Connection to ${host} is not allowed`);
|
|
361
|
+
}
|
|
362
|
+
if ((0, node_net_1.isIP)(host)) {
|
|
363
|
+
if (isPrivateIp(host)) {
|
|
364
|
+
throw new common_1.ForbiddenException(`Connection to private IP ${host} is not allowed`);
|
|
365
|
+
}
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
let resolved;
|
|
369
|
+
try {
|
|
370
|
+
const v4 = await node_dns_1.promises.resolve4(host).catch(() => []);
|
|
371
|
+
const v6 = await node_dns_1.promises.resolve6(host).catch(() => []);
|
|
372
|
+
resolved = [...v4, ...v6];
|
|
373
|
+
}
|
|
374
|
+
catch {
|
|
375
|
+
throw new common_1.ForbiddenException(`Could not resolve host ${host}`);
|
|
376
|
+
}
|
|
377
|
+
if (resolved.length === 0) {
|
|
378
|
+
throw new common_1.ForbiddenException(`Could not resolve host ${host}`);
|
|
379
|
+
}
|
|
380
|
+
for (const ip of resolved) {
|
|
381
|
+
if (isPrivateIp(ip)) {
|
|
382
|
+
throw new common_1.ForbiddenException(`Host ${host} resolves to private IP ${ip} — refusing to connect`);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Tunnel-aware variant for postgres — same semantics as the mongo one.
|
|
388
|
+
* When tunnelId is set, the URI describes the customer's own network
|
|
389
|
+
* reachable through their `hostwh expose --tcp` agent, so private IPs
|
|
390
|
+
* are intentional and we skip the SSRF check.
|
|
391
|
+
*/
|
|
392
|
+
async function assertSafePostgresUriUnlessTunneled(uri, tunnelId) {
|
|
393
|
+
if (tunnelId)
|
|
394
|
+
return;
|
|
395
|
+
await assertSafePostgresUri(uri);
|
|
396
|
+
}
|
|
397
|
+
exports.URI_GUARD_FAMILY = {
|
|
398
|
+
mongodb: 'mongo',
|
|
399
|
+
memory_mongodb: 'mongo',
|
|
400
|
+
postgres: 'postgres',
|
|
401
|
+
};
|
|
402
|
+
/** True when this credential type carries a connection string we must guard. */
|
|
403
|
+
function isGuardedUriCredential(type) {
|
|
404
|
+
return type in exports.URI_GUARD_FAMILY;
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Los esquemas que puede llevar la URI de cada familia.
|
|
408
|
+
*
|
|
409
|
+
* Va aquí, junto a `URI_GUARD_FAMILY`, y no en un `if` suelto dentro del
|
|
410
|
+
* servicio: el tipo `Record<UriGuardFamily, …>` obliga a que una familia
|
|
411
|
+
* nueva declare los suyos o no compila, que es la misma disciplina que ya
|
|
412
|
+
* aplica el registro de guardias.
|
|
413
|
+
*/
|
|
414
|
+
exports.URI_SCHEMES = {
|
|
415
|
+
mongo: ['mongodb:', 'mongodb+srv:'],
|
|
416
|
+
postgres: ['postgres:', 'postgresql:'],
|
|
417
|
+
};
|
|
418
|
+
/** Cómo se llama cada familia cuando hay que decírselo a una persona. */
|
|
419
|
+
const NOMBRE_DE_FAMILIA = {
|
|
420
|
+
mongo: 'MongoDB',
|
|
421
|
+
postgres: 'PostgreSQL',
|
|
422
|
+
};
|
|
423
|
+
/**
|
|
424
|
+
* El esquema tal y como está escrito, sin construir un `URL`.
|
|
425
|
+
*
|
|
426
|
+
* Se lee a mano a propósito: `new URL()` acepta cosas que aquí no queremos
|
|
427
|
+
* dar por buenas y, sobre todo, LANZA con una cadena sin esquema, y ese
|
|
428
|
+
* fallo hay que contarlo con nuestras palabras y no con las del motor.
|
|
429
|
+
*/
|
|
430
|
+
function esquemaDe(uri) {
|
|
431
|
+
/* Se exige el `://` entero, no sólo los dos puntos: sin eso, `localhost:27017`
|
|
432
|
+
hacía pasar `localhost` por esquema y el error hablaba de un inexistente
|
|
433
|
+
`localhost://` en vez de decir que ahí falta el esquema. Lo cazó su test. */
|
|
434
|
+
const encontrado = /^([a-zA-Z][a-zA-Z0-9+.-]*):[/][/]/.exec(uri.trim());
|
|
435
|
+
return encontrado ? `${encontrado[1].toLowerCase()}:` : null;
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Que la cadena de conexión sea del producto que dice el tipo de credencial.
|
|
439
|
+
*
|
|
440
|
+
* Faltaba, y no se notaba porque los dos sitios que miraban la URI se
|
|
441
|
+
* rinden antes en el caso más común:
|
|
442
|
+
*
|
|
443
|
+
* - el guardia SSRF vuelve en cuanto la credencial lleva `tunnelId`, porque
|
|
444
|
+
* entonces la URI describe la red del propio cliente;
|
|
445
|
+
* - la comprobación de la base de Mongo vuelve si no se tecleó ninguna.
|
|
446
|
+
*
|
|
447
|
+
* Con las dos condiciones a la vez —una credencial con túnel y sin base, que
|
|
448
|
+
* es justo lo que sale del flujo de túneles— NADIE llegaba a mirar la cadena.
|
|
449
|
+
* Una `postgres://` se guardaba como MongoDB sin una palabra, y el error
|
|
450
|
+
* aparecía mucho después dentro de un nodo, en boca del driver:
|
|
451
|
+
* «Connection string is not a valid mongodb:// URI», que no nombra lo único
|
|
452
|
+
* que hacía falta saber: que eso era de Postgres.
|
|
453
|
+
*
|
|
454
|
+
* Por eso esto NO mira el túnel ni la base: corre siempre.
|
|
455
|
+
*/
|
|
456
|
+
function assertUriSchemeMatchesType(type, uri) {
|
|
457
|
+
const familia = exports.URI_GUARD_FAMILY[type];
|
|
458
|
+
if (!familia)
|
|
459
|
+
return;
|
|
460
|
+
const esperados = exports.URI_SCHEMES[familia];
|
|
461
|
+
const comoSeEscribe = esperados.map((e) => `${e}//`).join(' or ');
|
|
462
|
+
const esquema = esquemaDe(uri);
|
|
463
|
+
if (!esquema) {
|
|
464
|
+
throw new common_1.BadRequestException(`That connection string has no scheme — a ${NOMBRE_DE_FAMILIA[familia]} credential starts with ${comoSeEscribe}`);
|
|
465
|
+
}
|
|
466
|
+
if (esperados.includes(esquema))
|
|
467
|
+
return;
|
|
468
|
+
/* Si el esquema es de otra familia que conocemos, se dice CUÁL y a dónde
|
|
469
|
+
llevarlo. Un «no vale» a secas deja a quien lo pegó buscando el error en
|
|
470
|
+
la contraseña o en el host, que es donde nadie lo tenía. */
|
|
471
|
+
const otra = Object.keys(exports.URI_SCHEMES).find((f) => f !== familia && exports.URI_SCHEMES[f].includes(esquema));
|
|
472
|
+
if (otra) {
|
|
473
|
+
throw new common_1.BadRequestException(`That is a ${NOMBRE_DE_FAMILIA[otra]} connection string (${esquema}//), but this credential is ${NOMBRE_DE_FAMILIA[familia]}. Create it as a ${NOMBRE_DE_FAMILIA[otra]} credential instead.`);
|
|
474
|
+
}
|
|
475
|
+
throw new common_1.BadRequestException(`"${esquema}//" is not a ${NOMBRE_DE_FAMILIA[familia]} connection string — expected ${comoSeEscribe}`);
|
|
476
|
+
}
|
|
477
|
+
/** Apply the right guard for a credential type. No-op for unguarded types. */
|
|
478
|
+
async function assertSafeCredentialUri(type, uri) {
|
|
479
|
+
const family = exports.URI_GUARD_FAMILY[type];
|
|
480
|
+
if (!family)
|
|
481
|
+
return;
|
|
482
|
+
if (family === 'mongo')
|
|
483
|
+
await assertSafeMongoUri(uri);
|
|
484
|
+
else
|
|
485
|
+
await assertSafePostgresUri(uri);
|
|
486
|
+
}
|
|
487
|
+
/** As above, but a tunnelled credential describes the customer's own network. */
|
|
488
|
+
async function assertSafeCredentialUriUnlessTunneled(type, uri, tunnelId) {
|
|
489
|
+
if (tunnelId)
|
|
490
|
+
return;
|
|
491
|
+
await assertSafeCredentialUri(type, uri);
|
|
492
|
+
}
|
|
493
|
+
/** Mask the password portion of a postgres URI for logs / UI. */
|
|
494
|
+
function maskPostgresUri(uri) {
|
|
495
|
+
return uri.replace(/^(postgres(?:ql)?:\/\/)([^:]+):([^@]+)@/i, '$1$2:***@');
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Parse the first host:port pair out of a `mongodb://` or
|
|
499
|
+
* `mongodb+srv://` URI. Used by the TCP forwarder + URI rewriter to
|
|
500
|
+
* know what target to register and what host:port to substitute with
|
|
501
|
+
* the local forwarder address.
|
|
502
|
+
*
|
|
503
|
+
* Returns the bare host (no port) plus the explicit port — defaulting
|
|
504
|
+
* to 27017 for `mongodb://` and to a synthetic 27017 for `mongodb+srv://`
|
|
505
|
+
* (SRV resolution is bypassed entirely when tunnelled, the agent talks
|
|
506
|
+
* to its target directly).
|
|
507
|
+
*/
|
|
508
|
+
function parseFirstMongoHost(uri) {
|
|
509
|
+
const match = /^mongodb(\+srv)?:\/\/(?:[^@/]*@)?([^/?]+)/i.exec(uri);
|
|
510
|
+
if (!match)
|
|
511
|
+
throw new Error('Connection string is not a valid mongodb:// URI');
|
|
512
|
+
const isSrv = !!match[1];
|
|
513
|
+
const firstHost = match[2]
|
|
514
|
+
.split(',')[0]
|
|
515
|
+
.trim()
|
|
516
|
+
.replace(/^\[/, '')
|
|
517
|
+
.replace(/\].*$/, '');
|
|
518
|
+
const portIdx = firstHost.lastIndexOf(':');
|
|
519
|
+
if (portIdx > 0 && /^\d+$/.test(firstHost.slice(portIdx + 1))) {
|
|
520
|
+
return {
|
|
521
|
+
host: firstHost.slice(0, portIdx),
|
|
522
|
+
port: Number(firstHost.slice(portIdx + 1)),
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
return { host: firstHost, port: isSrv ? 27017 : 27017 };
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Replace the host(s) section of a Mongo URI with a single host:port.
|
|
529
|
+
* Preserves user:pass, path (database), and querystring. Forces the
|
|
530
|
+
* scheme to `mongodb://` (drops `+srv`) because the rewritten URI
|
|
531
|
+
* points at our local forwarder, not an SRV-resolvable hostname.
|
|
532
|
+
*
|
|
533
|
+
* Also drops the `tls`, `ssl`, and `replicaSet` query params — they
|
|
534
|
+
* would cause the driver to negotiate TLS with our local plaintext
|
|
535
|
+
* forwarder. The agent talks to its real Atlas with TLS on its end.
|
|
536
|
+
*/
|
|
537
|
+
function rewriteMongoUriHost(uri, host, port) {
|
|
538
|
+
// Match: scheme://[user:pass@]hosts[/db?query]
|
|
539
|
+
const m = /^mongodb(?:\+srv)?:\/\/(?:([^@/]*)@)?([^/?]+)(\/[^?]*)?(\?.*)?$/i.exec(uri);
|
|
540
|
+
if (!m)
|
|
541
|
+
throw new Error('Connection string is not a valid mongodb:// URI');
|
|
542
|
+
const userInfo = m[1];
|
|
543
|
+
const path = m[3] ?? '';
|
|
544
|
+
let query = m[4] ?? '';
|
|
545
|
+
if (query) {
|
|
546
|
+
// Drop tls/ssl/replicaSet — incompatible with our plaintext forwarder.
|
|
547
|
+
const params = new URLSearchParams(query.slice(1));
|
|
548
|
+
for (const k of [
|
|
549
|
+
'tls',
|
|
550
|
+
'ssl',
|
|
551
|
+
'replicaSet',
|
|
552
|
+
'tlsCAFile',
|
|
553
|
+
'tlsCertificateKeyFile',
|
|
554
|
+
]) {
|
|
555
|
+
params.delete(k);
|
|
556
|
+
}
|
|
557
|
+
const remaining = params.toString();
|
|
558
|
+
query = remaining ? `?${remaining}` : '';
|
|
559
|
+
}
|
|
560
|
+
const auth = userInfo ? `${userInfo}@` : '';
|
|
561
|
+
return `mongodb://${auth}${host}:${port}${path}${query}`;
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* Parse the first host:port pair out of a `postgres://` /
|
|
565
|
+
* `postgresql://` URI. Same role as `parseFirstMongoHost` — the TCP
|
|
566
|
+
* forwarder needs to know what host:port to register against the
|
|
567
|
+
* tunnel, and the URI rewriter needs to know what to swap out.
|
|
568
|
+
*
|
|
569
|
+
* Postgres URIs only ever name one host (no comma list, no `+srv`
|
|
570
|
+
* sibling), but we still parse the userinfo correctly so passwords
|
|
571
|
+
* containing `@` (URL-encoded as `%40`) don't get treated as host
|
|
572
|
+
* delimiters.
|
|
573
|
+
*/
|
|
574
|
+
function parseFirstPostgresHost(uri) {
|
|
575
|
+
const match = /^postgres(?:ql)?:\/\/(?:[^@/]*@)?([^/?]+)/i.exec(uri);
|
|
576
|
+
if (!match)
|
|
577
|
+
throw new Error('Connection string is not a valid postgres:// URI');
|
|
578
|
+
const firstHost = match[1].trim().replace(/^\[/, '').replace(/\].*$/, '');
|
|
579
|
+
const portIdx = firstHost.lastIndexOf(':');
|
|
580
|
+
if (portIdx > 0 && /^\d+$/.test(firstHost.slice(portIdx + 1))) {
|
|
581
|
+
return {
|
|
582
|
+
host: firstHost.slice(0, portIdx),
|
|
583
|
+
port: Number(firstHost.slice(portIdx + 1)),
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
return { host: firstHost, port: 5432 };
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Replace the host:port of a Postgres URI with the local forwarder
|
|
590
|
+
* address. Same idea as `rewriteMongoUriHost`: the local forwarder is
|
|
591
|
+
* plaintext (the agent restores TLS on its end of the wire), so any
|
|
592
|
+
* SSL-related query params would cause the driver to negotiate TLS
|
|
593
|
+
* against the wrong webhook and fail. Drop them.
|
|
594
|
+
*
|
|
595
|
+
* Preserves userinfo + path (the database name lives in the path) +
|
|
596
|
+
* remaining querystring. Also forces scheme to `postgres://` (drops
|
|
597
|
+
* `postgresql://` to one canonical form).
|
|
598
|
+
*/
|
|
599
|
+
/**
|
|
600
|
+
* Qué base declara una URI de Postgres en su ruta, o `null` si no declara
|
|
601
|
+
* ninguna —en ese caso el driver cae a la base con el nombre del usuario—.
|
|
602
|
+
*
|
|
603
|
+
* Se decodifica porque `rewritePostgresUriDatabase` codifica al escribir: sin
|
|
604
|
+
* esto, comparar «lo que había» con «lo que se pide» diría que `mi base` y
|
|
605
|
+
* `mi%20base` son distintas y registraría un cambio que no existe.
|
|
606
|
+
*/
|
|
607
|
+
function baseDeLaUri(uri) {
|
|
608
|
+
const m = /^postgres(?:ql)?:\/\/(?:[^@/]*@)?[^/?]+\/([^?]*)/i.exec(uri);
|
|
609
|
+
if (!m || !m[1])
|
|
610
|
+
return null;
|
|
611
|
+
try {
|
|
612
|
+
return decodeURIComponent(m[1]) || null;
|
|
613
|
+
}
|
|
614
|
+
catch {
|
|
615
|
+
return m[1] || null;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* Cambiar la base de datos de una URI de Postgres — la que va en la ruta.
|
|
620
|
+
*
|
|
621
|
+
* En Mongo la base se elige con `client.db(nombre)` sobre una conexión ya
|
|
622
|
+
* abierta. En Postgres **no existe eso**: la base se decide al conectarse y
|
|
623
|
+
* viene en la URI, así que respetar el `databaseName` de un nodo obliga a
|
|
624
|
+
* reescribirla aquí. Ésa es la razón de que `databaseName` estuviera muerto en
|
|
625
|
+
* `postgres-actions.service.ts` — se calculaba y no se podía aplicar sin esto.
|
|
626
|
+
*
|
|
627
|
+
* Se conserva el esquema tal cual llegó (`postgres://` o `postgresql://`), el
|
|
628
|
+
* userinfo, el host y el querystring; sólo cambia la ruta. El nombre se
|
|
629
|
+
* codifica porque una base puede llevar caracteres que en una URI significan
|
|
630
|
+
* otra cosa, y sin codificar `mi base` o `a/b` producirían una URI distinta de
|
|
631
|
+
* la que se pidió.
|
|
632
|
+
*
|
|
633
|
+
* Con `database` vacío devuelve la URI intacta: quien llama decide si hay algo
|
|
634
|
+
* que aplicar.
|
|
635
|
+
*/
|
|
636
|
+
function rewritePostgresUriDatabase(uri, database) {
|
|
637
|
+
const nombre = (database ?? '').trim();
|
|
638
|
+
if (!nombre)
|
|
639
|
+
return uri;
|
|
640
|
+
const m = /^(postgres(?:ql)?):\/\/(?:([^@/]*)@)?([^/?]+)(\/[^?]*)?(\?.*)?$/i.exec(uri);
|
|
641
|
+
if (!m)
|
|
642
|
+
throw new Error('Connection string is not a valid postgres:// URI');
|
|
643
|
+
const esquema = m[1];
|
|
644
|
+
const auth = m[2] ? `${m[2]}@` : '';
|
|
645
|
+
const hostPuerto = m[3];
|
|
646
|
+
const query = m[5] ?? '';
|
|
647
|
+
return `${esquema}://${auth}${hostPuerto}/${encodeURIComponent(nombre)}${query}`;
|
|
648
|
+
}
|
|
649
|
+
function rewritePostgresUriHost(uri, host, port) {
|
|
650
|
+
const m = /^postgres(?:ql)?:\/\/(?:([^@/]*)@)?([^/?]+)(\/[^?]*)?(\?.*)?$/i.exec(uri);
|
|
651
|
+
if (!m)
|
|
652
|
+
throw new Error('Connection string is not a valid postgres:// URI');
|
|
653
|
+
const userInfo = m[1];
|
|
654
|
+
const path = m[3] ?? '';
|
|
655
|
+
let query = m[4] ?? '';
|
|
656
|
+
if (query) {
|
|
657
|
+
const params = new URLSearchParams(query.slice(1));
|
|
658
|
+
for (const k of [
|
|
659
|
+
'sslmode',
|
|
660
|
+
'sslrootcert',
|
|
661
|
+
'sslcert',
|
|
662
|
+
'sslkey',
|
|
663
|
+
'sslpassword',
|
|
664
|
+
'sslcrl',
|
|
665
|
+
'sslcrldir',
|
|
666
|
+
]) {
|
|
667
|
+
params.delete(k);
|
|
668
|
+
}
|
|
669
|
+
const remaining = params.toString();
|
|
670
|
+
query = remaining ? `?${remaining}` : '';
|
|
671
|
+
}
|
|
672
|
+
const auth = userInfo ? `${userInfo}@` : '';
|
|
673
|
+
return `postgres://${auth}${host}:${port}${path}${query}`;
|
|
674
|
+
}
|
|
675
|
+
/* ── Salida HTTP ────────────────────────────────────────────────────────── */
|
|
676
|
+
/**
|
|
677
|
+
* El guard para las URLs de entrega, que hasta ahora no tenía ninguno.
|
|
678
|
+
*
|
|
679
|
+
* ## El fallo que cierra
|
|
680
|
+
*
|
|
681
|
+
* `webhook.targetUrl` llegaba al POST saliente sin tocar. Su única validación
|
|
682
|
+
* era `@IsUrl({ require_tld: false })`, y el validador instalado acepta una IP
|
|
683
|
+
* pelada sin lista negra, así que loopback, link-local y privadas pasaban.
|
|
684
|
+
* `fallbackUrls` llevaba sólo `@IsString({ each: true })` y llegaba al mismo
|
|
685
|
+
* sitio sin ninguna comprobación de URL. Este fichero existía, pero estaba
|
|
686
|
+
* cableado únicamente a las rutas de credenciales Mongo y Postgres.
|
|
687
|
+
*
|
|
688
|
+
* Con `validateStatus: () => true` la respuesta interna se conservaba, se
|
|
689
|
+
* persistía en el registro de entrega y —en modo `sync`— volvía inline a quien
|
|
690
|
+
* publicó. Eso es descubrimiento de red interna con lectura de vuelta, desde
|
|
691
|
+
* una cuenta de alta libre.
|
|
692
|
+
*
|
|
693
|
+
* ## Por qué se puede bloquear sin romper a nadie
|
|
694
|
+
*
|
|
695
|
+
* La entrega a la red local del cliente ya tiene su propio camino: el
|
|
696
|
+
* procesador comprueba `hasTunnel(webhookId)` y entrega por el túnel; el POST
|
|
697
|
+
* directo es la rama `else`. Así que el destino privado legítimo va por
|
|
698
|
+
* `hostwh expose` y no por aquí.
|
|
699
|
+
*/
|
|
700
|
+
/** Sólo estos esquemas. `file:`, `gopher:` y compañía no son destinos de
|
|
701
|
+
* entrega, y algunos son primitivas de lectura local. */
|
|
702
|
+
const ESQUEMAS_DE_ENTREGA = new Set(['http:', 'https:']);
|
|
703
|
+
/** Igual que `isPrivateIp`, expuesto para quien tenga ya la IP resuelta. */
|
|
704
|
+
function isPrivateAddress(ip) {
|
|
705
|
+
return isPrivateIp(ip);
|
|
706
|
+
}
|
|
707
|
+
/**
|
|
708
|
+
* Rechaza una URL de entrega que apunte adentro.
|
|
709
|
+
*
|
|
710
|
+
* Comprueba esquema, nombres bloqueados, IP literal y —cuando es un nombre— lo
|
|
711
|
+
* que resuelve AHORA. Esa resolución es la foto previa; lo que de verdad ata la
|
|
712
|
+
* dirección comprobada a la dirección marcada es el hook de `agentesDeEntrega`,
|
|
713
|
+
* porque entre esta consulta y el socket cabe un cambio de DNS.
|
|
714
|
+
*/
|
|
715
|
+
async function assertSafeHttpUrl(rawUrl) {
|
|
716
|
+
let url;
|
|
717
|
+
try {
|
|
718
|
+
url = new URL(rawUrl);
|
|
719
|
+
}
|
|
720
|
+
catch {
|
|
721
|
+
throw new common_1.ForbiddenException(`Delivery URL is not a valid URL: ${rawUrl}`);
|
|
722
|
+
}
|
|
723
|
+
if (!ESQUEMAS_DE_ENTREGA.has(url.protocol)) {
|
|
724
|
+
throw new common_1.ForbiddenException(`Delivery URL scheme "${url.protocol}" is not allowed — use http or https`);
|
|
725
|
+
}
|
|
726
|
+
// WHATWG URL conserva los corchetes de un host IPv6; con ellos la cadena no
|
|
727
|
+
// es una IP literal y caería al camino de DNS.
|
|
728
|
+
const host = url.hostname.replace(/^\[(.+)\]$/, '$1');
|
|
729
|
+
if (!host)
|
|
730
|
+
throw new common_1.ForbiddenException('Delivery URL is missing a host');
|
|
731
|
+
if (BLOCKED_HOSTNAMES.has(host.toLowerCase())) {
|
|
732
|
+
throw new common_1.ForbiddenException(`Delivery to ${host} is not allowed`);
|
|
733
|
+
}
|
|
734
|
+
if ((0, node_net_1.isIP)(host)) {
|
|
735
|
+
if (isPrivateIp(host)) {
|
|
736
|
+
throw new common_1.ForbiddenException(`Delivery to private IP ${host} is not allowed`);
|
|
737
|
+
}
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
const v4 = await node_dns_1.promises.resolve4(host).catch(() => []);
|
|
741
|
+
const v6 = await node_dns_1.promises.resolve6(host).catch(() => []);
|
|
742
|
+
const resueltas = [...v4, ...v6];
|
|
743
|
+
if (resueltas.length === 0) {
|
|
744
|
+
throw new common_1.ForbiddenException(`Could not resolve delivery host ${host}`);
|
|
745
|
+
}
|
|
746
|
+
for (const ip of resueltas) {
|
|
747
|
+
if (isPrivateIp(ip)) {
|
|
748
|
+
throw new common_1.ForbiddenException(`Delivery host ${host} resolves to private IP ${ip} — refusing to connect`);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* Agentes cuyo `lookup` vuelve a comprobar cada dirección resuelta.
|
|
754
|
+
*
|
|
755
|
+
* Ésta es la parte que cierra el rebinding: entre la comprobación de arriba y
|
|
756
|
+
* el socket cabe un cambio de DNS, así que la dirección se valida **en el
|
|
757
|
+
* momento de marcarla**, dentro del propio `lookup`.
|
|
758
|
+
*
|
|
759
|
+
* OJO con lo que este `lookup` NO cubre: sólo corre cuando hay un nombre que
|
|
760
|
+
* resolver. Si el host ya es una IP literal, Node se salta el DNS y esto no
|
|
761
|
+
* llega a ejecutarse — de ahí `conectorQueRechazaPrivadas`, más abajo, que
|
|
762
|
+
* mira todas las conexiones. Aquí decía que valía «para cada salto de una
|
|
763
|
+
* redirección»; no era verdad, y por ahí se colaba un 302 hacia una literal.
|
|
764
|
+
*
|
|
765
|
+
* Se comprueba aquí y no resolviendo a mano para conectar a la IP: conectar a
|
|
766
|
+
* la IP obliga a rehacer SNI y la cabecera Host, y eso rompe cualquier destino
|
|
767
|
+
* en hosting compartido o detrás de un CDN.
|
|
768
|
+
*/
|
|
769
|
+
function lookupQueRechazaPrivadas(hostname, options, callback) {
|
|
770
|
+
node_dns_1.lookup(hostname, options, (err, address, family) => {
|
|
771
|
+
if (err)
|
|
772
|
+
return callback(err, address, family);
|
|
773
|
+
const direcciones = Array.isArray(address)
|
|
774
|
+
? address.map((a) => a.address)
|
|
775
|
+
: [String(address)];
|
|
776
|
+
const adentro = direcciones.find((ip) => isPrivateIp(ip));
|
|
777
|
+
if (adentro) {
|
|
778
|
+
return callback(Object.assign(new Error(`refusing to connect to private address ${adentro} for ${hostname}`), { code: 'ESSRFBLOCKED' }), address, family);
|
|
779
|
+
}
|
|
780
|
+
callback(null, address, family);
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
/**
|
|
784
|
+
* Mira el host de UNA conexión, y lanza si es una privada literal.
|
|
785
|
+
*
|
|
786
|
+
* ## El agujero que tapa, que es el mismo que ya tenía `fetch`
|
|
787
|
+
*
|
|
788
|
+
* `lookupQueRechazaPrivadas` sólo corre cuando hay un nombre que resolver. Si
|
|
789
|
+
* el host ya es una IP literal, Node se salta el DNS entero y el `lookup` no
|
|
790
|
+
* llega a ejecutarse. Para `fetch` eso lo cierra `conectorQueRechazaPrivadas`
|
|
791
|
+
* (undici), y desde entonces este fichero decía que estaba cubierto — pero
|
|
792
|
+
* **sólo para undici**. Los agentes de `node:http`, que son los que usan axios
|
|
793
|
+
* y por tanto el nodo HTTP y el procesador de entregas, se quedaron sin
|
|
794
|
+
* equivalente.
|
|
795
|
+
*
|
|
796
|
+
* Por ahí entra lo de siempre:
|
|
797
|
+
*
|
|
798
|
+
* el atacante pide https://loquesea.suyo/x (público, pasa el chequeo)
|
|
799
|
+
* su servidor responde 302 Location: http://169.254.169.254/latest/...
|
|
800
|
+
*
|
|
801
|
+
* y ese salto abre su propia conexión, contra una literal, sin DNS. También
|
|
802
|
+
* entra un endpoint escrito a mano que YA sea una IP privada en cualquier
|
|
803
|
+
* cliente que use estos agentes sin llamar antes a `assertSafeHttpUrl`.
|
|
804
|
+
*
|
|
805
|
+
* ## Por qué envolver `createConnection` y no pasar una opción
|
|
806
|
+
*
|
|
807
|
+
* `http.Agent` no tiene un gancho de «antes de conectar»: `lookup` es lo más
|
|
808
|
+
* parecido y ya vimos que no basta. `createConnection` es el único punto por
|
|
809
|
+
* el que pasan TODAS las conexiones del agente, con o sin nombre, y es
|
|
810
|
+
* público y documentado. Se envuelve el del prototipo en vez de reescribirlo
|
|
811
|
+
* para no perder el manejo de sesiones TLS de `https.Agent`.
|
|
812
|
+
*/
|
|
813
|
+
function conConectorQueRechazaPrivadas(agente) {
|
|
814
|
+
const original = agente.createConnection.bind(agente);
|
|
815
|
+
agente.createConnection = ((opciones, callback) => {
|
|
816
|
+
// Node conserva los corchetes de un host IPv6; con ellos la cadena no es
|
|
817
|
+
// una IP literal y el `isIP` diría que no. Mismo detalle que en undici.
|
|
818
|
+
const host = String(opciones?.host ?? '').replace(/^\[(.+)\]$/, '$1');
|
|
819
|
+
if ((0, node_net_1.isIP)(host) && isPrivateIp(host)) {
|
|
820
|
+
const fallo = Object.assign(new Error(`Delivery to private IP ${host} is not allowed`), { code: 'ESSRFBLOCKED' });
|
|
821
|
+
/* Por el callback cuando lo hay: es como `Agent.createSocket` espera el
|
|
822
|
+
fallo, y lanzar ahí dentro lo convertiría en una excepción sin
|
|
823
|
+
capturar en vez de en un error de la petición. */
|
|
824
|
+
if (callback) {
|
|
825
|
+
callback(fallo);
|
|
826
|
+
return undefined;
|
|
827
|
+
}
|
|
828
|
+
throw fallo;
|
|
829
|
+
}
|
|
830
|
+
return original(opciones, callback);
|
|
831
|
+
});
|
|
832
|
+
return agente;
|
|
833
|
+
}
|
|
834
|
+
let agentes = null;
|
|
835
|
+
/** Los agentes de salida de la entrega. Se crean una vez: llevan pool de
|
|
836
|
+
* conexiones y uno por petición lo tiraría.
|
|
837
|
+
*
|
|
838
|
+
* Llevan las DOS capas: el `lookup` para los nombres y el conector para todo
|
|
839
|
+
* lo demás. Ninguna sobra — ver `conConectorQueRechazaPrivadas`. */
|
|
840
|
+
function agentesDeEntrega() {
|
|
841
|
+
if (!agentes) {
|
|
842
|
+
const opciones = {
|
|
843
|
+
keepAlive: true,
|
|
844
|
+
lookup: lookupQueRechazaPrivadas,
|
|
845
|
+
};
|
|
846
|
+
agentes = {
|
|
847
|
+
httpAgent: conConectorQueRechazaPrivadas(new http.Agent(opciones)),
|
|
848
|
+
httpsAgent: conConectorQueRechazaPrivadas(new https.Agent(opciones)),
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
return agentes;
|
|
852
|
+
}
|
|
853
|
+
/**
|
|
854
|
+
* Lo mismo que `agentesDeEntrega`, pero para `fetch`.
|
|
855
|
+
*
|
|
856
|
+
* `agentesDeEntrega` devuelve agentes de `node:http`, que es lo que usa axios.
|
|
857
|
+
* El `fetch` global no los mira: va por undici, que tiene su propio
|
|
858
|
+
* despachador. Sin esto, los sitios escritos con `fetch` se quedaban fuera del
|
|
859
|
+
* guardia — que es exactamente lo que pasaba, y por eso este fichero llevaba
|
|
860
|
+
* meses cableado sólo al procesador de entregas.
|
|
861
|
+
*
|
|
862
|
+
* El `lookup` es EL MISMO que el de los agentes, así que la regla de qué es
|
|
863
|
+
* una dirección de adentro se escribe una sola vez.
|
|
864
|
+
*/
|
|
865
|
+
let despachador = null;
|
|
866
|
+
/**
|
|
867
|
+
* El conector que mira CADA conexión, también cuando el host ya es una IP.
|
|
868
|
+
*
|
|
869
|
+
* El `lookup` de arriba sólo se consulta cuando hay un nombre que resolver:
|
|
870
|
+
* si el host ya es una IP literal, Node se salta el DNS entero y el `lookup`
|
|
871
|
+
* no llega a correr. Eso dejaba abierto el salto de una redirección, que es
|
|
872
|
+
* donde más duele, porque la primera URL sí pasa por `assertSafeHttpUrl` pero
|
|
873
|
+
* las siguientes las elige quien conteste:
|
|
874
|
+
*
|
|
875
|
+
* el atacante pide https://loquesea.suyo/x (público de verdad, pasa)
|
|
876
|
+
* su servidor responde 302 Location: http://169.254.169.254/latest/...
|
|
877
|
+
* y ese salto entraba, porque no hay nombre que resolver.
|
|
878
|
+
*
|
|
879
|
+
* El conector, en cambio, corre una vez por conexión y cada salto abre la
|
|
880
|
+
* suya, así que aquí sí se ve. Para los nombres sigue mandando el `lookup`
|
|
881
|
+
* de siempre — misma regla, un solo sitio donde se escribe.
|
|
882
|
+
*/
|
|
883
|
+
const conectorQueRechazaPrivadas = (() => {
|
|
884
|
+
let base = null;
|
|
885
|
+
return (opciones, callback) => {
|
|
886
|
+
base ??= (0, undici_1.buildConnector)({ lookup: lookupQueRechazaPrivadas });
|
|
887
|
+
// undici conserva los corchetes de un host IPv6; sin quitarlos la cadena
|
|
888
|
+
// no es una IP literal y el `isIP` de abajo diría que no.
|
|
889
|
+
const host = String(opciones.hostname ?? '').replace(/^\[(.+)\]$/, '$1');
|
|
890
|
+
if ((0, node_net_1.isIP)(host) && isPrivateIp(host)) {
|
|
891
|
+
callback(Object.assign(new Error(`Delivery to private IP ${host} is not allowed`), { code: 'ESSRFBLOCKED' }), null);
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
return base(opciones, callback);
|
|
895
|
+
};
|
|
896
|
+
})();
|
|
897
|
+
function despachadorDeSalida() {
|
|
898
|
+
if (!despachador) {
|
|
899
|
+
despachador = new undici_1.Agent({ connect: conectorQueRechazaPrivadas });
|
|
900
|
+
}
|
|
901
|
+
return despachador;
|
|
902
|
+
}
|
|
903
|
+
/**
|
|
904
|
+
* `fetch` con el guardia puesto: comprueba la URL antes de salir y resuelve
|
|
905
|
+
* por el despachador que rechaza direcciones privadas, así que un DNS que
|
|
906
|
+
* cambie entre la comprobación y el socket tampoco entra.
|
|
907
|
+
*
|
|
908
|
+
* Es el reemplazo de una palabra para cualquier `fetch` que salga a una URL
|
|
909
|
+
* que haya elegido un inquilino.
|
|
910
|
+
*/
|
|
911
|
+
async function fetchSeguro(url, init) {
|
|
912
|
+
await assertSafeHttpUrl(url);
|
|
913
|
+
return fetch(url, {
|
|
914
|
+
...init,
|
|
915
|
+
dispatcher: despachadorDeSalida(),
|
|
916
|
+
});
|
|
917
|
+
}
|