crawlforge-mcp-server 5.5.3 → 5.5.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crawlforge-mcp-server",
3
- "version": "5.5.3",
3
+ "version": "5.5.5",
4
4
  "mcpName": "io.github.mysleekdesigns/crawlforge-mcp-server",
5
5
  "description": "CrawlForge MCP Server - Professional Model Context Protocol server with 29 web scraping, crawling, deep-research, and autonomous-extraction tools. Returns clean Markdown and structured JSON for Claude, Cursor, and any MCP client. Defaults to local Ollama for LLM extraction (no API key needed); OpenAI/Anthropic available as opt-in. Includes a unified multi-format scrape tool, an autonomous agent, pre-built site templates, and Camoufox stealth browsing.",
6
6
  "main": "server.js",
@@ -114,7 +114,7 @@
114
114
  "cheerio": "^1.1.2",
115
115
  "commander": "^14.0.3",
116
116
  "compromise": "^14.14.4",
117
- "crawlforge-extractors": "^1.5.1",
117
+ "crawlforge-extractors": "^1.5.2",
118
118
  "diff": "^9.0.0",
119
119
  "dotenv": "^17.2.1",
120
120
  "franc": "^6.2.0",
package/server.js CHANGED
@@ -105,7 +105,7 @@ const taskStore = createTaskStore({ logger });
105
105
  // Create the server
106
106
  const server = new McpServer({
107
107
  name: "crawlforge",
108
- version: "5.5.3",
108
+ version: "5.5.5",
109
109
  description: "Production-ready MCP server with 29 web scraping, crawling, and content processing tools. Features MCP Resources (crawlforge://), Prompts, Sampling fallback, Elicitation, stealth browsing, deep research, structured extraction, embedded JavaScript state extraction, real Google SERP rank tracking, Reddit search via community archives, change tracking, local-LLM extraction via Ollama, unified multi-format scrape, and autonomous agent tool.",
110
110
  homepage: "https://www.crawlforge.dev",
111
111
  icon: "https://www.crawlforge.dev/icon.png",
@@ -922,7 +922,7 @@ registerToolIfEnabled("scrape_with_actions", {
922
922
  includeMetadata: z.boolean().default(true),
923
923
  includeLinks: z.boolean().default(true),
924
924
  includeImages: z.boolean().default(true)
925
- }).optional().describe("Content extraction options"),
925
+ }).optional().describe("Content extraction options. selectors results are returned as content.json.extracted, so include \"json\" in formats when passing selectors — without it the extraction is not part of the response."),
926
926
  continueOnActionError: z.boolean().default(false).describe("Continue executing actions if one fails"),
927
927
  maxRetries: z.number().min(0).max(3).default(1).describe("Maximum retry attempts on failure"),
928
928
  screenshotOnError: z.boolean().default(true).describe("Capture screenshot when an error occurs"),
@@ -8,7 +8,7 @@ import { ResultRanker } from '../tools/search/ranking/ResultRanker.js';
8
8
  import { CacheManager } from './cache/CacheManager.js';
9
9
  import { Logger } from '../utils/Logger.js';
10
10
  import { LLMManager } from './llm/LLMManager.js';
11
- import { safeFetch } from '../utils/ssrfGuard.js';
11
+ import { safeFetch, safeGoto } from '../utils/ssrfGuard.js';
12
12
  import { preflightFetch, browserPreflight } from '../utils/robotsGate.js';
13
13
  import { noteRetryAfter } from '../utils/hostRateLimiter.js';
14
14
  import {
@@ -989,7 +989,9 @@ export class ResearchOrchestrator extends EventEmitter {
989
989
  page = await this._stealthManager.createStealthPage(contextId);
990
990
  }
991
991
  try {
992
- const resp = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: this.stealthTimeoutMs });
992
+ // browserPreflight (robots + platform blocklist) ran in _stealthFetchHtml;
993
+ // safeGoto adds the SSRF/DNS-rebinding guard the browser path also needs.
994
+ const resp = await safeGoto(page, url, { waitUntil: 'domcontentloaded', timeout: this.stealthTimeoutMs });
993
995
  // Do NOT bail on the initial HTTP status: anti-bot challenges (Cloudflare
994
996
  // Turnstile) return 403 on the first response and only resolve to the
995
997
  // real page after their JS runs. Let it settle, then judge by the
@@ -13,6 +13,7 @@ import { z } from 'zod';
13
13
  import crypto from 'crypto';
14
14
  import HumanBehaviorSimulator from '../utils/HumanBehaviorSimulator.js';
15
15
  import { BrowserContextPool } from './BrowserContextPool.js';
16
+ import { safeGoto } from '../utils/ssrfGuard.js';
16
17
 
