leapfrog-mcp 0.6.2 → 0.6.3
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/adaptive-wait.d.ts +72 -0
- package/dist/adaptive-wait.js +695 -0
- package/dist/api-intelligence.d.ts +82 -0
- package/dist/api-intelligence.js +575 -0
- package/dist/captcha-solver.d.ts +26 -0
- package/dist/captcha-solver.js +547 -0
- package/dist/cdp-connector.d.ts +33 -0
- package/dist/cdp-connector.js +176 -0
- package/dist/consent-dismiss.d.ts +33 -0
- package/dist/consent-dismiss.js +358 -0
- package/dist/crash-recovery.d.ts +74 -0
- package/dist/crash-recovery.js +242 -0
- package/dist/domain-knowledge.d.ts +149 -0
- package/dist/domain-knowledge.js +449 -0
- package/dist/harness-intelligence.d.ts +65 -0
- package/dist/harness-intelligence.js +432 -0
- package/dist/humanize-fingerprint.d.ts +42 -0
- package/dist/humanize-fingerprint.js +161 -0
- package/dist/humanize-mouse.d.ts +95 -0
- package/dist/humanize-mouse.js +275 -0
- package/dist/humanize-pause.d.ts +48 -0
- package/dist/humanize-pause.js +111 -0
- package/dist/humanize-scroll.d.ts +67 -0
- package/dist/humanize-scroll.js +185 -0
- package/dist/humanize-typing.d.ts +60 -0
- package/dist/humanize-typing.js +258 -0
- package/dist/humanize-utils.d.ts +62 -0
- package/dist/humanize-utils.js +100 -0
- package/dist/intervention.d.ts +65 -0
- package/dist/intervention.js +591 -0
- package/dist/logger.d.ts +13 -0
- package/dist/logger.js +47 -0
- package/dist/network-intelligence.d.ts +70 -0
- package/dist/network-intelligence.js +424 -0
- package/dist/page-classifier.d.ts +33 -0
- package/dist/page-classifier.js +1000 -0
- package/dist/paginate.d.ts +42 -0
- package/dist/paginate.js +693 -0
- package/dist/recording.d.ts +72 -0
- package/dist/recording.js +934 -0
- package/dist/script-executor.d.ts +31 -0
- package/dist/script-executor.js +249 -0
- package/dist/session-hud.d.ts +20 -0
- package/dist/session-hud.js +134 -0
- package/dist/snapshot-differ.d.ts +26 -0
- package/dist/snapshot-differ.js +225 -0
- package/dist/ssrf.d.ts +28 -0
- package/dist/ssrf.js +290 -0
- package/dist/stealth-audit.d.ts +27 -0
- package/dist/stealth-audit.js +719 -0
- package/dist/stealth.d.ts +195 -0
- package/dist/stealth.js +1157 -0
- package/dist/tab-manager.d.ts +14 -0
- package/dist/tab-manager.js +306 -0
- package/dist/tiles-coordinator.d.ts +106 -0
- package/dist/tiles-coordinator.js +358 -0
- package/package.json +1 -1
package/dist/ssrf.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Synchronous SSRF check covering:
|
|
3
|
+
* - Blocked hostnames (localhost, cloud metadata)
|
|
4
|
+
* - Blocked TLDs (.internal)
|
|
5
|
+
* - Direct IP ranges (IPv4, IPv6)
|
|
6
|
+
* - IPv4-mapped IPv6 (::ffff:127.0.0.1, ::ffff:7f00:1)
|
|
7
|
+
* - Octal, hex, decimal IP encodings
|
|
8
|
+
*
|
|
9
|
+
* Returns a block reason string, or null if allowed.
|
|
10
|
+
* Does NOT perform DNS resolution -- use checkSSRF() for full protection.
|
|
11
|
+
*/
|
|
12
|
+
export declare function checkSSRFSync(hostname: string): string | null;
|
|
13
|
+
/**
|
|
14
|
+
* Full SSRF check: runs all synchronous checks, then performs DNS resolution
|
|
15
|
+
* to catch hostnames that resolve to internal IPs (DNS rebinding, etc.).
|
|
16
|
+
*/
|
|
17
|
+
export declare function checkSSRF(hostname: string): Promise<string | null>;
|
|
18
|
+
/**
|
|
19
|
+
* Install a Playwright route handler on a page that intercepts ALL requests
|
|
20
|
+
* and blocks those targeting internal IPs / blocked hostnames.
|
|
21
|
+
*
|
|
22
|
+
* This catches redirect chains (302 -> internal IP) BEFORE the browser
|
|
23
|
+
* follows them, closing the TOCTOU gap in the post-navigation check.
|
|
24
|
+
*
|
|
25
|
+
* Uses the synchronous check only (no DNS) to avoid blocking the request
|
|
26
|
+
* pipeline. DNS-based checks are still applied at the navigate tool level.
|
|
27
|
+
*/
|
|
28
|
+
export declare function installSSRFRouteGuard(page: import("playwright-core").Page): Promise<void>;
|
package/dist/ssrf.js
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// ─── SSRF Protection (shared module) ─────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Centralized SSRF validation used by:
|
|
4
|
+
// - index.ts (navigate tool pre-check + post-redirect check)
|
|
5
|
+
// - session-manager.ts (page.route interception for redirect chains)
|
|
6
|
+
// - recording.ts (session_replay navigate steps)
|
|
7
|
+
// - paginate.ts (URL-pattern pagination)
|
|
8
|
+
//
|
|
9
|
+
// checkSSRFSync() — fast synchronous check (IP ranges, hostnames, TLDs)
|
|
10
|
+
// checkSSRF() — full async check (sync checks + DNS resolution)
|
|
11
|
+
import * as dns from "dns/promises";
|
|
12
|
+
import * as net from "net";
|
|
13
|
+
import { logger } from "./logger.js";
|
|
14
|
+
// ─── Localhost policy ───────────────────────────────────────────────────
|
|
15
|
+
//
|
|
16
|
+
// Localhost and 127.0.0.0/8 are ALLOWED by default. Leapfrog is a local-first
|
|
17
|
+
// dev tool — blocking your own dev server out of the box is hostile UX.
|
|
18
|
+
// Set LEAP_BLOCK_LOCALHOST=true to block loopback for hardened environments.
|
|
19
|
+
// All other internal ranges (10.x, 172.16.x, 192.168.x, cloud metadata)
|
|
20
|
+
// remain blocked regardless.
|
|
21
|
+
function allowLocalhost() {
|
|
22
|
+
return process.env.LEAP_BLOCK_LOCALHOST !== 'true';
|
|
23
|
+
}
|
|
24
|
+
const LOCALHOST_HOSTNAMES = new Set([
|
|
25
|
+
'localhost',
|
|
26
|
+
'localhost.localdomain',
|
|
27
|
+
]);
|
|
28
|
+
function isLoopbackIP(ip) {
|
|
29
|
+
return /^127\./.test(ip) || ip === '::1';
|
|
30
|
+
}
|
|
31
|
+
// ─── Blocked ranges & hostnames ─────────────────────────────────────────
|
|
32
|
+
const BLOCKED_IP_RANGES = [
|
|
33
|
+
/^127\./, /^10\./, /^172\.(1[6-9]|2\d|3[01])\./, /^192\.168\./,
|
|
34
|
+
/^169\.254\./, /^0\./, /^::1$/, /^fc00:/, /^fe80:/, /^fd/,
|
|
35
|
+
/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./, // CGNAT range 100.64.0.0/10
|
|
36
|
+
/^198\.1[89]\./, // Benchmarking range 198.18.0.0/15
|
|
37
|
+
];
|
|
38
|
+
const BLOCKED_HOSTNAMES = new Set([
|
|
39
|
+
'localhost',
|
|
40
|
+
'localhost.localdomain',
|
|
41
|
+
'ip6-localhost',
|
|
42
|
+
'ip6-loopback',
|
|
43
|
+
// Cloud metadata hostnames
|
|
44
|
+
'metadata.google.internal',
|
|
45
|
+
'kubernetes.default.svc',
|
|
46
|
+
'kubernetes.default.svc.cluster.local',
|
|
47
|
+
]);
|
|
48
|
+
/** TLDs that should be blocked outright */
|
|
49
|
+
const BLOCKED_TLDS = [
|
|
50
|
+
'.internal',
|
|
51
|
+
];
|
|
52
|
+
// ─── IP format parsers ──────────────────────────────────────────────────
|
|
53
|
+
function isInternalIP(ip) {
|
|
54
|
+
return BLOCKED_IP_RANGES.some((r) => r.test(ip));
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Parse octal IP notation like 0177.0.0.1 -> 127.0.0.1
|
|
58
|
+
* Returns null if not a valid octal IP.
|
|
59
|
+
*/
|
|
60
|
+
function parseOctalIP(hostname) {
|
|
61
|
+
const parts = hostname.split('.');
|
|
62
|
+
if (parts.length !== 4)
|
|
63
|
+
return null;
|
|
64
|
+
const nums = [];
|
|
65
|
+
for (const part of parts) {
|
|
66
|
+
if (!/^0?\d+$/.test(part))
|
|
67
|
+
return null;
|
|
68
|
+
const n = part.startsWith('0') && part.length > 1 ? parseInt(part, 8) : parseInt(part, 10);
|
|
69
|
+
if (isNaN(n) || n < 0 || n > 255)
|
|
70
|
+
return null;
|
|
71
|
+
nums.push(n);
|
|
72
|
+
}
|
|
73
|
+
return nums.join('.');
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Parse hex IP notation like 0x7f000001 -> 127.0.0.1
|
|
77
|
+
* Returns null if not a valid hex IP.
|
|
78
|
+
*/
|
|
79
|
+
function parseHexIP(hostname) {
|
|
80
|
+
if (!/^0x[0-9a-fA-F]+$/.test(hostname))
|
|
81
|
+
return null;
|
|
82
|
+
const num = parseInt(hostname, 16);
|
|
83
|
+
if (isNaN(num) || num < 0 || num > 0xFFFFFFFF)
|
|
84
|
+
return null;
|
|
85
|
+
return [
|
|
86
|
+
(num >>> 24) & 0xFF,
|
|
87
|
+
(num >>> 16) & 0xFF,
|
|
88
|
+
(num >>> 8) & 0xFF,
|
|
89
|
+
num & 0xFF,
|
|
90
|
+
].join('.');
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Parse decimal IP notation like 2130706433 -> 127.0.0.1
|
|
94
|
+
* Returns null if not a valid decimal IP.
|
|
95
|
+
*/
|
|
96
|
+
function parseDecimalIP(hostname) {
|
|
97
|
+
if (!/^\d+$/.test(hostname))
|
|
98
|
+
return null;
|
|
99
|
+
const num = parseInt(hostname, 10);
|
|
100
|
+
if (isNaN(num) || num < 0 || num > 0xFFFFFFFF)
|
|
101
|
+
return null;
|
|
102
|
+
return [
|
|
103
|
+
(num >>> 24) & 0xFF,
|
|
104
|
+
(num >>> 16) & 0xFF,
|
|
105
|
+
(num >>> 8) & 0xFF,
|
|
106
|
+
num & 0xFF,
|
|
107
|
+
].join('.');
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Extract the embedded IPv4 address from an IPv4-mapped IPv6 address.
|
|
111
|
+
* Handles both dotted form (::ffff:127.0.0.1) and hex form (::ffff:7f00:1).
|
|
112
|
+
* Returns the IPv4 address string, or null if not an IPv4-mapped IPv6.
|
|
113
|
+
*/
|
|
114
|
+
function extractIPv4FromMappedIPv6(ip) {
|
|
115
|
+
const lower = ip.toLowerCase();
|
|
116
|
+
// Match ::ffff: prefix (case-insensitive)
|
|
117
|
+
if (!lower.startsWith('::ffff:'))
|
|
118
|
+
return null;
|
|
119
|
+
const suffix = ip.slice(7); // strip "::ffff:"
|
|
120
|
+
// Dotted form: ::ffff:127.0.0.1
|
|
121
|
+
if (suffix.includes('.')) {
|
|
122
|
+
// Validate it's a proper IPv4
|
|
123
|
+
if (net.isIPv4(suffix))
|
|
124
|
+
return suffix;
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
// Hex form: ::ffff:7f00:1 (URL constructor normalizes to this)
|
|
128
|
+
// Two 16-bit hex groups representing the IPv4 address
|
|
129
|
+
const hexParts = suffix.split(':');
|
|
130
|
+
if (hexParts.length !== 2)
|
|
131
|
+
return null;
|
|
132
|
+
const hi = parseInt(hexParts[0], 16);
|
|
133
|
+
const lo = parseInt(hexParts[1], 16);
|
|
134
|
+
if (isNaN(hi) || isNaN(lo) || hi < 0 || hi > 0xFFFF || lo < 0 || lo > 0xFFFF)
|
|
135
|
+
return null;
|
|
136
|
+
return [
|
|
137
|
+
(hi >>> 8) & 0xFF,
|
|
138
|
+
hi & 0xFF,
|
|
139
|
+
(lo >>> 8) & 0xFF,
|
|
140
|
+
lo & 0xFF,
|
|
141
|
+
].join('.');
|
|
142
|
+
}
|
|
143
|
+
// ─── Synchronous SSRF check (fast path) ─────────────────────────────────
|
|
144
|
+
/**
|
|
145
|
+
* Synchronous SSRF check covering:
|
|
146
|
+
* - Blocked hostnames (localhost, cloud metadata)
|
|
147
|
+
* - Blocked TLDs (.internal)
|
|
148
|
+
* - Direct IP ranges (IPv4, IPv6)
|
|
149
|
+
* - IPv4-mapped IPv6 (::ffff:127.0.0.1, ::ffff:7f00:1)
|
|
150
|
+
* - Octal, hex, decimal IP encodings
|
|
151
|
+
*
|
|
152
|
+
* Returns a block reason string, or null if allowed.
|
|
153
|
+
* Does NOT perform DNS resolution -- use checkSSRF() for full protection.
|
|
154
|
+
*/
|
|
155
|
+
export function checkSSRFSync(hostname) {
|
|
156
|
+
const lowerHostname = hostname.toLowerCase();
|
|
157
|
+
// Allow localhost when not explicitly blocked
|
|
158
|
+
if (allowLocalhost() && LOCALHOST_HOSTNAMES.has(lowerHostname)) {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
// Blocked hostname check (localhost, cloud metadata, etc.)
|
|
162
|
+
if (BLOCKED_HOSTNAMES.has(lowerHostname)) {
|
|
163
|
+
return `Blocked: ${hostname} is a reserved hostname.`;
|
|
164
|
+
}
|
|
165
|
+
// Blocked TLD check (.internal)
|
|
166
|
+
for (const tld of BLOCKED_TLDS) {
|
|
167
|
+
if (lowerHostname === tld.slice(1) || lowerHostname.endsWith(tld)) {
|
|
168
|
+
return `Blocked: ${hostname} uses a reserved TLD (${tld}).`;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
// IPv6 bracket notation: [::1] -> ::1
|
|
172
|
+
let normalizedHost = hostname;
|
|
173
|
+
if (normalizedHost.startsWith('[') && normalizedHost.endsWith(']')) {
|
|
174
|
+
normalizedHost = normalizedHost.slice(1, -1);
|
|
175
|
+
}
|
|
176
|
+
// IPv4-mapped IPv6: ::ffff:127.0.0.1 or ::ffff:7f00:1
|
|
177
|
+
const mappedIPv4 = extractIPv4FromMappedIPv6(normalizedHost);
|
|
178
|
+
if (mappedIPv4) {
|
|
179
|
+
if (isInternalIP(mappedIPv4)) {
|
|
180
|
+
return `Blocked: ${hostname} is an IPv4-mapped IPv6 address resolving to internal IP ${mappedIPv4}.`;
|
|
181
|
+
}
|
|
182
|
+
// It's a valid IPv4-mapped IPv6 pointing to a public IP -- allow
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
// Direct IP check
|
|
186
|
+
if (net.isIP(normalizedHost)) {
|
|
187
|
+
if (allowLocalhost() && isLoopbackIP(normalizedHost))
|
|
188
|
+
return null;
|
|
189
|
+
if (isInternalIP(normalizedHost))
|
|
190
|
+
return `Blocked: ${hostname} is an internal IP address.`;
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
// Hex IP notation: 0x7f000001 -> 127.0.0.1
|
|
194
|
+
const hexResolved = parseHexIP(normalizedHost);
|
|
195
|
+
if (hexResolved) {
|
|
196
|
+
if (isInternalIP(hexResolved))
|
|
197
|
+
return `Blocked: ${hostname} resolves to internal IP ${hexResolved} (hex notation).`;
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
// Octal IP notation: 0177.0.0.1 -> 127.0.0.1
|
|
201
|
+
const octalResolved = parseOctalIP(normalizedHost);
|
|
202
|
+
if (octalResolved) {
|
|
203
|
+
if (isInternalIP(octalResolved))
|
|
204
|
+
return `Blocked: ${hostname} resolves to internal IP ${octalResolved} (octal notation).`;
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
// Decimal IP notation: 2130706433 -> 127.0.0.1
|
|
208
|
+
const decimalResolved = parseDecimalIP(normalizedHost);
|
|
209
|
+
if (decimalResolved) {
|
|
210
|
+
if (isInternalIP(decimalResolved))
|
|
211
|
+
return `Blocked: ${hostname} resolves to internal IP ${decimalResolved} (decimal notation).`;
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
// ─── Full async SSRF check (sync + DNS) ─────────────────────────────────
|
|
217
|
+
/**
|
|
218
|
+
* Full SSRF check: runs all synchronous checks, then performs DNS resolution
|
|
219
|
+
* to catch hostnames that resolve to internal IPs (DNS rebinding, etc.).
|
|
220
|
+
*/
|
|
221
|
+
export async function checkSSRF(hostname) {
|
|
222
|
+
// Run synchronous checks first (fast path)
|
|
223
|
+
const syncResult = checkSSRFSync(hostname);
|
|
224
|
+
if (syncResult !== null)
|
|
225
|
+
return syncResult;
|
|
226
|
+
// If it was a recognized IP format (direct, hex, octal, decimal, mapped IPv6),
|
|
227
|
+
// the sync check already returned. Only hostnames reach here.
|
|
228
|
+
// Strip brackets for DNS resolution
|
|
229
|
+
let normalizedHost = hostname;
|
|
230
|
+
if (normalizedHost.startsWith('[') && normalizedHost.endsWith(']')) {
|
|
231
|
+
normalizedHost = normalizedHost.slice(1, -1);
|
|
232
|
+
}
|
|
233
|
+
// Skip DNS for raw IPs (already handled by sync check)
|
|
234
|
+
if (net.isIP(normalizedHost))
|
|
235
|
+
return null;
|
|
236
|
+
// DNS resolution check (catches DNS rebinding)
|
|
237
|
+
try {
|
|
238
|
+
const addresses = await dns.resolve4(normalizedHost);
|
|
239
|
+
for (const addr of addresses) {
|
|
240
|
+
if (allowLocalhost() && isLoopbackIP(addr))
|
|
241
|
+
continue;
|
|
242
|
+
if (isInternalIP(addr)) {
|
|
243
|
+
return `Blocked: ${hostname} resolves to internal IP ${addr}.`;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
// DNS failure -- let the browser handle it (will show its own error)
|
|
249
|
+
}
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
// ─── Route handler for Playwright page interception ─────────────────────
|
|
253
|
+
/**
|
|
254
|
+
* Install a Playwright route handler on a page that intercepts ALL requests
|
|
255
|
+
* and blocks those targeting internal IPs / blocked hostnames.
|
|
256
|
+
*
|
|
257
|
+
* This catches redirect chains (302 -> internal IP) BEFORE the browser
|
|
258
|
+
* follows them, closing the TOCTOU gap in the post-navigation check.
|
|
259
|
+
*
|
|
260
|
+
* Uses the synchronous check only (no DNS) to avoid blocking the request
|
|
261
|
+
* pipeline. DNS-based checks are still applied at the navigate tool level.
|
|
262
|
+
*/
|
|
263
|
+
export async function installSSRFRouteGuard(page) {
|
|
264
|
+
await page.route('**/*', (route) => {
|
|
265
|
+
try {
|
|
266
|
+
const url = new URL(route.request().url());
|
|
267
|
+
// Only check http/https -- allow data:, blob:, etc.
|
|
268
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
269
|
+
route.continue().catch(() => { });
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
const blockReason = checkSSRFSync(url.hostname);
|
|
273
|
+
if (blockReason) {
|
|
274
|
+
logger.warn("security.ssrf_route_blocked", {
|
|
275
|
+
url: route.request().url(),
|
|
276
|
+
hostname: url.hostname,
|
|
277
|
+
reason: blockReason,
|
|
278
|
+
});
|
|
279
|
+
route.abort('blockedbyclient').catch(() => { });
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
route.continue().catch(() => { });
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
// URL parse error or other failure -- let the request through
|
|
286
|
+
// rather than breaking legitimate navigation
|
|
287
|
+
route.continue().catch(() => { });
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { StealthModeType } from "./stealth.js";
|
|
2
|
+
export type AuditMode = 'off' | 'passive' | 'active' | 'compare';
|
|
3
|
+
export interface AuditResult {
|
|
4
|
+
label: string;
|
|
5
|
+
status: "pass" | "fail" | "warn";
|
|
6
|
+
detail?: string;
|
|
7
|
+
tier: 1 | 2 | 3;
|
|
8
|
+
priority?: string;
|
|
9
|
+
/** When true, this failure is expected for the given mode (e.g. plugins=0 in passive) */
|
|
10
|
+
expected?: boolean;
|
|
11
|
+
}
|
|
12
|
+
export interface AuditOptions {
|
|
13
|
+
localOnly?: boolean;
|
|
14
|
+
full?: boolean;
|
|
15
|
+
json?: boolean;
|
|
16
|
+
headed?: boolean;
|
|
17
|
+
mode?: AuditMode;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Run the audit for a single stealth mode. Returns tagged results.
|
|
21
|
+
* Extracted from main so compare mode can call it 3 times.
|
|
22
|
+
*/
|
|
23
|
+
export declare function runAuditForMode(stealthMode: StealthModeType, options: Pick<AuditOptions, "localOnly" | "full" | "headed">): Promise<{
|
|
24
|
+
results: AuditResult[];
|
|
25
|
+
durationMs: number;
|
|
26
|
+
}>;
|
|
27
|
+
export declare function runStealthAudit(options?: AuditOptions): Promise<void>;
|