suparank 1.2.4 → 1.2.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.
Files changed (2) hide show
  1. package/mcp-client.js +121 -9
  2. package/package.json +1 -1
package/mcp-client.js CHANGED
@@ -35,6 +35,17 @@ const projectSlug = process.argv[2]
35
35
  const apiKey = process.argv[3]
36
36
  const apiUrl = process.env.SUPARANK_API_URL || 'https://api.suparank.io'
37
37
 
38
+ // ============================================================================
39
+ // EXTERNAL API ENDPOINTS - Configurable via environment variables
40
+ // ============================================================================
41
+ const API_ENDPOINTS = {
42
+ // Image generation providers
43
+ fal: process.env.FAL_API_URL || 'https://fal.run/fal-ai/nano-banana-pro',
44
+ gemini: process.env.GEMINI_API_URL || 'https://generativelanguage.googleapis.com/v1beta/models',
45
+ wiro: process.env.WIRO_API_URL || 'https://api.wiro.ai/v1',
46
+ wiroTaskDetail: process.env.WIRO_TASK_URL || 'https://api.wiro.ai/v1/Task/Detail'
47
+ }
48
+
38
49
  if (!projectSlug) {
39
50
  console.error('Error: Project slug is required')
40
51
  console.error('Usage: node mcp-client.js <project-slug> <api-key>')
@@ -160,7 +171,9 @@ function loadStats() {
160
171
  if (fs.existsSync(file)) {
161
172
  return JSON.parse(fs.readFileSync(file, 'utf-8'))
162
173
  }
163
- } catch (e) {}
174
+ } catch (e) {
175
+ log(`Warning: Could not load stats: ${e.message}`)
176
+ }
164
177
  return { tool_calls: 0, images_generated: 0, articles_created: 0, words_written: 0 }
165
178
  }
166
179
 
@@ -196,6 +209,46 @@ function slugify(text) {
196
209
  .substring(0, 50)
197
210
  }
198
211
 
212
+ // ============================================================================
213
+ // PATH SANITIZATION - Prevents path traversal attacks
214
+ // ============================================================================
215
+
216
+ /**
217
+ * Sanitize and validate a path to prevent traversal attacks
218
+ * @param {string} userPath - User-provided path segment
219
+ * @param {string} allowedBase - Base directory that paths must stay within
220
+ * @returns {string} - Resolved safe path
221
+ * @throws {Error} - If path would escape the allowed base
222
+ */
223
+ function sanitizePath(userPath, allowedBase) {
224
+ // Remove any null bytes (common attack vector)
225
+ const cleanPath = userPath.replace(/\0/g, '')
226
+
227
+ // Resolve to absolute path
228
+ const resolved = path.resolve(allowedBase, cleanPath)
229
+
230
+ // Ensure the resolved path starts with the allowed base
231
+ // Adding path.sep ensures we don't match partial directory names
232
+ const normalizedBase = path.normalize(allowedBase + path.sep)
233
+ const normalizedResolved = path.normalize(resolved + path.sep)
234
+
235
+ if (!normalizedResolved.startsWith(normalizedBase)) {
236
+ throw new Error(`Path traversal detected: "${userPath}" would escape allowed directory`)
237
+ }
238
+
239
+ return resolved
240
+ }
241
+
242
+ /**
243
+ * Get content folder path safely
244
+ * @param {string} folderName - Folder name (article ID or title slug)
245
+ * @returns {string} - Safe path to content folder
246
+ */
247
+ function getContentFolderSafe(folderName) {
248
+ const contentDir = path.join(getSuparankDir(), 'content')
249
+ return sanitizePath(folderName, contentDir)
250
+ }
251
+
199
252
  /**
200
253
  * Atomic file write - prevents corruption on concurrent writes
201
254
  */
@@ -356,9 +409,56 @@ function loadSession() {
356
409
  return false
357
410
  }
358
411
 
412
+ // ============================================================================
413
+ // SESSION MUTEX - Prevents race conditions on concurrent session writes
414
+ // ============================================================================
415
+
416
+ let sessionLock = false
417
+ const sessionLockQueue = []
418
+
419
+ /**
420
+ * Acquire session lock for safe concurrent access
421
+ * @returns {Promise<void>}
422
+ */
423
+ async function acquireSessionLock() {
424
+ if (!sessionLock) {
425
+ sessionLock = true
426
+ return
427
+ }
428
+ return new Promise(resolve => {
429
+ sessionLockQueue.push(resolve)
430
+ })
431
+ }
432
+
433
+ /**
434
+ * Release session lock, allowing next queued operation
435
+ */
436
+ function releaseSessionLock() {
437
+ if (sessionLockQueue.length > 0) {
438
+ const next = sessionLockQueue.shift()
439
+ next()
440
+ } else {
441
+ sessionLock = false
442
+ }
443
+ }
444
+
445
+ /**
446
+ * Safe session save with mutex - use this for concurrent operations
447
+ * @returns {Promise<void>}
448
+ */
449
+ async function saveSessionSafe() {
450
+ await acquireSessionLock()
451
+ try {
452
+ saveSession()
453
+ } finally {
454
+ releaseSessionLock()
455
+ }
456
+ }
457
+
359
458
  /**
360
459
  * Save session state to file (persists across MCP restarts)
361
460
  * Uses atomic write to prevent corruption
461
+ * NOTE: For concurrent operations, use saveSessionSafe() instead
362
462
  */
