@poolzin/pool-bot 2026.3.4 → 2026.3.6
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/CHANGELOG.md +10 -0
- package/assets/pool-bot-icon-dark.png +0 -0
- package/assets/pool-bot-logo-1.png +0 -0
- package/assets/pool-bot-mascot.png +0 -0
- package/dist/agents/pi-embedded-runner/tool-result-truncation.js +62 -7
- package/dist/agents/poolbot-tools.js +12 -0
- package/dist/agents/session-write-lock.js +93 -8
- package/dist/agents/tools/pdf-native-providers.js +102 -0
- package/dist/agents/tools/pdf-tool.helpers.js +86 -0
- package/dist/agents/tools/pdf-tool.js +508 -0
- package/dist/build-info.json +3 -3
- package/dist/cron/normalize.js +3 -0
- package/dist/cron/service/jobs.js +48 -0
- package/dist/gateway/protocol/schema/cron.js +3 -0
- package/dist/gateway/server-channels.js +99 -14
- package/dist/gateway/server-cron.js +89 -0
- package/dist/gateway/server-health-probes.js +55 -0
- package/dist/gateway/server-http.js +5 -0
- package/dist/hooks/bundled/session-memory/handler.js +8 -2
- package/dist/infra/abort-signal.js +12 -0
- package/dist/infra/boundary-file-read.js +118 -0
- package/dist/infra/boundary-path.js +594 -0
- package/dist/infra/file-identity.js +12 -0
- package/dist/infra/fs-safe.js +377 -12
- package/dist/infra/hardlink-guards.js +30 -0
- package/dist/infra/json-utf8-bytes.js +8 -0
- package/dist/infra/net/fetch-guard.js +63 -13
- package/dist/infra/net/proxy-env.js +17 -0
- package/dist/infra/net/ssrf.js +74 -272
- package/dist/infra/path-alias-guards.js +21 -0
- package/dist/infra/path-guards.js +13 -1
- package/dist/infra/ports-probe.js +19 -0
- package/dist/infra/prototype-keys.js +4 -0
- package/dist/infra/restart-stale-pids.js +254 -0
- package/dist/infra/safe-open-sync.js +71 -0
- package/dist/infra/secure-random.js +7 -0
- package/dist/media/ffmpeg-limits.js +4 -0
- package/dist/media/input-files.js +6 -2
- package/dist/media/temp-files.js +12 -0
- package/dist/memory/embedding-chunk-limits.js +5 -2
- package/dist/memory/embeddings-ollama.js +91 -138
- package/dist/memory/embeddings-remote-fetch.js +11 -10
- package/dist/memory/embeddings.js +25 -9
- package/dist/memory/manager-embedding-ops.js +1 -1
- package/dist/memory/post-json.js +23 -0
- package/dist/memory/qmd-manager.js +272 -77
- package/dist/memory/remote-http.js +33 -0
- package/dist/plugin-sdk/windows-spawn.js +214 -0
- package/dist/shared/net/ip-test-fixtures.js +1 -0
- package/dist/shared/net/ip.js +303 -0
- package/dist/shared/net/ipv4.js +8 -11
- package/dist/shared/pid-alive.js +59 -2
- package/dist/test-helpers/ssrf.js +13 -0
- package/dist/tui/tui.js +9 -4
- package/dist/utils/fetch-timeout.js +12 -1
- package/docs/adr/003-feature-gap-analysis.md +112 -0
- package/package.json +10 -4
package/dist/infra/net/ssrf.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { lookup as dnsLookupCb } from "node:dns";
|
|
2
2
|
import { lookup as dnsLookup } from "node:dns/promises";
|
|
3
3
|
import { Agent } from "undici";
|
|
4
|
+
import { extractEmbeddedIpv4FromIpv6, isBlockedSpecialUseIpv4Address, isBlockedSpecialUseIpv6Address, isCanonicalDottedDecimalIPv4, isIpv4Address, isLegacyIpv4Literal, parseCanonicalIpAddress, parseLooseIpAddress, } from "../../shared/net/ip.js";
|
|
4
5
|
import { normalizeHostname } from "./hostname.js";
|
|
5
6
|
export class SsrFBlockedError extends Error {
|
|
6
7
|
constructor(message) {
|
|
@@ -27,6 +28,14 @@ function normalizeHostnameAllowlist(values) {
|
|
|
27
28
|
.map((value) => normalizeHostname(value))
|
|
28
29
|
.filter((value) => value !== "*" && value !== "*." && value.length > 0)));
|
|
29
30
|
}
|
|
31
|
+
export function isPrivateNetworkAllowedByPolicy(policy) {
|
|
32
|
+
return policy?.dangerouslyAllowPrivateNetwork === true || policy?.allowPrivateNetwork === true;
|
|
33
|
+
}
|
|
34
|
+
function resolveIpv4SpecialUseBlockOptions(policy) {
|
|
35
|
+
return {
|
|
36
|
+
allowRfc2544BenchmarkRange: policy?.allowRfc2544BenchmarkRange === true,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
30
39
|
function isHostnameAllowedByPattern(hostname, pattern) {
|
|
31
40
|
if (pattern.startsWith("*.")) {
|
|
32
41
|
const suffix = pattern.slice(2);
|
|
@@ -43,45 +52,7 @@ function matchesHostnameAllowlist(hostname, allowlist) {
|
|
|
43
52
|
}
|
|
44
53
|
return allowlist.some((pattern) => isHostnameAllowedByPattern(hostname, pattern));
|
|
45
54
|
}
|
|
46
|
-
function
|
|
47
|
-
if (!/^[0-9]+$/.test(part)) {
|
|
48
|
-
return null;
|
|
49
|
-
}
|
|
50
|
-
const value = Number.parseInt(part, 10);
|
|
51
|
-
if (Number.isNaN(value) || value < 0 || value > 255) {
|
|
52
|
-
return null;
|
|
53
|
-
}
|
|
54
|
-
// Accept only canonical decimal octets (no leading zeros, no alternate radices).
|
|
55
|
-
if (part !== String(value)) {
|
|
56
|
-
return null;
|
|
57
|
-
}
|
|
58
|
-
return value;
|
|
59
|
-
}
|
|
60
|
-
function parseIpv4(address) {
|
|
61
|
-
const parts = address.split(".");
|
|
62
|
-
if (parts.length !== 4) {
|
|
63
|
-
return null;
|
|
64
|
-
}
|
|
65
|
-
for (const part of parts) {
|
|
66
|
-
if (parseStrictIpv4Octet(part) === null) {
|
|
67
|
-
return null;
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
return parts.map((part) => Number.parseInt(part, 10));
|
|
71
|
-
}
|
|
72
|
-
function classifyIpv4Part(part) {
|
|
73
|
-
if (/^0x[0-9a-f]+$/i.test(part)) {
|
|
74
|
-
return "hex";
|
|
75
|
-
}
|
|
76
|
-
if (/^0x/i.test(part)) {
|
|
77
|
-
return "invalid-hex";
|
|
78
|
-
}
|
|
79
|
-
if (/^[0-9]+$/.test(part)) {
|
|
80
|
-
return "decimal";
|
|
81
|
-
}
|
|
82
|
-
return "non-numeric";
|
|
83
|
-
}
|
|
84
|
-
function isUnsupportedLegacyIpv4Literal(address) {
|
|
55
|
+
function looksLikeUnsupportedIpv4Literal(address) {
|
|
85
56
|
const parts = address.split(".");
|
|
86
57
|
if (parts.length === 0 || parts.length > 4) {
|
|
87
58
|
return false;
|
|
@@ -89,186 +60,12 @@ function isUnsupportedLegacyIpv4Literal(address) {
|
|
|
89
60
|
if (parts.some((part) => part.length === 0)) {
|
|
90
61
|
return true;
|
|
91
62
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
}
|
|
96
|
-
if (partKinds.some((kind) => kind === "invalid-hex")) {
|
|
97
|
-
return true;
|
|
98
|
-
}
|
|
99
|
-
if (parts.length !== 4) {
|
|
100
|
-
return true;
|
|
101
|
-
}
|
|
102
|
-
for (const part of parts) {
|
|
103
|
-
if (/^0x/i.test(part)) {
|
|
104
|
-
return true;
|
|
105
|
-
}
|
|
106
|
-
const value = Number.parseInt(part, 10);
|
|
107
|
-
if (Number.isNaN(value) || value > 255 || part !== String(value)) {
|
|
108
|
-
return true;
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
return false;
|
|
112
|
-
}
|
|
113
|
-
function stripIpv6ZoneId(address) {
|
|
114
|
-
const index = address.indexOf("%");
|
|
115
|
-
return index >= 0 ? address.slice(0, index) : address;
|
|
116
|
-
}
|
|
117
|
-
function parseIpv6Hextets(address) {
|
|
118
|
-
let input = stripIpv6ZoneId(address.trim().toLowerCase());
|
|
119
|
-
if (!input) {
|
|
120
|
-
return null;
|
|
121
|
-
}
|
|
122
|
-
// Handle IPv4-embedded IPv6 like ::ffff:127.0.0.1 by converting the tail to 2 hextets.
|
|
123
|
-
if (input.includes(".")) {
|
|
124
|
-
const lastColon = input.lastIndexOf(":");
|
|
125
|
-
if (lastColon < 0) {
|
|
126
|
-
return null;
|
|
127
|
-
}
|
|
128
|
-
const ipv4 = parseIpv4(input.slice(lastColon + 1));
|
|
129
|
-
if (!ipv4) {
|
|
130
|
-
return null;
|
|
131
|
-
}
|
|
132
|
-
const high = (ipv4[0] << 8) + ipv4[1];
|
|
133
|
-
const low = (ipv4[2] << 8) + ipv4[3];
|
|
134
|
-
input = `${input.slice(0, lastColon)}:${high.toString(16)}:${low.toString(16)}`;
|
|
135
|
-
}
|
|
136
|
-
const doubleColonParts = input.split("::");
|
|
137
|
-
if (doubleColonParts.length > 2) {
|
|
138
|
-
return null;
|
|
139
|
-
}
|
|
140
|
-
const headParts = doubleColonParts[0]?.length > 0 ? doubleColonParts[0].split(":").filter(Boolean) : [];
|
|
141
|
-
const tailParts = doubleColonParts.length === 2 && doubleColonParts[1]?.length > 0
|
|
142
|
-
? doubleColonParts[1].split(":").filter(Boolean)
|
|
143
|
-
: [];
|
|
144
|
-
const missingParts = 8 - headParts.length - tailParts.length;
|
|
145
|
-
if (missingParts < 0) {
|
|
146
|
-
return null;
|
|
147
|
-
}
|
|
148
|
-
const fullParts = doubleColonParts.length === 1
|
|
149
|
-
? input.split(":")
|
|
150
|
-
: [...headParts, ...Array.from({ length: missingParts }, () => "0"), ...tailParts];
|
|
151
|
-
if (fullParts.length !== 8) {
|
|
152
|
-
return null;
|
|
153
|
-
}
|
|
154
|
-
const hextets = [];
|
|
155
|
-
for (const part of fullParts) {
|
|
156
|
-
if (!part) {
|
|
157
|
-
return null;
|
|
158
|
-
}
|
|
159
|
-
const value = Number.parseInt(part, 16);
|
|
160
|
-
if (Number.isNaN(value) || value < 0 || value > 0xffff) {
|
|
161
|
-
return null;
|
|
162
|
-
}
|
|
163
|
-
hextets.push(value);
|
|
164
|
-
}
|
|
165
|
-
return hextets;
|
|
166
|
-
}
|
|
167
|
-
function decodeIpv4FromHextets(high, low) {
|
|
168
|
-
return [(high >>> 8) & 0xff, high & 0xff, (low >>> 8) & 0xff, low & 0xff];
|
|
169
|
-
}
|
|
170
|
-
const EMBEDDED_IPV4_RULES = [
|
|
171
|
-
{
|
|
172
|
-
// IPv4-mapped: ::ffff:a.b.c.d and IPv4-compatible ::a.b.c.d.
|
|
173
|
-
matches: (hextets) => hextets[0] === 0 &&
|
|
174
|
-
hextets[1] === 0 &&
|
|
175
|
-
hextets[2] === 0 &&
|
|
176
|
-
hextets[3] === 0 &&
|
|
177
|
-
hextets[4] === 0 &&
|
|
178
|
-
(hextets[5] === 0xffff || hextets[5] === 0),
|
|
179
|
-
extract: (hextets) => [hextets[6], hextets[7]],
|
|
180
|
-
},
|
|
181
|
-
{
|
|
182
|
-
// NAT64 well-known prefix: 64:ff9b::/96.
|
|
183
|
-
matches: (hextets) => hextets[0] === 0x0064 &&
|
|
184
|
-
hextets[1] === 0xff9b &&
|
|
185
|
-
hextets[2] === 0 &&
|
|
186
|
-
hextets[3] === 0 &&
|
|
187
|
-
hextets[4] === 0 &&
|
|
188
|
-
hextets[5] === 0,
|
|
189
|
-
extract: (hextets) => [hextets[6], hextets[7]],
|
|
190
|
-
},
|
|
191
|
-
{
|
|
192
|
-
// NAT64 local-use prefix: 64:ff9b:1::/48.
|
|
193
|
-
matches: (hextets) => hextets[0] === 0x0064 &&
|
|
194
|
-
hextets[1] === 0xff9b &&
|
|
195
|
-
hextets[2] === 0x0001 &&
|
|
196
|
-
hextets[3] === 0 &&
|
|
197
|
-
hextets[4] === 0 &&
|
|
198
|
-
hextets[5] === 0,
|
|
199
|
-
extract: (hextets) => [hextets[6], hextets[7]],
|
|
200
|
-
},
|
|
201
|
-
{
|
|
202
|
-
// 6to4 prefix: 2002::/16 where hextets[1..2] carry IPv4.
|
|
203
|
-
matches: (hextets) => hextets[0] === 0x2002,
|
|
204
|
-
extract: (hextets) => [hextets[1], hextets[2]],
|
|
205
|
-
},
|
|
206
|
-
{
|
|
207
|
-
// Teredo prefix: 2001:0000::/32 with client IPv4 obfuscated via XOR 0xffff.
|
|
208
|
-
matches: (hextets) => hextets[0] === 0x2001 && hextets[1] === 0x0000,
|
|
209
|
-
extract: (hextets) => [hextets[6] ^ 0xffff, hextets[7] ^ 0xffff],
|
|
210
|
-
},
|
|
211
|
-
{
|
|
212
|
-
// ISATAP IID format: 000000ug00000000:5efe:w.x.y.z (RFC 5214 section 6.1).
|
|
213
|
-
// Match only the IID marker bits to avoid over-broad :5efe: detection.
|
|
214
|
-
matches: (hextets) => (hextets[4] & 0xfcff) === 0 && hextets[5] === 0x5efe,
|
|
215
|
-
extract: (hextets) => [hextets[6], hextets[7]],
|
|
216
|
-
},
|
|
217
|
-
];
|
|
218
|
-
function extractIpv4FromEmbeddedIpv6(hextets) {
|
|
219
|
-
for (const rule of EMBEDDED_IPV4_RULES) {
|
|
220
|
-
if (!rule.matches(hextets)) {
|
|
221
|
-
continue;
|
|
222
|
-
}
|
|
223
|
-
const [high, low] = rule.extract(hextets);
|
|
224
|
-
return decodeIpv4FromHextets(high, low);
|
|
225
|
-
}
|
|
226
|
-
return null;
|
|
227
|
-
}
|
|
228
|
-
function ipv4ToUint(parts) {
|
|
229
|
-
const [a, b, c, d] = parts;
|
|
230
|
-
return (((a << 24) >>> 0) | (b << 16) | (c << 8) | d) >>> 0;
|
|
231
|
-
}
|
|
232
|
-
function ipv4RangeFromCidr(cidr) {
|
|
233
|
-
const base = ipv4ToUint(cidr.base);
|
|
234
|
-
const hostBits = 32 - cidr.prefixLength;
|
|
235
|
-
const mask = cidr.prefixLength === 0 ? 0 : (0xffffffff << hostBits) >>> 0;
|
|
236
|
-
const start = (base & mask) >>> 0;
|
|
237
|
-
const end = (start | (~mask >>> 0)) >>> 0;
|
|
238
|
-
return [start, end];
|
|
239
|
-
}
|
|
240
|
-
const BLOCKED_IPV4_SPECIAL_USE_CIDRS = [
|
|
241
|
-
{ base: [0, 0, 0, 0], prefixLength: 8 },
|
|
242
|
-
{ base: [10, 0, 0, 0], prefixLength: 8 },
|
|
243
|
-
{ base: [100, 64, 0, 0], prefixLength: 10 },
|
|
244
|
-
{ base: [127, 0, 0, 0], prefixLength: 8 },
|
|
245
|
-
{ base: [169, 254, 0, 0], prefixLength: 16 },
|
|
246
|
-
{ base: [172, 16, 0, 0], prefixLength: 12 },
|
|
247
|
-
{ base: [192, 0, 0, 0], prefixLength: 24 },
|
|
248
|
-
{ base: [192, 0, 2, 0], prefixLength: 24 },
|
|
249
|
-
{ base: [192, 88, 99, 0], prefixLength: 24 },
|
|
250
|
-
{ base: [192, 168, 0, 0], prefixLength: 16 },
|
|
251
|
-
{ base: [198, 18, 0, 0], prefixLength: 15 },
|
|
252
|
-
{ base: [198, 51, 100, 0], prefixLength: 24 },
|
|
253
|
-
{ base: [203, 0, 113, 0], prefixLength: 24 },
|
|
254
|
-
{ base: [224, 0, 0, 0], prefixLength: 4 },
|
|
255
|
-
{ base: [240, 0, 0, 0], prefixLength: 4 },
|
|
256
|
-
];
|
|
257
|
-
const BLOCKED_IPV4_SPECIAL_USE_RANGES = BLOCKED_IPV4_SPECIAL_USE_CIDRS.map(ipv4RangeFromCidr);
|
|
258
|
-
function isBlockedIpv4SpecialUse(parts) {
|
|
259
|
-
if (parts.length !== 4) {
|
|
260
|
-
return false;
|
|
261
|
-
}
|
|
262
|
-
const value = ipv4ToUint(parts);
|
|
263
|
-
for (const [start, end] of BLOCKED_IPV4_SPECIAL_USE_RANGES) {
|
|
264
|
-
if (value >= start && value <= end) {
|
|
265
|
-
return true;
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
return false;
|
|
63
|
+
// Tighten only "ipv4-ish" literals (numbers + optional 0x prefix). Hostnames like
|
|
64
|
+
// "example.com" must stay in hostname policy handling and not be treated as malformed IPs.
|
|
65
|
+
return parts.every((part) => /^[0-9]+$/.test(part) || /^0x/i.test(part));
|
|
269
66
|
}
|
|
270
67
|
// Returns true for private/internal and special-use non-global addresses.
|
|
271
|
-
export function isPrivateIpAddress(address) {
|
|
68
|
+
export function isPrivateIpAddress(address, policy) {
|
|
272
69
|
let normalized = address.trim().toLowerCase();
|
|
273
70
|
if (normalized.startsWith("[") && normalized.endsWith("]")) {
|
|
274
71
|
normalized = normalized.slice(1, -1);
|
|
@@ -276,57 +73,29 @@ export function isPrivateIpAddress(address) {
|
|
|
276
73
|
if (!normalized) {
|
|
277
74
|
return false;
|
|
278
75
|
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
return
|
|
76
|
+
const blockOptions = resolveIpv4SpecialUseBlockOptions(policy);
|
|
77
|
+
const strictIp = parseCanonicalIpAddress(normalized);
|
|
78
|
+
if (strictIp) {
|
|
79
|
+
if (isIpv4Address(strictIp)) {
|
|
80
|
+
return isBlockedSpecialUseIpv4Address(strictIp, blockOptions);
|
|
284
81
|
}
|
|
285
|
-
|
|
286
|
-
hextets[1] === 0 &&
|
|
287
|
-
hextets[2] === 0 &&
|
|
288
|
-
hextets[3] === 0 &&
|
|
289
|
-
hextets[4] === 0 &&
|
|
290
|
-
hextets[5] === 0 &&
|
|
291
|
-
hextets[6] === 0 &&
|
|
292
|
-
hextets[7] === 0;
|
|
293
|
-
const isLoopback = hextets[0] === 0 &&
|
|
294
|
-
hextets[1] === 0 &&
|
|
295
|
-
hextets[2] === 0 &&
|
|
296
|
-
hextets[3] === 0 &&
|
|
297
|
-
hextets[4] === 0 &&
|
|
298
|
-
hextets[5] === 0 &&
|
|
299
|
-
hextets[6] === 0 &&
|
|
300
|
-
hextets[7] === 1;
|
|
301
|
-
if (isUnspecified || isLoopback) {
|
|
82
|
+
if (isBlockedSpecialUseIpv6Address(strictIp)) {
|
|
302
83
|
return true;
|
|
303
84
|
}
|
|
304
|
-
const embeddedIpv4 =
|
|
85
|
+
const embeddedIpv4 = extractEmbeddedIpv4FromIpv6(strictIp);
|
|
305
86
|
if (embeddedIpv4) {
|
|
306
|
-
return
|
|
307
|
-
}
|
|
308
|
-
// IPv6 private/internal ranges
|
|
309
|
-
// - link-local: fe80::/10
|
|
310
|
-
// - site-local (deprecated, but internal): fec0::/10
|
|
311
|
-
// - unique local: fc00::/7
|
|
312
|
-
const first = hextets[0];
|
|
313
|
-
if ((first & 0xffc0) === 0xfe80) {
|
|
314
|
-
return true;
|
|
315
|
-
}
|
|
316
|
-
if ((first & 0xffc0) === 0xfec0) {
|
|
317
|
-
return true;
|
|
318
|
-
}
|
|
319
|
-
if ((first & 0xfe00) === 0xfc00) {
|
|
320
|
-
return true;
|
|
87
|
+
return isBlockedSpecialUseIpv4Address(embeddedIpv4, blockOptions);
|
|
321
88
|
}
|
|
322
89
|
return false;
|
|
323
90
|
}
|
|
324
|
-
|
|
325
|
-
if (
|
|
326
|
-
return
|
|
91
|
+
// Security-critical parse failures should fail closed for any malformed IPv6 literal.
|
|
92
|
+
if (normalized.includes(":") && !parseLooseIpAddress(normalized)) {
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
if (!isCanonicalDottedDecimalIPv4(normalized) && isLegacyIpv4Literal(normalized)) {
|
|
96
|
+
return true;
|
|
327
97
|
}
|
|
328
|
-
|
|
329
|
-
if (isUnsupportedLegacyIpv4Literal(normalized)) {
|
|
98
|
+
if (looksLikeUnsupportedIpv4Literal(normalized)) {
|
|
330
99
|
return true;
|
|
331
100
|
}
|
|
332
101
|
return false;
|
|
@@ -346,12 +115,27 @@ function isBlockedHostnameNormalized(normalized) {
|
|
|
346
115
|
normalized.endsWith(".local") ||
|
|
347
116
|
normalized.endsWith(".internal"));
|
|
348
117
|
}
|
|
349
|
-
export function isBlockedHostnameOrIp(hostname) {
|
|
118
|
+
export function isBlockedHostnameOrIp(hostname, policy) {
|
|
350
119
|
const normalized = normalizeHostname(hostname);
|
|
351
120
|
if (!normalized) {
|
|
352
121
|
return false;
|
|
353
122
|
}
|
|
354
|
-
return isBlockedHostnameNormalized(normalized) || isPrivateIpAddress(normalized);
|
|
123
|
+
return isBlockedHostnameNormalized(normalized) || isPrivateIpAddress(normalized, policy);
|
|
124
|
+
}
|
|
125
|
+
const BLOCKED_HOST_OR_IP_MESSAGE = "Blocked hostname or private/internal/special-use IP address";
|
|
126
|
+
const BLOCKED_RESOLVED_IP_MESSAGE = "Blocked: resolves to private/internal/special-use IP address";
|
|
127
|
+
function assertAllowedHostOrIpOrThrow(hostnameOrIp, policy) {
|
|
128
|
+
if (isBlockedHostnameOrIp(hostnameOrIp, policy)) {
|
|
129
|
+
throw new SsrFBlockedError(BLOCKED_HOST_OR_IP_MESSAGE);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function assertAllowedResolvedAddressesOrThrow(results, policy) {
|
|
133
|
+
for (const entry of results) {
|
|
134
|
+
// Reuse the exact same host/IP classifier as the pre-DNS check to avoid drift.
|
|
135
|
+
if (isBlockedHostnameOrIp(entry.address, policy)) {
|
|
136
|
+
throw new SsrFBlockedError(BLOCKED_RESOLVED_IP_MESSAGE);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
355
139
|
}
|
|
356
140
|
export function createPinnedLookup(params) {
|
|
357
141
|
const normalizedHost = normalizeHostname(params.hostname);
|
|
@@ -392,34 +176,52 @@ export function createPinnedLookup(params) {
|
|
|
392
176
|
cb(null, chosen.address, chosen.family);
|
|
393
177
|
});
|
|
394
178
|
}
|
|
179
|
+
function dedupeAndPreferIpv4(results) {
|
|
180
|
+
const seen = new Set();
|
|
181
|
+
const ipv4 = [];
|
|
182
|
+
const otherFamilies = [];
|
|
183
|
+
for (const entry of results) {
|
|
184
|
+
if (seen.has(entry.address)) {
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
seen.add(entry.address);
|
|
188
|
+
if (entry.family === 4) {
|
|
189
|
+
ipv4.push(entry.address);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
otherFamilies.push(entry.address);
|
|
193
|
+
}
|
|
194
|
+
return [...ipv4, ...otherFamilies];
|
|
195
|
+
}
|
|
395
196
|
export async function resolvePinnedHostnameWithPolicy(hostname, params = {}) {
|
|
396
197
|
const normalized = normalizeHostname(hostname);
|
|
397
198
|
if (!normalized) {
|
|
398
199
|
throw new Error("Invalid hostname");
|
|
399
200
|
}
|
|
400
|
-
const allowPrivateNetwork =
|
|
201
|
+
const allowPrivateNetwork = isPrivateNetworkAllowedByPolicy(params.policy);
|
|
401
202
|
const allowedHostnames = normalizeHostnameSet(params.policy?.allowedHostnames);
|
|
402
203
|
const hostnameAllowlist = normalizeHostnameAllowlist(params.policy?.hostnameAllowlist);
|
|
403
204
|
const isExplicitAllowed = allowedHostnames.has(normalized);
|
|
205
|
+
const skipPrivateNetworkChecks = allowPrivateNetwork || isExplicitAllowed;
|
|
404
206
|
if (!matchesHostnameAllowlist(normalized, hostnameAllowlist)) {
|
|
405
207
|
throw new SsrFBlockedError(`Blocked hostname (not in allowlist): ${hostname}`);
|
|
406
208
|
}
|
|
407
|
-
if (!
|
|
408
|
-
|
|
209
|
+
if (!skipPrivateNetworkChecks) {
|
|
210
|
+
// Phase 1: fail fast for literal hosts/IPs before any DNS lookup side-effects.
|
|
211
|
+
assertAllowedHostOrIpOrThrow(normalized, params.policy);
|
|
409
212
|
}
|
|
410
213
|
const lookupFn = params.lookupFn ?? dnsLookup;
|
|
411
214
|
const results = await lookupFn(normalized, { all: true });
|
|
412
215
|
if (results.length === 0) {
|
|
413
216
|
throw new Error(`Unable to resolve hostname: ${hostname}`);
|
|
414
217
|
}
|
|
415
|
-
if (!
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
throw new SsrFBlockedError("Blocked: resolves to private/internal/special-use IP address");
|
|
419
|
-
}
|
|
420
|
-
}
|
|
218
|
+
if (!skipPrivateNetworkChecks) {
|
|
219
|
+
// Phase 2: re-check DNS answers so public hostnames cannot pivot to private targets.
|
|
220
|
+
assertAllowedResolvedAddressesOrThrow(results, params.policy);
|
|
421
221
|
}
|
|
422
|
-
|
|
222
|
+
// Prefer addresses returned as IPv4 by DNS family metadata before other
|
|
223
|
+
// families so Happy Eyeballs and pinned round-robin both attempt IPv4 first.
|
|
224
|
+
const addresses = dedupeAndPreferIpv4(results);
|
|
423
225
|
if (addresses.length === 0) {
|
|
424
226
|
throw new Error(`Unable to resolve hostname: ${hostname}`);
|
|
425
227
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { BOUNDARY_PATH_ALIAS_POLICIES, resolveBoundaryPath, } from "./boundary-path.js";
|
|
2
|
+
import { assertNoHardlinkedFinalPath } from "./hardlink-guards.js";
|
|
3
|
+
export const PATH_ALIAS_POLICIES = BOUNDARY_PATH_ALIAS_POLICIES;
|
|
4
|
+
export async function assertNoPathAliasEscape(params) {
|
|
5
|
+
const resolved = await resolveBoundaryPath({
|
|
6
|
+
absolutePath: params.absolutePath,
|
|
7
|
+
rootPath: params.rootPath,
|
|
8
|
+
boundaryLabel: params.boundaryLabel,
|
|
9
|
+
policy: params.policy,
|
|
10
|
+
});
|
|
11
|
+
const allowFinalSymlink = params.policy?.allowFinalSymlinkForUnlink === true;
|
|
12
|
+
if (allowFinalSymlink && resolved.kind === "symlink") {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
await assertNoHardlinkedFinalPath({
|
|
16
|
+
filePath: resolved.absolutePath,
|
|
17
|
+
root: resolved.rootPath,
|
|
18
|
+
boundaryLabel: params.boundaryLabel,
|
|
19
|
+
allowFinalHardlinkForUnlink: params.policy?.allowFinalHardlinkForUnlink,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
const NOT_FOUND_CODES = new Set(["ENOENT", "ENOTDIR"]);
|
|
3
3
|
const SYMLINK_OPEN_CODES = new Set(["ELOOP", "EINVAL", "ENOTSUP"]);
|
|
4
|
+
export function normalizeWindowsPathForComparison(input) {
|
|
5
|
+
let normalized = path.win32.normalize(input);
|
|
6
|
+
if (normalized.startsWith("\\\\?\\")) {
|
|
7
|
+
normalized = normalized.slice(4);
|
|
8
|
+
if (normalized.toUpperCase().startsWith("UNC\\")) {
|
|
9
|
+
normalized = `\\\\${normalized.slice(4)}`;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
return normalized.replaceAll("/", "\\").toLowerCase();
|
|
13
|
+
}
|
|
4
14
|
export function isNodeError(value) {
|
|
5
15
|
return Boolean(value && typeof value === "object" && "code" in value);
|
|
6
16
|
}
|
|
@@ -17,7 +27,9 @@ export function isPathInside(root, target) {
|
|
|
17
27
|
const resolvedRoot = path.resolve(root);
|
|
18
28
|
const resolvedTarget = path.resolve(target);
|
|
19
29
|
if (process.platform === "win32") {
|
|
20
|
-
const
|
|
30
|
+
const rootForCompare = normalizeWindowsPathForComparison(resolvedRoot);
|
|
31
|
+
const targetForCompare = normalizeWindowsPathForComparison(resolvedTarget);
|
|
32
|
+
const relative = path.win32.relative(rootForCompare, targetForCompare);
|
|
21
33
|
return relative === "" || (!relative.startsWith("..") && !path.win32.isAbsolute(relative));
|
|
22
34
|
}
|
|
23
35
|
const relative = path.relative(resolvedRoot, resolvedTarget);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import net from "node:net";
|
|
2
|
+
export async function tryListenOnPort(params) {
|
|
3
|
+
const listenOptions = { port: params.port };
|
|
4
|
+
if (params.host) {
|
|
5
|
+
listenOptions.host = params.host;
|
|
6
|
+
}
|
|
7
|
+
if (typeof params.exclusive === "boolean") {
|
|
8
|
+
listenOptions.exclusive = params.exclusive;
|
|
9
|
+
}
|
|
10
|
+
await new Promise((resolve, reject) => {
|
|
11
|
+
const tester = net
|
|
12
|
+
.createServer()
|
|
13
|
+
.once("error", (err) => reject(err))
|
|
14
|
+
.once("listening", () => {
|
|
15
|
+
tester.close(() => resolve());
|
|
16
|
+
})
|
|
17
|
+
.listen(listenOptions);
|
|
18
|
+
});
|
|
19
|
+
}
|