17
18
  const StealthConfigSchema = z.object({
18
19
  level: z.enum(['basic', 'medium', 'advanced']).default('medium'),
@@ -1776,7 +1777,9 @@ export class StealthBrowserManager {
1776
1777
  const { contextId } = await this.createStealthContext({ ...stealthConfig, engine });
1777
1778
  try {
1778
1779
  const page = await this.createStealthPage(contextId);
1779
- await page.goto(url, { waitUntil: 'domcontentloaded' });
1780
+ // SSRF guard at the navigation boundary: the stealth engine resolves DNS
1781
+ // itself, so a URL/host-only check would miss rebinding to private/metadata IPs.
1782
+ await safeGoto(page, url, { waitUntil: 'domcontentloaded' });
1780
1783
  if (wait_for > 0) await page.waitForTimeout(wait_for);
1781
1784
 
1782
1785
  const [title, html, text] = await Promise.all([
@@ -9,6 +9,7 @@ import { z } from 'zod';
9
9
  import StealthBrowserManager from '../StealthBrowserManager.js';
10
10
  import HumanBehaviorSimulator from '../../utils/HumanBehaviorSimulator.js';
11
11
  import LocalizationManager from '../LocalizationManager.js';
12
+ import { safeGoto } from '../../utils/ssrfGuard.js';
12
13
 
13
14
  const BrowserProcessorSchema = z.object({
14
15
  url: z.string().url(),
@@ -613,8 +614,10 @@ export class BrowserProcessor {
613
614
  async navigateAndWait(page, url, options) {
614
615
  const startTime = Date.now();
615
616
 
616
- // Navigate to URL
617
- await page.goto(url, {
617
+ // Navigate to URL. safeGoto adds the SSRF/DNS-rebinding guard on top of the
618
+ // robots/blocklist preflight the calling tool already ran, and re-checks the
619
+ // landed URL so a redirect can't carry us into a private/metadata range.
620
+ await safeGoto(page, url, {
618
621
  waitUntil: 'domcontentloaded',
619
622
  timeout: 30000
620
623
  });
@@ -323,6 +323,30 @@ export async function safeFetch(url, options = {}) {
323
323
  }
324
324
  }
325
325
 
326
+ /**
327
+ * SSRF-safe wrapper around Playwright's `page.goto`. The navigate analog of
328
+ * safeFetch: a browser drives its own DNS resolution, so URL/host checks alone
329
+ * miss DNS-rebinding and private-IP targets — resolveDns:true closes that.
330
+ * Checks the URL before navigating, then re-checks the landed URL, because a
331
+ * redirect during navigation could carry us into a blocked range even when the
332
+ * original URL was safe. Returns the Response so callers can read the status.
333
+ * Options pass through unchanged; behaviour-preserving for allowed URLs.
334
+ *
335
+ * @param {import('playwright').Page} page
336
+ * @param {string} url
337
+ * @param {Parameters<import('playwright').Page['goto']>[1]} [options]
338
+ * @returns {Promise<import('playwright').Response|null>}
339
+ */
340
+ export async function safeGoto(page, url, options = {}) {
341
+ await assertUrlAllowed(url, { resolveDns: true });
342
+ const response = await page.goto(url, options);
343
+ const landedUrl = page.url();
344
+ if (/^https?:\/\//i.test(landedUrl)) {
345
+ await assertUrlAllowed(landedUrl, { resolveDns: true });
346
+ }
347
+ return response;
348
+ }
349
+
326
350
  // Exposed for unit tests.
327
351
  export const __ssrfInternals = {
328
352
  ssrfLookup,
@@ -1,120 +0,0 @@
1
- /**
2
- * Critical Security Patches for Wave 3 Features
3
- *
4
- * This file contains emergency patches for critical vulnerabilities
5
- * identified in the security audit. Apply these patches immediately
6
- * before any production deployment.
7
- */
8
-
9
- import Wave3Security from './wave3-security.js';
10
- const { SSRFProtection, PathSecurity, InputSecurity, CryptoSecurity, BrowserSecurity } = Wave3Security;
11
-
12
- /**
13
- * CRITICAL PATCH 1: SSRF Protection for Research Tool
14
- * Fixes: CVE-001 - Server-Side Request Forgery
15
- */
16
- export function patchResearchOrchestrator(ResearchOrchestrator) {
17
- const originalConductResearch = ResearchOrchestrator.prototype.conductResearch;
18
-
19
- ResearchOrchestrator.prototype.conductResearch = async function(topic, options = {}) {
20
- // Input validation
21
- if (typeof topic !== 'string' || topic.length < 3 || topic.length > 500) {
22
- throw new Error('Invalid topic: must be string between 3-500 characters');
23
- }
24
-
25
- // Sanitize topic to prevent script injection
26
- const sanitizedTopic = InputSecurity.sanitizeString(topic);
27
-
28
- // Validate and limit research parameters
29
- const secureOptions = {
30
- ...options,
31
- maxUrls: Math.min(options.maxUrls || 10, 50),
32
- timeLimit: Math.min(options.timeLimit || 60000, 120000),
33
- maxDepth: Math.min(Math.max(options.maxDepth || 3, 1), 5),
34
- concurrency: Math.min(options.concurrency || 3, 5)
35
- };
36
-
37
- return originalConductResearch.call(this, sanitizedTopic, secureOptions);
38
- };
39
- }
40
-
41
- /**
42
- * CRITICAL PATCH 2: Browser Security Hardening
43
- * Fixes: CVE-002, CVE-007 - Script Injection and Browser Security
44
- */
45
- export function patchStealthBrowserManager(StealthBrowserManager) {
46
- // Override dangerous browser args
47
- const originalLaunchStealthBrowser = StealthBrowserManager.prototype.launchStealthBrowser;
48
-
49
- StealthBrowserManager.prototype.launchStealthBrowser = async function(config = {}) {
50
- // Force secure browser configuration
51
- const secureConfig = {
52
- ...config,
53
- level: config.level === 'advanced' ? 'medium' : config.level
54
- };
55
-
56
- // Override with secure browser args
57
- this.secureArgs = BrowserSecurity.getSecureBrowserArgs();
58
-
59
- return originalLaunchStealthBrowser.call(this, secureConfig);
60
- };
61
- }
62
-
63
- /**
64
- * CRITICAL PATCH 3: Path Traversal Protection
65
- * Fixes: CVE-003 - Arbitrary File System Access
66
- */
67
- export function patchSnapshotManager(SnapshotManager) {
68
- // Secure file operations
69
- const originalWriteSnapshotFile = SnapshotManager.prototype.writeSnapshotFile;
70
-
71
- SnapshotManager.prototype.writeSnapshotFile = async function(snapshotId, content) {
72
- // Validate snapshot ID
73
- const safeSnapshotId = PathSecurity.validateSnapshotId(snapshotId);
74
-
75
- // Validate content size
76
- InputSecurity.validateContentSize(content);
77
-
78
- // Ensure safe file path
79
- const safePath = PathSecurity.sanitizePath(`${safeSnapshotId}.snap`, this.options.storageDir);
80
-
81
- return originalWriteSnapshotFile.call(this, safeSnapshotId, content);
82
- };
83
-
84
- // Override generateSnapshotId to use cryptographically secure generation
85
- SnapshotManager.prototype.generateSnapshotId = function(url, timestamp) {
86
- return CryptoSecurity.generateSecureId(16);
87
- };
88
- }
89
-
90
- /**
91
- * Apply all critical patches
92
- */
93
- export function applyAllSecurityPatches(components) {
94
- console.log('🔒 Applying Wave 3 security patches...');
95
-
96
- if (components.ResearchOrchestrator) {
97
- patchResearchOrchestrator(components.ResearchOrchestrator);
98
- console.log('✅ ResearchOrchestrator patched');
99
- }
100
-
101
- if (components.StealthBrowserManager) {
102
- patchStealthBrowserManager(components.StealthBrowserManager);
103
- console.log('✅ StealthBrowserManager patched');
104
- }
105
-
106
- if (components.SnapshotManager) {
107
- patchSnapshotManager(components.SnapshotManager);
108
- console.log('✅ SnapshotManager patched');
109
- }
110
-
111
- console.log('🛡️ All Wave 3 security patches applied successfully');
112
- console.log('⚠️ Remember to run security tests before deployment');
113
- }
114
-
115
- export default {
116
- patchResearchOrchestrator,
117
- patchStealthBrowserManager,
118
- patchSnapshotManager,
119
- applyAllSecurityPatches
120
- };
@@ -1,355 +0,0 @@
1
- /**
2
- * Wave 3 Security Test Suite
3
- * Validates security patches and tests for vulnerabilities
4
- */
5
-
6
- import Wave3Security from './wave3-security.js';
7
- const { SecurityTesting, SSRFProtection, PathSecurity, InputSecurity } = Wave3Security;
8
-
9
- /**
10
- * Test SSRF Protection
11
- */
12
- export async function testSSRFProtection() {
13
- console.log('🧪 Testing SSRF Protection...');
14
-
15
- const maliciousUrls = [
16
- 'http://localhost:3000/admin',
17
- 'http://127.0.0.1:22',
18
- 'http://169.254.169.254/latest/meta-data/',
19
- 'file:///etc/passwd',
20
- 'ftp://internal.server.com',
21
- 'http://10.0.0.1/secret',
22
- 'https://metadata.google.internal/'
23
- ];
24
-
25
- const results = SecurityTesting.testSecurityFunction(
26
- SSRFProtection.validateUrl.bind(SSRFProtection),
27
- maliciousUrls
28
- );
29
-
30
- const blocked = results.filter(r => r.blocked).length;
31
- const total = results.length;
32
-
33
- console.log(`✅ SSRF Test: ${blocked}/${total} malicious URLs blocked`);
34
-
35
- if (blocked !== total) {
36
- console.error('❌ SSRF Protection insufficient!');
37
- results.filter(r => !r.blocked).forEach(r => {
38
- console.error(` 🚨 Not blocked: ${r.payload}`);
39
- });
40
- }
41
-
42
- return { passed: blocked === total, blocked, total };
43
- }
44
-
45
- /**
46
- * Test Path Traversal Protection
47
- */
48
- export function testPathTraversal() {
49
- console.log('🧪 Testing Path Traversal Protection...');
50
-
51
- const maliciousPaths = [
52
- '../../../etc/passwd',
53
- '..\\..\\..\\windows\\system32\\config\\sam',
54
- '/etc/shadow',
55
- 'C:\\Windows\\System32\\drivers\\etc\\hosts',
56
- '....//....//....//etc/passwd',
57
- '../../../../../../../etc/passwd',
58
- '..\\..\\..\\..\\..\\..\\/etc/passwd'
59
- ];
60
-
61
- const results = SecurityTesting.testSecurityFunction(
62
- PathSecurity.validateSnapshotId.bind(PathSecurity),
63
- maliciousPaths
64
- );
65
-
66
- const blocked = results.filter(r => r.blocked).length;
67
- const total = results.length;
68
-
69
- console.log(`✅ Path Traversal Test: ${blocked}/${total} malicious paths blocked`);
70
-
71
- if (blocked !== total) {
72
- console.error('❌ Path Traversal Protection insufficient!');
73
- results.filter(r => !r.blocked).forEach(r => {
74
- console.error(` 🚨 Not blocked: ${r.payload}`);
75
- });
76
- }
77
-
78
- return { passed: blocked === total, blocked, total };
79
- }
80
-
81
- /**
82
- * Test Input Validation
83
- */
84
- export function testInputValidation() {
85
- console.log('🧪 Testing Input Validation...');
86
-
87
- const maliciousInputs = [
88
- '<script>alert("XSS")</script>',
89
- 'javascript:alert("XSS")',
90
- '<img src="x" onerror="alert(\'XSS\')">',
91
- '<svg onload="alert(\'XSS\')"></svg>',
92
- '${7*7}',
93
- '#{7*7}',
94
- '{{7*7}}',
95
- '<%= 7*7 %>'
96
- ];
97
-
98
- const results = maliciousInputs.map(input => {
99
- try {
100
- const sanitized = InputSecurity.sanitizeHtml(input);
101
- const isBlocked = !sanitized.includes('<script') &&
102
- !sanitized.includes('javascript:') &&
103
- !sanitized.includes('onerror=') &&
104
- !sanitized.includes('onload=');
105
- return { input, blocked: isBlocked, sanitized };
106
- } catch (error) {
107
- return { input, blocked: true, error: error.message };
108
- }
109
- });
110
-
111
- const blocked = results.filter(r => r.blocked).length;
112
- const total = results.length;
113
-
114
- console.log(`✅ Input Validation Test: ${blocked}/${total} malicious inputs neutralized`);
115
-
116
- return { passed: blocked === total, blocked, total };
117
- }
118
-
119
- /**
120
- * Test Resource Limits
121
- */
122
- export function testResourceLimits() {
123
- console.log('🧪 Testing Resource Limits...');
124
-
125
- const tests = [
126
- {
127
- name: 'Large Content',
128
- test: () => {
129
- const largeContent = 'x'.repeat(100 * 1024 * 1024); // 100MB
130
- try {
131
- InputSecurity.validateContentSize(largeContent);
132
- return false; // Should have thrown
133
- } catch (error) {
134
- return error.message.includes('Content too large');
135
- }
136
- }
137
- },
138
- {
139
- name: 'Long String',
140
- test: () => {
141
- const longString = 'x'.repeat(20000);
142
- try {
143
- InputSecurity.sanitizeString(longString, 10000);
144
- return false; // Should have thrown
145
- } catch (error) {
146
- return error.message.includes('String too long');
147
- }
148
- }
149
- },
150
- {
151
- name: 'Large Array',
152
- test: () => {
153
- const largeArray = new Array(200).fill('item');
154
- try {
155
- InputSecurity.sanitizeArray(largeArray, 100);
156
- return false; // Should have thrown
157
- } catch (error) {
158
- return error.message.includes('Array too long');
159
- }
160
- }
161
- }
162
- ];
163
-
164
- let passed = 0;
165
- tests.forEach(test => {
166
- if (test.test()) {
167
- console.log(` ✅ ${test.name}: PASS`);
168
- passed++;
169
- } else {
170
- console.log(` ❌ ${test.name}: FAIL`);
171
- }
172
- });
173
-
174
- console.log(`✅ Resource Limits Test: ${passed}/${tests.length} tests passed`);
175
-
176
- return { passed: passed === tests.length, passedCount: passed, total: tests.length };
177
- }
178
-
179
- /**
180
- * Test Cryptographic Functions
181
- */
182
- export function testCryptographicSecurity() {
183
- console.log('🧪 Testing Cryptographic Security...');
184
-
185
- const tests = [
186
- {
187
- name: 'Secure ID Generation',
188
- test: () => {
189
- const id1 = Wave3Security.CryptoSecurity.generateSecureId();
190
- const id2 = Wave3Security.CryptoSecurity.generateSecureId();
191
-
192
- // Should be different
193
- if (id1 === id2) return false;
194
-
195
- // Should be hex and right length
196
- if (!/^[a-f0-9]{64}$/.test(id1)) return false;
197
- if (!/^[a-f0-9]{64}$/.test(id2)) return false;
198
-
199
- return true;
200
- }
201
- },
202
- {
203
- name: 'Timing Safe Comparison',
204
- test: () => {
205
- const hash1 = 'abcd1234';
206
- const hash2 = 'abcd1234';
207
- const hash3 = 'efgh5678';
208
-
209
- // Same hashes should match
210
- if (!Wave3Security.CryptoSecurity.timingSafeCompare(hash1, hash2)) return false;
211
-
212
- // Different hashes should not match
213
- if (Wave3Security.CryptoSecurity.timingSafeCompare(hash1, hash3)) return false;
214
-
215
- return true;
216
- }
217
- },
218
- {
219
- name: 'Webhook Signature',
220
- test: () => {
221
- const payload = JSON.stringify({ test: 'data' });
222
- const secret = 'test-secret';
223
-
224
- const signature1 = Wave3Security.CryptoSecurity.generateWebhookSignature(payload, secret);
225
- const signature2 = Wave3Security.CryptoSecurity.generateWebhookSignature(payload, secret);
226
-
227
- // Should be consistent
228
- if (signature1 !== signature2) return false;
229
-
230
- // Should validate correctly
231
- if (!Wave3Security.CryptoSecurity.validateWebhookSignature(payload, signature1, secret)) return false;
232
-
233
- // Should reject wrong signature
234
- if (Wave3Security.CryptoSecurity.validateWebhookSignature(payload, 'wrong', secret)) return false;
235
-
236
- return true;
237
- }
238
- }
239
- ];
240
-
241
- let passed = 0;
242
- tests.forEach(test => {
243
- if (test.test()) {
244
- console.log(` ✅ ${test.name}: PASS`);
245
- passed++;
246
- } else {
247
- console.log(` ❌ ${test.name}: FAIL`);
248
- }
249
- });
250
-
251
- console.log(`✅ Cryptographic Security Test: ${passed}/${tests.length} tests passed`);
252
-
253
- return { passed: passed === tests.length, passedCount: passed, total: tests.length };
254
- }
255
-
256
- /**
257
- * Run Full Security Test Suite
258
- */
259
- export async function runFullSecurityTests() {
260
- console.log('🛡️ Running Wave 3 Security Test Suite...\n');
261
-
262
- const results = [];
263
-
264
- // Test SSRF Protection
265
- results.push(await testSSRFProtection());
266
- console.log('');
267
-
268
- // Test Path Traversal Protection
269
- results.push(testPathTraversal());
270
- console.log('');
271
-
272
- // Test Input Validation
273
- results.push(testInputValidation());
274
- console.log('');
275
-
276
- // Test Resource Limits
277
- results.push(testResourceLimits());
278
- console.log('');
279
-
280
- // Test Cryptographic Security
281
- results.push(testCryptographicSecurity());
282
- console.log('');
283
-
284
- // Summary
285
- const totalPassed = results.filter(r => r.passed).length;
286
- const totalTests = results.length;
287
-
288
- console.log('🎯 Security Test Summary:');
289
- console.log(` Passed: ${totalPassed}/${totalTests} test suites`);
290
-
291
- if (totalPassed === totalTests) {
292
- console.log('✅ ALL SECURITY TESTS PASSED! Wave 3 appears secure.');
293
- } else {
294
- console.log('❌ SECURITY TESTS FAILED! Do not deploy to production.');
295
- console.log(' Review failed tests and apply additional patches.');
296
- }
297
-
298
- return {
299
- allPassed: totalPassed === totalTests,
300
- passed: totalPassed,
301
- total: totalTests,
302
- results
303
- };
304
- }
305
-
306
- /**
307
- * Quick Security Health Check
308
- */
309
- export function quickSecurityCheck() {
310
- console.log('🚀 Quick Security Health Check...');
311
-
312
- const checks = [
313
- {
314
- name: 'SSRF Protection Available',
315
- check: () => typeof SSRFProtection.validateUrl === 'function'
316
- },
317
- {
318
- name: 'Path Security Available',
319
- check: () => typeof PathSecurity.validateSnapshotId === 'function'
320
- },
321
- {
322
- name: 'Input Sanitization Available',
323
- check: () => typeof InputSecurity.sanitizeString === 'function'
324
- },
325
- {
326
- name: 'Crypto Security Available',
327
- check: () => typeof Wave3Security.CryptoSecurity.generateSecureId === 'function'
328
- }
329
- ];
330
-
331
- let passed = 0;
332
- checks.forEach(check => {
333
- if (check.check()) {
334
- console.log(` ✅ ${check.name}`);
335
- passed++;
336
- } else {
337
- console.log(` ❌ ${check.name}`);
338
- }
339
- });
340
-
341
- console.log(`\n🎯 Health Check: ${passed}/${checks.length} security functions available`);
342
-
343
- return passed === checks.length;
344
- }
345
-
346
- // Export test functions
347
- export default {
348
- testSSRFProtection,
349
- testPathTraversal,
350
- testInputValidation,
351
- testResourceLimits,
352
- testCryptographicSecurity,
353
- runFullSecurityTests,
354
- quickSecurityCheck
355
- };
@@ -1,653 +0,0 @@
1
- /**
2
- * Wave 3 Security Validation Utilities
3
- * Provides security validation, sanitization, and protection functions
4
- * for Wave 3 features in the CrawlForge MCP Server project.
5
- */
6
-
7
- import { z } from 'zod';
8
- import { createHash, randomBytes, timingSafeEqual } from 'crypto';
9
- import path from 'path';
10
- import { URL } from 'url';
11
- import DOMPurify from 'isomorphic-dompurify';
12
- import { identityHeaders } from '../utils/fetchIdentity.js';
13
-
14
- // Security configuration
15
- const SECURITY_CONFIG = {
16
- // SSRF Protection
17
- allowedDomains: [
18
- 'google.com',
19
- 'bing.com',
20
- 'duckduckgo.com',
21
- 'wikipedia.org',
22
- 'archive.org',
23
- '*.edu',
24
- '*.gov'
25
- ],
26
-
27
- blockedDomains: [
28
- 'localhost',
29
- '127.0.0.1',
30
- '0.0.0.0',
31
- '10.*',
32
- '172.16.*',
33
- '192.168.*',
34
- '169.254.*', // AWS metadata
35
- 'metadata.google.internal'
36
- ],
37
-
38
- // Resource limits
39
- maxContentSize: 50 * 1024 * 1024, // 50MB
40
- maxSnapshotSize: 100 * 1024 * 1024, // 100MB
41
- maxResearchUrls: 50,
42
- maxResearchTime: 120000, // 2 minutes
43
-
44
- // Path validation
45
- allowedDirectories: [
46
- './snapshots',
47
- './cache',
48
- './temp'
49
- ],
50
-
51
- // Content validation
52
- maxStringLength: 10000,
53
- maxArrayLength: 100,
54
-
55
- // Rate limiting
56
- defaultRateLimit: {
57
- windowMs: 60 * 1000, // 1 minute
58
- max: 100 // requests per window
59
- }
60
- };
61
-
62
- /**
63
- * SSRF Protection Functions
64
- */
65
- export class SSRFProtection {
66
- static validateUrl(url) {
67
- try {
68
- const parsedUrl = new URL(url);
69
-
70
- // Check protocol
71
- if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
72
- throw new Error('Invalid protocol. Only HTTP and HTTPS are allowed.');
73
- }
74
-
75
- // Check for blocked domains
76
- const hostname = parsedUrl.hostname.toLowerCase();
77
- for (const blocked of SECURITY_CONFIG.blockedDomains) {
78
- if (blocked.includes('*')) {
79
- const pattern = blocked.replace('*', '.*');
80
- if (new RegExp(pattern).test(hostname)) {
81
- throw new Error(`Blocked domain: ${hostname}`);
82
- }
83
- } else if (hostname === blocked || hostname.endsWith(`.${blocked}`)) {
84
- throw new Error(`Blocked domain: ${hostname}`);
85
- }
86
- }
87
-
88
- // Check for allowed domains (if whitelist is used)
89
- if (SECURITY_CONFIG.allowedDomains.length > 0) {
90
- let isAllowed = false;
91
- for (const allowed of SECURITY_CONFIG.allowedDomains) {
92
- if (allowed.includes('*')) {
93
- const pattern = allowed.replace('*', '.*');
94
- if (new RegExp(pattern).test(hostname)) {
95
- isAllowed = true;
96
- break;
97
- }
98
- } else if (hostname === allowed || hostname.endsWith(`.${allowed}`)) {
99
- isAllowed = true;
100
- break;
101
- }
102
- }
103
- if (!isAllowed) {
104
- throw new Error(`Domain not in whitelist: ${hostname}`);
105
- }
106
- }
107
-
108
- // Check for IP addresses (basic check)
109
- const ipPattern = /^(\d{1,3}\.){3}\d{1,3}$/;
110
- if (ipPattern.test(hostname)) {
111
- throw new Error('Direct IP access not allowed');
112
- }
113
-
114
- return parsedUrl;
115
- } catch (error) {
116
- if (error instanceof TypeError) {
117
- throw new Error('Invalid URL format');
118
- }
119
- throw error;
120
- }
121
- }
122
-
123
- static async validateAndFetch(url, options = {}) {
124
- const validatedUrl = this.validateUrl(url);
125
-
126
- const fetchOptions = {
127
- timeout: options.timeout || 30000,
128
- headers: {
129
- ...identityHeaders({ role: 'health-check' }),
130
- ...options.headers
131
- },
132
- ...options
133
- };
134
-
135
- // Remove sensitive headers
136
- delete fetchOptions.headers['Authorization'];
137
- delete fetchOptions.headers['Cookie'];
138
-
139
- return fetch(validatedUrl.toString(), fetchOptions);
140
- }
141
- }
142
-
143
- /**
144
- * Path Traversal Protection
145
- */
146
- export class PathSecurity {
147
- static sanitizePath(userPath, baseDirectory) {
148
- // Normalize the path to resolve any .. or . components
149
- const normalizedPath = path.normalize(userPath);
150
-
151
- // Resolve the absolute path
152
- const absolutePath = path.resolve(baseDirectory, normalizedPath);
153
- const absoluteBase = path.resolve(baseDirectory);
154
-
155
- // Ensure the resolved path is within the base directory
156
- if (!absolutePath.startsWith(absoluteBase + path.sep) && absolutePath !== absoluteBase) {
157
- throw new Error('Path traversal detected');
158
- }
159
-
160
- return absolutePath;
161
- }
162
-
163
- static validateSnapshotId(snapshotId) {
164
- // Only allow alphanumeric characters and hyphens
165
- if (!/^[a-zA-Z0-9_-]{1,64}$/.test(snapshotId)) {
166
- throw new Error('Invalid snapshot ID format');
167
- }
168
-
169
- // Prevent path traversal attempts
170
- if (snapshotId.includes('..') || snapshotId.includes('/') || snapshotId.includes('\\')) {
171
- throw new Error('Invalid characters in snapshot ID');
172
- }
173
-
174
- return snapshotId;
175
- }
176
-
177
- static validateDirectory(directory) {
178
- const normalizedDir = path.normalize(directory);
179
-
180
- // Check against allowed directories
181
- for (const allowed of SECURITY_CONFIG.allowedDirectories) {
182
- const absoluteAllowed = path.resolve(allowed);
183
- const absoluteDir = path.resolve(normalizedDir);
184
-
185
- if (absoluteDir.startsWith(absoluteAllowed)) {
186
- return absoluteDir;
187
- }
188
- }
189
-
190
- throw new Error('Directory not allowed');
191
- }
192
- }
193
-
194
- /**
195
- * Input Validation and Sanitization
196
- */
197
- export class InputSecurity {
198
- static sanitizeHtml(html) {
199
- return DOMPurify.sanitize(html, {
200
- ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
201
- ALLOWED_ATTR: []
202
- });
203
- }
204
-
205
- static sanitizeString(str, maxLength = SECURITY_CONFIG.maxStringLength) {
206
- if (typeof str !== 'string') {
207
- throw new Error('Input must be a string');
208
- }
209
-
210
- if (str.length > maxLength) {
211
- throw new Error(`String too long. Maximum length is ${maxLength}`);
212
- }
213
-
214
- // Remove null bytes and control characters
215
- return str.replace(/[\x00-\x1F\x7F-\x9F]/g, '');
216
- }
217
-
218
- static sanitizeArray(arr, maxLength = SECURITY_CONFIG.maxArrayLength) {
219
- if (!Array.isArray(arr)) {
220
- throw new Error('Input must be an array');
221
- }
222
-
223
- if (arr.length > maxLength) {
224
- throw new Error(`Array too long. Maximum length is ${maxLength}`);
225
- }
226
-
227
- return arr;
228
- }
229
-
230
- static validateContentSize(content) {
231
- const size = Buffer.byteLength(content, 'utf8');
232
- if (size > SECURITY_CONFIG.maxContentSize) {
233
- throw new Error(`Content too large: ${size} bytes (max: ${SECURITY_CONFIG.maxContentSize})`);
234
- }
235
- return size;
236
- }
237
-
238
- static sanitizeJavaScript(jsCode) {
239
- // Remove potentially dangerous patterns
240
- const dangerousPatterns = [
241
- /eval\s*\(/gi,
242
- /Function\s*\(/gi,
243
- /setTimeout\s*\(/gi,
244
- /setInterval\s*\(/gi,
245
- /document\.write/gi,
246
- /innerHTML/gi,
247
- /outerHTML/gi,
248
- /execScript/gi,
249
- /script:/gi,
250
- /javascript:/gi,
251
- /data:/gi,
252
- /vbscript:/gi
253
- ];
254
-
255
- let sanitized = jsCode;
256
- for (const pattern of dangerousPatterns) {
257
- if (pattern.test(sanitized)) {
258
- throw new Error('Dangerous JavaScript pattern detected');
259
- }
260
- }
261
-
262
- return sanitized;
263
- }
264
- }
265
-
266
- /**
267
- * Cryptographic Security
268
- */
269
- export class CryptoSecurity {
270
- static generateSecureId(length = 32) {
271
- return randomBytes(length).toString('hex');
272
- }
273
-
274
- static generateSessionId() {
275
- return this.generateSecureId(16);
276
- }
277
-
278
- static hashContent(content, algorithm = 'sha256') {
279
- return createHash(algorithm).update(content).digest('hex');
280
- }
281
-
282
- static timingSafeCompare(a, b) {
283
- if (typeof a !== 'string' || typeof b !== 'string') {
284
- throw new Error('Both values must be strings');
285
- }
286
-
287
- if (a.length !== b.length) {
288
- return false;
289
- }
290
-
291
- const bufferA = Buffer.from(a);
292
- const bufferB = Buffer.from(b);
293
-
294
- return timingSafeEqual(bufferA, bufferB);
295
- }
296
-
297
- static generateWebhookSignature(payload, secret) {
298
- return createHash('sha256')
299
- .update(payload + secret)
300
- .digest('hex');
301
- }
302
-
303
- static validateWebhookSignature(payload, signature, secret) {
304
- const expectedSignature = this.generateWebhookSignature(payload, secret);
305
- return this.timingSafeCompare(signature, expectedSignature);
306
- }
307
- }
308
-
309
- /**
310
- * Resource Management Security
311
- */
312
- export class ResourceSecurity {
313
- static validateResearchLimits(options) {
314
- const maxUrls = Math.min(options.maxUrls || 10, SECURITY_CONFIG.maxResearchUrls);
315
- const timeLimit = Math.min(options.timeLimit || 60000, SECURITY_CONFIG.maxResearchTime);
316
- const maxDepth = Math.min(Math.max(options.maxDepth || 3, 1), 5);
317
-
318
- return {
319
- maxUrls,
320
- timeLimit,
321
- maxDepth,
322
- concurrency: Math.min(options.concurrency || 3, 5)
323
- };
324
- }
325
-
326
- static validateSnapshotLimits(options) {
327
- return {
328
- maxSnapshots: Math.min(options.maxSnapshots || 100, 1000),
329
- maxAge: Math.max(options.maxAge || 86400000, 3600000), // Min 1 hour
330
- maxStorageSize: Math.min(options.maxStorageSize || 1073741824, 10737418240), // Max 10GB
331
- compressionThreshold: Math.max(options.compressionThreshold || 1024, 1024)
332
- };
333
- }
334
- }
335
-
336
- /**
337
- * Browser Security
338
- */
339
- export class BrowserSecurity {
340
- static getSecureBrowserArgs() {
341
- return [
342
- // Security-focused args (remove dangerous ones from original)
343
- '--disable-blink-features=AutomationControlled',
344
- '--disable-dev-shm-usage',
345
- '--disable-extensions',
346
- '--disable-plugins',
347
- '--disable-images', // Reduce attack surface
348
- '--disable-javascript-harmony-shipping',
349
- '--disable-background-timer-throttling',
350
- '--disable-backgrounding-occluded-windows',
351
- '--disable-renderer-backgrounding',
352
- '--disable-field-trial-config',
353
- '--disable-back-forward-cache',
354
- '--disable-hang-monitor',
355
- '--disable-prompt-on-repost',
356
- '--disable-sync',
357
- '--disable-translate',
358
- '--metrics-recording-only',
359
- '--no-first-run',
360
- '--safebrowsing-disable-auto-update',
361
- '--password-store=basic',
362
- '--use-mock-keychain',
363
-
364
- // Security enhancements
365
- '--disable-file-system',
366
- '--disable-databases',
367
- '--disable-local-storage',
368
- '--disable-session-storage',
369
- '--disable-application-cache',
370
- '--disable-notifications',
371
- '--disable-geolocation',
372
- '--disable-microphone',
373
- '--disable-camera',
374
-
375
- // DO NOT include these dangerous args from original:
376
- // --no-sandbox (removes critical security boundary)
377
- // --disable-web-security (removes Same Origin Policy)
378
- // --disable-features=VizDisplayCompositor (removes security features)
379
- ];
380
- }
381
-
382
- static validateStealthConfig(config) {
383
- // Ensure stealth mode doesn't compromise security
384
- const secureConfig = {
385
- ...config,
386
- hideWebDriver: true, // This is fine
387
- blockWebRTC: true, // This is fine for privacy
388
- spoofTimezone: true, // This is fine for privacy
389
- randomizeHeaders: true, // This is fine for privacy
390
-
391
- // Remove dangerous options
392
- disableSecurity: false,
393
- allowUnsafeInlineScripts: false,
394
- bypassCSP: false
395
- };
396
-
397
- return secureConfig;
398
- }
399
-
400
- static sanitizeInjectedScript(script) {
401
- // Validate that injected scripts don't contain dangerous patterns
402
- const dangerousPatterns = [
403
- /eval\(/gi,
404
- /Function\(/gi,
405
- /setTimeout\(/gi,
406
- /setInterval\(/gi,
407
- /XMLHttpRequest/gi,
408
- /fetch\(/gi,
409
- /import\(/gi,
410
- /require\(/gi
411
- ];
412
-
413
- for (const pattern of dangerousPatterns) {
414
- if (pattern.test(script)) {
415
- throw new Error('Dangerous script pattern detected');
416
- }
417
- }
418
-
419
- return script;
420
- }
421
- }
422
-
423
- /**
424
- * Validation Schemas for Wave 3 Components
425
- */
426
- export const Wave3SecuritySchemas = {
427
- // Research Tool Security Schema
428
- researchRequest: z.object({
429
- topic: z.string().min(3).max(500).refine(val => !/<script/i.test(val), 'No script tags allowed'),
430
- maxDepth: z.number().min(1).max(5),
431
- maxUrls: z.number().min(1).max(50),
432
- timeLimit: z.number().min(30000).max(120000),
433
- concurrency: z.number().min(1).max(5)
434
- }),
435
-
436
- // Stealth Browser Security Schema
437
- stealthConfig: z.object({
438
- level: z.enum(['basic', 'medium']), // Remove 'advanced' - too dangerous
439
- randomizeFingerprint: z.boolean(),
440
- hideWebDriver: z.boolean(),
441
- blockWebRTC: z.boolean(),
442
- spoofTimezone: z.boolean(),
443
- customUserAgent: z.string().max(500).optional(),
444
- customViewport: z.object({
445
- width: z.number().min(800).max(1920),
446
- height: z.number().min(600).max(1080)
447
- }).optional()
448
- }),
449
-
450
- // Localization Security Schema
451
- localizationConfig: z.object({
452
- countryCode: z.string().length(2).regex(/^[A-Z]{2}$/),
453
- language: z.string().max(10).regex(/^[a-z]{2}-[A-Z]{2}$/),
454
- timezone: z.string().max(50).regex(/^[A-Za-z_/]+$/),
455
- currency: z.string().length(3).regex(/^[A-Z]{3}$/)
456
- }),
457
-
458
- // Snapshot Security Schema
459
- snapshotRequest: z.object({
460
- url: z.string().url().refine(url => {
461
- try {
462
- SSRFProtection.validateUrl(url);
463
- return true;
464
- } catch {
465
- return false;
466
- }
467
- }, 'Invalid or blocked URL'),
468
- content: z.string().max(SECURITY_CONFIG.maxContentSize),
469
- snapshotId: z.string().regex(/^[a-zA-Z0-9_-]{1,64}$/).optional()
470
- }),
471
-
472
- // Change Tracking Security Schema
473
- changeTrackingRequest: z.object({
474
- url: z.string().url(),
475
- granularity: z.enum(['page', 'section', 'element', 'text']),
476
- customSelectors: z.array(z.string().max(100)).max(10).optional(),
477
- excludeSelectors: z.array(z.string().max(100)).max(20).optional()
478
- })
479
- };
480
-
481
- /**
482
- * Security Middleware Factory
483
- */
484
- export class SecurityMiddleware {
485
- static rateLimiter(options = SECURITY_CONFIG.defaultRateLimit) {
486
- const requests = new Map();
487
-
488
- return (identifier) => {
489
- const now = Date.now();
490
- const windowStart = now - options.windowMs;
491
-
492
- // Clean old requests
493
- for (const [key, timestamps] of requests.entries()) {
494
- requests.set(key, timestamps.filter(time => time > windowStart));
495
- }
496
-
497
- // Check current requests
498
- const userRequests = requests.get(identifier) || [];
499
- if (userRequests.length >= options.max) {
500
- throw new Error('Rate limit exceeded');
501
- }
502
-
503
- // Add current request
504
- userRequests.push(now);
505
- requests.set(identifier, userRequests);
506
- };
507
- }
508
-
509
- static validateRequest(schema) {
510
- return (data) => {
511
- try {
512
- return schema.parse(data);
513
- } catch (error) {
514
- throw new Error(`Validation failed: ${error.message}`);
515
- }
516
- };
517
- }
518
-
519
- static auditLogger() {
520
- return (operation, details) => {
521
- const logEntry = {
522
- timestamp: new Date().toISOString(),
523
- operation,
524
- details: typeof details === 'object' ? JSON.stringify(details) : details,
525
- level: 'SECURITY'
526
- };
527
-
528
- console.log('[SECURITY AUDIT]', JSON.stringify(logEntry));
529
- };
530
- }
531
- }
532
-
533
- /**
534
- * Security Testing Utilities
535
- */
536
- export class SecurityTesting {
537
- static generateMaliciousPayloads() {
538
- return {
539
- ssrf: [
540
- 'http://localhost:3000/admin',
541
- 'http://127.0.0.1:22',
542
- 'http://169.254.169.254/latest/meta-data/',
543
- 'file:///etc/passwd',
544
- 'ftp://internal.server.com'
545
- ],
546
-
547
- pathTraversal: [
548
- '../../../etc/passwd',
549
- '..\\..\\..\\windows\\system32\\config\\sam',
550
- '/etc/shadow',
551
- 'C:\\Windows\\System32\\drivers\\etc\\hosts'
552
- ],
553
-
554
- xss: [
555
- '<script>alert("XSS")</script>',
556
- 'javascript:alert("XSS")',
557
- '<img src="x" onerror="alert(\'XSS\')">',
558
- '<svg onload="alert(\'XSS\')"></svg>'
559
- ],
560
-
561
- injection: [
562
- "'; DROP TABLE users; --",
563
- '${7*7}',
564
- '#{7*7}',
565
- '{{7*7}}',
566
- '<%= 7*7 %>'
567
- ]
568
- };
569
- }
570
-
571
- static testSecurityFunction(securityFunction, maliciousPayloads) {
572
- const results = [];
573
-
574
- for (const payload of maliciousPayloads) {
575
- try {
576
- securityFunction(payload);
577
- results.push({ payload, blocked: false, error: null });
578
- } catch (error) {
579
- results.push({ payload, blocked: true, error: error.message });
580
- }
581
- }
582
-
583
- return results;
584
- }
585
- }
586
-
587
- /**
588
- * Emergency Security Patches
589
- */
590
- export class EmergencyPatches {
591
- // Patch for ResearchOrchestrator SSRF vulnerability
592
- static patchResearchTool(researchTool) {
593
- const originalExecute = researchTool.execute;
594
-
595
- researchTool.execute = async function(params) {
596
- // Validate research limits
597
- const secureParams = ResourceSecurity.validateResearchLimits(params);
598
-
599
- // Validate topic for script injection
600
- secureParams.topic = InputSecurity.sanitizeString(params.topic, 500);
601
-
602
- return originalExecute.call(this, secureParams);
603
- };
604
- }
605
-
606
- // Patch for StealthBrowserManager script injection
607
- static patchBrowserManager(browserManager) {
608
- const originalCreateStealthContext = browserManager.createStealthContext;
609
-
610
- browserManager.createStealthContext = async function(config) {
611
- // Validate and secure the config
612
- const secureConfig = BrowserSecurity.validateStealthConfig(config);
613
-
614
- // Use secure browser args
615
- secureConfig.browserArgs = BrowserSecurity.getSecureBrowserArgs();
616
-
617
- return originalCreateStealthContext.call(this, secureConfig);
618
- };
619
- }
620
-
621
- // Patch for SnapshotManager path traversal
622
- static patchSnapshotManager(snapshotManager) {
623
- const originalStoreSnapshot = snapshotManager.storeSnapshot;
624
-
625
- snapshotManager.storeSnapshot = async function(url, content, metadata, options) {
626
- // Validate URL
627
- SSRFProtection.validateUrl(url);
628
-
629
- // Validate content size
630
- InputSecurity.validateContentSize(content);
631
-
632
- // Generate secure snapshot ID
633
- const snapshotId = CryptoSecurity.generateSecureId();
634
-
635
- return originalStoreSnapshot.call(this, url, content, metadata, { ...options, snapshotId });
636
- };
637
- }
638
- }
639
-
640
- // Export default security instance
641
- export default {
642
- SSRFProtection,
643
- PathSecurity,
644
- InputSecurity,
645
- CryptoSecurity,
646
- ResourceSecurity,
647
- BrowserSecurity,
648
- Wave3SecuritySchemas,
649
- SecurityMiddleware,
650
- SecurityTesting,
651
- EmergencyPatches,
652
- SECURITY_CONFIG
653
- };