363
463
  function saveSession() {
364
464
  try {
@@ -457,11 +557,13 @@ function saveContentToFolder() {
457
557
  try {
458
558
  ensureContentDir()
459
559
 
460
- // Create folder name: YYYY-MM-DD-slug
560
+ // Create folder name: YYYY-MM-DD-slug (slugify removes dangerous characters)
461
561
  const date = new Date().toISOString().split('T')[0]
462
562
  const slug = slugify(sessionState.title)
463
563
  const folderName = `${date}-${slug}`
464
- const folderPath = path.join(getContentDir(), folderName)
564
+
565
+ // Use safe path function to prevent any path traversal
566
+ const folderPath = getContentFolderSafe(folderName)
465
567
 
466
568
  // Create folder if doesn't exist
467
569
  if (!fs.existsSync(folderPath)) {
@@ -2558,8 +2660,18 @@ Once loaded, you can run optimization tools:
2558
2660
  }
2559
2661
  }
2560
2662
 
2561
- const contentDir = getContentDir()
2562
- const folderPath = path.join(contentDir, folder_name)
2663
+ // Sanitize folder_name to prevent path traversal attacks
2664
+ let folderPath
2665
+ try {
2666
+ folderPath = getContentFolderSafe(folder_name)
2667
+ } catch (error) {
2668
+ return {
2669
+ content: [{
2670
+ type: 'text',
2671
+ text: `❌ Invalid folder name: ${error.message}`
2672
+ }]
2673
+ }
2674
+ }
2563
2675
 
2564
2676
  if (!fs.existsSync(folderPath)) {
2565
2677
  return {
@@ -2705,7 +2817,7 @@ async function executeImageGeneration(args) {
2705
2817
  switch (provider) {
2706
2818
  case 'fal': {
2707
2819
  // fal.ai Nano Banana Pro (gemini-3-pro-image)
2708
- const response = await fetchWithRetry('https://fal.run/fal-ai/nano-banana-pro', {
2820
+ const response = await fetchWithRetry(API_ENDPOINTS.fal, {
2709
2821
  method: 'POST',
2710
2822
  headers: {
2711
2823
  'Authorization': `Key ${config.api_key}`,
@@ -2766,7 +2878,7 @@ ${imageNumber < totalImages ? `\n**Next:** Generate ${totalImages - imageNumber}
2766
2878
  // Google Gemini 3 Pro Image (Nano Banana Pro) - generateContent API
2767
2879
  const model = config.model || 'gemini-3-pro-image-preview'
2768
2880
  const response = await fetch(
2769
- `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`,
2881
+ `${API_ENDPOINTS.gemini}/${model}:generateContent`,
2770
2882
  {
2771
2883
  method: 'POST',
2772
2884
  headers: {
@@ -2837,7 +2949,7 @@ ${imageNumber < totalImages ? `\n**Next:** Generate ${totalImages - imageNumber}
2837
2949
 
2838
2950
  // Submit task
2839
2951
  log(`Submitting wiro.ai task for model: ${model}`)
2840
- const submitResponse = await fetch(`https://api.wiro.ai/v1/Run/${model}`, {
2952
+ const submitResponse = await fetch(`${API_ENDPOINTS.wiro}/Run/${model}`, {
2841
2953
  method: 'POST',
2842
2954
  headers: {
2843
2955
  'Content-Type': 'application/json',
@@ -2880,7 +2992,7 @@ ${imageNumber < totalImages ? `\n**Next:** Generate ${totalImages - imageNumber}
2880
2992
  .update(pollSignatureData)
2881
2993
  .digest('hex')
2882
2994
 
2883
- const pollResponse = await fetch('https://api.wiro.ai/v1/Task/Detail', {
2995
+ const pollResponse = await fetch(API_ENDPOINTS.wiroTaskDetail, {
2884
2996
  method: 'POST',
2885
2997
  headers: {
2886
2998
  'Content-Type': 'application/json',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "suparank",
3
- "version": "1.2.4",
3
+ "version": "1.2.5",
4
4
  "description": "AI-powered SEO content creation MCP - generate and publish optimized blog posts with Claude, ChatGPT, or Cursor",
5
5
  "main": "mcp-client.js",
6
6
  "type": "module",