crawlforge-mcp-server 5.5.5 → 5.5.7

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.5",
3
+ "version": "5.5.7",
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.2",
117
+ "crawlforge-extractors": "^1.5.3",
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.5",
108
+ version: "5.5.7",
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",
package/setup.js CHANGED
@@ -68,8 +68,11 @@ function addToMcpConfig(configPath, clientName, apiKey) {
68
68
  }
69
69
  };
70
70
 
71
- // Write updated config
72
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
71
+ // Write updated config. This file now embeds the API key, so keep it
72
+ // owner-only (0o600). The `mode` option only applies when writeFileSync
73
+ // creates the file, so chmod enforces it for a pre-existing config too.
74
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), { mode: 0o600 });
75
+ fs.chmodSync(configPath, 0o600);
73
76
 
74
77
  return {
75
78
  success: true,
@@ -107,7 +110,7 @@ function configureMcpClients(apiKey) {
107
110
  if (!fs.existsSync(cursorConfigPath)) {
108
111
  // Create mcp.json if it doesn't exist
109
112
  try {
110
- fs.writeFileSync(cursorConfigPath, JSON.stringify({ mcpServers: {} }, null, 2));
113
+ fs.writeFileSync(cursorConfigPath, JSON.stringify({ mcpServers: {} }, null, 2), { mode: 0o600 });
111
114
  } catch (e) {
112
115
  // Ignore creation errors
113
116
  }
@@ -10,8 +10,21 @@
10
10
  import crypto from 'crypto';
11
11
  import dotenv from 'dotenv';
12
12
 
13
- // Load .env file early to check for creator secret
14
- dotenv.config({ path: '.env', quiet: true });
13
+ // Load the cwd .env early to check for the creator secret, but into an isolated
14
+ // object rather than straight into process.env. A .env sitting in whatever
15
+ // directory the server is launched from is untrusted; seeding process.env from
16
+ // it directly would let it silently flip the SSRF controls (this module is
17
+ // imported before config.js runs its package-relative .env load). We copy the
18
+ // benign keys through — preserving the historical "cwd .env is read" behavior,
19
+ // including the creator secret — but never the SSRF/allowlist controls, which
20
+ // must come from the real environment.
21
+ const CWD_ENV_DENYLIST = new Set(['SSRF_PROTECTION_ENABLED', 'SSRF_STRICT', 'ALLOWED_DOMAINS']);
22
+ const cwdEnv = {};
23
+ dotenv.config({ path: '.env', quiet: true, processEnv: cwdEnv });
24
+ for (const [key, value] of Object.entries(cwdEnv)) {
25
+ if (CWD_ENV_DENYLIST.has(key)) continue;
26
+ if (process.env[key] === undefined) process.env[key] = value; // don't override the real env
27
+ }
15
28
 
16
29
  // SECURITY: Clear any externally-set creator mode env var to prevent bypass
17
30
  delete process.env.CRAWLFORGE_CREATOR_MODE;
@@ -151,7 +151,14 @@ function sendRpcError(res, status, code, message) {
151
151
  */
152
152
  export async function connectStreamableHttp(server, authManager, logger, options = {}) {
153
153
  const port = options.port ?? 3000;
154
- const host = options.host ?? '0.0.0.0';
154
+ // Bind loopback by default so a local `--http` server is not exposed to the
155
+ // LAN. Managed hosts need 0.0.0.0 to be reachable by their router: Render sets
156
+ // $RENDER; $MCP_HTTP_HOST is an explicit override for any other platform.
157
+ const host = options.host ?? process.env.MCP_HTTP_HOST ?? (process.env.RENDER ? '0.0.0.0' : '127.0.0.1');
158
+ const hostIsLoopback = host === '127.0.0.1' || host === '::1' || host === 'localhost';
159
+ if (authManager.isCreatorMode() && !hostIsLoopback) {
160
+ console.error(`WARNING: creator mode is enabled but the server is bound to ${host} (non-loopback) — per-request auth will NOT be bypassed. Bind to 127.0.0.1 to use creator mode.`);
161
+ }
155
162
  const legacy = options.legacy === true;
156
163
  const oauthProvider = options.oauth ?? null;
157
164
  const metrics = options.metrics ?? null;
@@ -242,12 +249,14 @@ export async function connectStreamableHttp(server, authManager, logger, options
242
249
 
243
250
  // MCP endpoint
244
251
  if (req.url === '/mcp' || req.url === '/' || req.url?.startsWith('/mcp?')) {
245
- // Per-request auth (bypassed in creator mode). `internal` marks a
246
- // request from the website's REST proxy (INTERNAL_PROXY_SECRET): it is
247
- // billing-exempt in withAuth because the website already charged the
248
- // end user. Request-scoped only — never persisted on the session.
252
+ // Per-request auth. Creator mode bypasses it, but only on a loopback bind
253
+ // — never expose an unauthenticated MCP endpoint on a public interface.
254
+ // `internal` marks a request from the website's REST proxy
255
+ // (INTERNAL_PROXY_SECRET): it is billing-exempt in withAuth because the
256
+ // website already charged the end user. Request-scoped only — never
257
+ // persisted on the session.
249
258
  let internal = false;
250
- if (!authManager.isCreatorMode()) {
259
+ if (!(authManager.isCreatorMode() && hostIsLoopback)) {
251
260
  const authResult = await authenticateRequest(req, authManager, oauthProvider);
252
261
  if (!authResult.ok) {
253
262
  logger.warn('Streamable HTTP request rejected', {
@@ -14,6 +14,7 @@ import { htmlToMarkdown } from '../../utils/htmlToMarkdown.js'; // D3.1
14
14
  import { safeFetch } from '../../utils/ssrfGuard.js';
15
15
  import { preflightFetch } from '../../utils/robotsGate.js';
16
16
  import { noteRetryAfter } from '../../utils/hostRateLimiter.js';
17
+ import { isRemoteTransport } from '../../utils/remoteMode.js';
17
18
 
18
19
  const ProcessDocumentSchema = z.object({
19
20
  source: z.string().min(1),
@@ -148,6 +149,18 @@ export class ProcessDocumentTool {
148
149
  const validated = ProcessDocumentSchema.parse(params);
149
150
  const { source, sourceType, options, respect_robots, user_agent } = validated;
150
151
 
152
+ // `file` and `pdf_file` read a path straight off this machine's disk,
153
+ // which is the point of the tool when you are running it yourself. Served
154
+ // to a network, it is arbitrary file read on the host, so refuse it there
155
+ // rather than trusting whoever holds the key. Local use is unaffected.
156
+ if ((sourceType === 'file' || sourceType === 'pdf_file') && isRemoteTransport()) {
157
+ throw new Error(
158
+ `sourceType '${sourceType}' reads a local file and is only available when this server ` +
159
+ `runs on your own machine (stdio, or HTTP bound to loopback). Pass a URL with ` +
160
+ `sourceType 'url' or 'pdf_url' instead.`
161
+ );
162
+ }
163
+
151
164
  const result = {
152
165
  source,
153
166
  sourceType,
@@ -5,6 +5,10 @@
5
5
  */
6
6
 
7
7
  import { identityHeaders } from '../../../utils/fetchIdentity.js';
8
+ // Webhook and Slack targets are caller-supplied URLs, so they go out through the
9
+ // same guard as every other outbound request. WebhookDispatcher already did this;
10
+ // these two senders were the only ones left calling bare fetch.
11
+ import { safeFetch } from '../../../utils/ssrfGuard.js';
8
12
 
9
13
  /**
10
14
  * Send all enabled notifications for a detected change.
@@ -41,7 +45,7 @@ export async function sendWebhookNotification(url, changeResult, webhookConfig,
41
45
  details: webhookConfig.includeContent ? changeResult.details : undefined
42
46
  };
43
47
 
44
- const response = await fetch(webhookConfig.url, {
48
+ const response = await safeFetch(webhookConfig.url, {
45
49
  method: webhookConfig.method || 'POST',
46
50
  headers: {
47
51
  'Content-Type': 'application/json',
@@ -90,7 +94,7 @@ export async function sendSlackNotification(url, changeResult, slackConfig, emit
90
94
  username: slackConfig.username || 'Change Tracker'
91
95
  };
92
96
 
93
- const response = await fetch(slackConfig.webhookUrl, {
97
+ const response = await safeFetch(slackConfig.webhookUrl, {
94
98
  method: 'POST',
95
99
  headers: { 'Content-Type': 'application/json' },
96
100
  body: JSON.stringify(payload)
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Is this process serving a network that is not just the machine it runs on?
3
+ *
4
+ * The distinction matters for tools that touch the local filesystem. Over
5
+ * stdio, and over HTTP bound to loopback, the caller *is* the local user:
6
+ * reading a file they name is the feature, and no privilege boundary is
7
+ * crossed. Bound to a public interface it is a different act entirely — the
8
+ * caller is remote and the filesystem is the server's, so the same request
9
+ * reads files that were never theirs to read.
10
+ *
11
+ * The two expressions below are deliberately identical to the places that
12
+ * actually decide the transport and the bind address:
13
+ * - `server.js` (`useHttp`), and
14
+ * - `src/server/transports/streamableHttp.js` (`host` / `hostIsLoopback`).
15
+ * Keep them in step. Erring toward "remote" is the safe direction: it refuses
16
+ * a local-file read that would have been allowed, which is a visible,
17
+ * reversible inconvenience rather than a silent disclosure.
18
+ */
19
+ export function isRemoteTransport() {
20
+ const useHttp = process.argv.includes('--http') || process.env.MCP_HTTP === 'true';
21
+ if (!useHttp) return false; // stdio — the caller is the local user
22
+
23
+ const host = process.env.MCP_HTTP_HOST ?? (process.env.RENDER ? '0.0.0.0' : '127.0.0.1');
24
+ return !(host === '127.0.0.1' || host === '::1' || host === 'localhost');
25
+ }