@jgardner04/ghost-mcp-server 1.13.0 → 1.13.2
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 +3 -7
- package/src/__tests__/mcp_server.test.js +258 -0
- package/src/__tests__/mcp_server_pages.test.js +21 -6
- package/src/controllers/__tests__/tagController.test.js +118 -3
- package/src/controllers/tagController.js +32 -6
- package/src/mcp_server.js +225 -112
- package/src/resources/ResourceManager.js +6 -2
- package/src/resources/__tests__/ResourceManager.test.js +2 -2
- package/src/schemas/__tests__/tagSchemas.test.js +100 -0
- package/src/schemas/pageSchemas.js +0 -2
- package/src/schemas/postSchemas.js +6 -4
- package/src/schemas/tagSchemas.js +33 -5
- package/src/services/__tests__/ghostService.test.js +30 -23
- package/src/services/__tests__/ghostServiceImproved.members.test.js +9 -5
- package/src/services/__tests__/ghostServiceImproved.newsletters.test.js +16 -11
- package/src/services/__tests__/ghostServiceImproved.pages.test.js +56 -13
- package/src/services/__tests__/ghostServiceImproved.posts.test.js +233 -0
- package/src/services/__tests__/ghostServiceImproved.tags.test.js +486 -0
- package/src/services/__tests__/ghostServiceImproved.tiers.test.js +12 -8
- package/src/services/ghostService.js +21 -18
- package/src/services/ghostServiceImproved.js +77 -194
- package/src/utils/__tests__/urlValidator.test.js +137 -1
- package/src/utils/urlValidator.js +25 -2
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import GhostAdminAPI from '@tryghost/admin-api';
|
|
2
|
-
import sanitizeHtml from 'sanitize-html';
|
|
3
2
|
import dotenv from 'dotenv';
|
|
4
3
|
import { promises as fs } from 'fs';
|
|
5
4
|
import {
|
|
@@ -11,9 +10,12 @@ import {
|
|
|
11
10
|
CircuitBreaker,
|
|
12
11
|
retryWithBackoff,
|
|
13
12
|
} from '../errors/index.js';
|
|
13
|
+
import { createContextLogger } from '../utils/logger.js';
|
|
14
14
|
|
|
15
15
|
dotenv.config();
|
|
16
16
|
|
|
17
|
+
const logger = createContextLogger('ghost-service-improved');
|
|
18
|
+
|
|
17
19
|
const { GHOST_ADMIN_API_URL, GHOST_ADMIN_API_KEY } = process.env;
|
|
18
20
|
|
|
19
21
|
// Validate configuration at startup
|
|
@@ -115,6 +117,30 @@ const handleApiRequest = async (resource, action, data = {}, options = {}, confi
|
|
|
115
117
|
* Input validation helpers
|
|
116
118
|
*/
|
|
117
119
|
const validators = {
|
|
120
|
+
validateScheduledStatus(data, resourceLabel = 'Resource') {
|
|
121
|
+
const errors = [];
|
|
122
|
+
|
|
123
|
+
if (data.status === 'scheduled' && !data.published_at) {
|
|
124
|
+
errors.push({
|
|
125
|
+
field: 'published_at',
|
|
126
|
+
message: 'published_at is required when status is scheduled',
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (data.published_at) {
|
|
131
|
+
const publishDate = new Date(data.published_at);
|
|
132
|
+
if (isNaN(publishDate.getTime())) {
|
|
133
|
+
errors.push({ field: 'published_at', message: 'Invalid date format' });
|
|
134
|
+
} else if (data.status === 'scheduled' && publishDate <= new Date()) {
|
|
135
|
+
errors.push({ field: 'published_at', message: 'Scheduled date must be in the future' });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (errors.length > 0) {
|
|
140
|
+
throw new ValidationError(`${resourceLabel} validation failed`, errors);
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
|
|
118
144
|
validatePostData(postData) {
|
|
119
145
|
const errors = [];
|
|
120
146
|
|
|
@@ -133,25 +159,11 @@ const validators = {
|
|
|
133
159
|
});
|
|
134
160
|
}
|
|
135
161
|
|
|
136
|
-
if (postData.status === 'scheduled' && !postData.published_at) {
|
|
137
|
-
errors.push({
|
|
138
|
-
field: 'published_at',
|
|
139
|
-
message: 'published_at is required when status is scheduled',
|
|
140
|
-
});
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
if (postData.published_at) {
|
|
144
|
-
const publishDate = new Date(postData.published_at);
|
|
145
|
-
if (isNaN(publishDate.getTime())) {
|
|
146
|
-
errors.push({ field: 'published_at', message: 'Invalid date format' });
|
|
147
|
-
} else if (postData.status === 'scheduled' && publishDate <= new Date()) {
|
|
148
|
-
errors.push({ field: 'published_at', message: 'Scheduled date must be in the future' });
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
162
|
if (errors.length > 0) {
|
|
153
163
|
throw new ValidationError('Post validation failed', errors);
|
|
154
164
|
}
|
|
165
|
+
|
|
166
|
+
this.validateScheduledStatus(postData, 'Post');
|
|
155
167
|
},
|
|
156
168
|
|
|
157
169
|
validateTagData(tagData) {
|
|
@@ -225,25 +237,11 @@ const validators = {
|
|
|
225
237
|
});
|
|
226
238
|
}
|
|
227
239
|
|
|
228
|
-
if (pageData.status === 'scheduled' && !pageData.published_at) {
|
|
229
|
-
errors.push({
|
|
230
|
-
field: 'published_at',
|
|
231
|
-
message: 'published_at is required when status is scheduled',
|
|
232
|
-
});
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
if (pageData.published_at) {
|
|
236
|
-
const publishDate = new Date(pageData.published_at);
|
|
237
|
-
if (isNaN(publishDate.getTime())) {
|
|
238
|
-
errors.push({ field: 'published_at', message: 'Invalid date format' });
|
|
239
|
-
} else if (pageData.status === 'scheduled' && publishDate <= new Date()) {
|
|
240
|
-
errors.push({ field: 'published_at', message: 'Scheduled date must be in the future' });
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
240
|
if (errors.length > 0) {
|
|
245
241
|
throw new ValidationError('Page validation failed', errors);
|
|
246
242
|
}
|
|
243
|
+
|
|
244
|
+
this.validateScheduledStatus(pageData, 'Page');
|
|
247
245
|
},
|
|
248
246
|
|
|
249
247
|
validateNewsletterData(newsletterData) {
|
|
@@ -282,48 +280,7 @@ export async function createPost(postData, options = { source: 'html' }) {
|
|
|
282
280
|
...postData,
|
|
283
281
|
};
|
|
284
282
|
|
|
285
|
-
//
|
|
286
|
-
if (dataWithDefaults.html) {
|
|
287
|
-
// Use proper HTML sanitization library to prevent XSS
|
|
288
|
-
dataWithDefaults.html = sanitizeHtml(dataWithDefaults.html, {
|
|
289
|
-
allowedTags: [
|
|
290
|
-
'h1',
|
|
291
|
-
'h2',
|
|
292
|
-
'h3',
|
|
293
|
-
'h4',
|
|
294
|
-
'h5',
|
|
295
|
-
'h6',
|
|
296
|
-
'blockquote',
|
|
297
|
-
'p',
|
|
298
|
-
'a',
|
|
299
|
-
'ul',
|
|
300
|
-
'ol',
|
|
301
|
-
'nl',
|
|
302
|
-
'li',
|
|
303
|
-
'b',
|
|
304
|
-
'i',
|
|
305
|
-
'strong',
|
|
306
|
-
'em',
|
|
307
|
-
'strike',
|
|
308
|
-
'code',
|
|
309
|
-
'hr',
|
|
310
|
-
'br',
|
|
311
|
-
'div',
|
|
312
|
-
'span',
|
|
313
|
-
'img',
|
|
314
|
-
'pre',
|
|
315
|
-
],
|
|
316
|
-
allowedAttributes: {
|
|
317
|
-
a: ['href', 'title'],
|
|
318
|
-
img: ['src', 'alt', 'title', 'width', 'height'],
|
|
319
|
-
'*': ['class', 'id'],
|
|
320
|
-
},
|
|
321
|
-
allowedSchemes: ['http', 'https', 'mailto'],
|
|
322
|
-
allowedSchemesByTag: {
|
|
323
|
-
img: ['http', 'https', 'data'],
|
|
324
|
-
},
|
|
325
|
-
});
|
|
326
|
-
}
|
|
283
|
+
// SECURITY: HTML must be sanitized before reaching this function. See htmlContentSchema in schemas/common.js
|
|
327
284
|
|
|
328
285
|
try {
|
|
329
286
|
return await handleApiRequest('posts', 'add', dataWithDefaults, options);
|
|
@@ -343,18 +300,22 @@ export async function updatePost(postId, updateData, options = {}) {
|
|
|
343
300
|
throw new ValidationError('Post ID is required for update');
|
|
344
301
|
}
|
|
345
302
|
|
|
303
|
+
// Validate scheduled status if status is being updated
|
|
304
|
+
if (updateData.status) {
|
|
305
|
+
validators.validateScheduledStatus(updateData, 'Post');
|
|
306
|
+
}
|
|
307
|
+
|
|
346
308
|
// Get the current post first to ensure it exists
|
|
347
309
|
try {
|
|
348
310
|
const existingPost = await handleApiRequest('posts', 'read', { id: postId });
|
|
349
311
|
|
|
350
|
-
//
|
|
351
|
-
const
|
|
352
|
-
...existingPost,
|
|
312
|
+
// Send only changed fields + updated_at for OCC (optimistic concurrency control)
|
|
313
|
+
const editData = {
|
|
353
314
|
...updateData,
|
|
354
|
-
updated_at: existingPost.updated_at,
|
|
315
|
+
updated_at: existingPost.updated_at,
|
|
355
316
|
};
|
|
356
317
|
|
|
357
|
-
return await handleApiRequest('posts', 'edit',
|
|
318
|
+
return await handleApiRequest('posts', 'edit', editData, { id: postId, ...options });
|
|
358
319
|
} catch (error) {
|
|
359
320
|
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
360
321
|
throw new NotFoundError('Post', postId);
|
|
@@ -454,47 +415,7 @@ export async function createPage(pageData, options = { source: 'html' }) {
|
|
|
454
415
|
...pageData,
|
|
455
416
|
};
|
|
456
417
|
|
|
457
|
-
//
|
|
458
|
-
if (dataWithDefaults.html) {
|
|
459
|
-
dataWithDefaults.html = sanitizeHtml(dataWithDefaults.html, {
|
|
460
|
-
allowedTags: [
|
|
461
|
-
'h1',
|
|
462
|
-
'h2',
|
|
463
|
-
'h3',
|
|
464
|
-
'h4',
|
|
465
|
-
'h5',
|
|
466
|
-
'h6',
|
|
467
|
-
'blockquote',
|
|
468
|
-
'p',
|
|
469
|
-
'a',
|
|
470
|
-
'ul',
|
|
471
|
-
'ol',
|
|
472
|
-
'nl',
|
|
473
|
-
'li',
|
|
474
|
-
'b',
|
|
475
|
-
'i',
|
|
476
|
-
'strong',
|
|
477
|
-
'em',
|
|
478
|
-
'strike',
|
|
479
|
-
'code',
|
|
480
|
-
'hr',
|
|
481
|
-
'br',
|
|
482
|
-
'div',
|
|
483
|
-
'span',
|
|
484
|
-
'img',
|
|
485
|
-
'pre',
|
|
486
|
-
],
|
|
487
|
-
allowedAttributes: {
|
|
488
|
-
a: ['href', 'title'],
|
|
489
|
-
img: ['src', 'alt', 'title', 'width', 'height'],
|
|
490
|
-
'*': ['class', 'id'],
|
|
491
|
-
},
|
|
492
|
-
allowedSchemes: ['http', 'https', 'mailto'],
|
|
493
|
-
allowedSchemesByTag: {
|
|
494
|
-
img: ['http', 'https', 'data'],
|
|
495
|
-
},
|
|
496
|
-
});
|
|
497
|
-
}
|
|
418
|
+
// SECURITY: HTML must be sanitized before reaching this function. See htmlContentSchema in schemas/common.js
|
|
498
419
|
|
|
499
420
|
try {
|
|
500
421
|
return await handleApiRequest('pages', 'add', dataWithDefaults, options);
|
|
@@ -513,60 +434,24 @@ export async function updatePage(pageId, updateData, options = {}) {
|
|
|
513
434
|
throw new ValidationError('Page ID is required for update');
|
|
514
435
|
}
|
|
515
436
|
|
|
516
|
-
//
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
'h2',
|
|
522
|
-
'h3',
|
|
523
|
-
'h4',
|
|
524
|
-
'h5',
|
|
525
|
-
'h6',
|
|
526
|
-
'blockquote',
|
|
527
|
-
'p',
|
|
528
|
-
'a',
|
|
529
|
-
'ul',
|
|
530
|
-
'ol',
|
|
531
|
-
'nl',
|
|
532
|
-
'li',
|
|
533
|
-
'b',
|
|
534
|
-
'i',
|
|
535
|
-
'strong',
|
|
536
|
-
'em',
|
|
537
|
-
'strike',
|
|
538
|
-
'code',
|
|
539
|
-
'hr',
|
|
540
|
-
'br',
|
|
541
|
-
'div',
|
|
542
|
-
'span',
|
|
543
|
-
'img',
|
|
544
|
-
'pre',
|
|
545
|
-
],
|
|
546
|
-
allowedAttributes: {
|
|
547
|
-
a: ['href', 'title'],
|
|
548
|
-
img: ['src', 'alt', 'title', 'width', 'height'],
|
|
549
|
-
'*': ['class', 'id'],
|
|
550
|
-
},
|
|
551
|
-
allowedSchemes: ['http', 'https', 'mailto'],
|
|
552
|
-
allowedSchemesByTag: {
|
|
553
|
-
img: ['http', 'https', 'data'],
|
|
554
|
-
},
|
|
555
|
-
});
|
|
437
|
+
// SECURITY: HTML must be sanitized before reaching this function. See htmlContentSchema in schemas/common.js
|
|
438
|
+
|
|
439
|
+
// Validate scheduled status if status is being updated
|
|
440
|
+
if (updateData.status) {
|
|
441
|
+
validators.validateScheduledStatus(updateData, 'Page');
|
|
556
442
|
}
|
|
557
443
|
|
|
558
444
|
try {
|
|
559
445
|
// Get existing page to retrieve updated_at for conflict resolution
|
|
560
446
|
const existingPage = await handleApiRequest('pages', 'read', { id: pageId });
|
|
561
447
|
|
|
562
|
-
//
|
|
563
|
-
const
|
|
564
|
-
...existingPage,
|
|
448
|
+
// Send only changed fields + updated_at for OCC (optimistic concurrency control)
|
|
449
|
+
const editData = {
|
|
565
450
|
...updateData,
|
|
566
451
|
updated_at: existingPage.updated_at,
|
|
567
452
|
};
|
|
568
453
|
|
|
569
|
-
return await handleApiRequest('pages', 'edit',
|
|
454
|
+
return await handleApiRequest('pages', 'edit', editData, { id: pageId, ...options });
|
|
570
455
|
} catch (error) {
|
|
571
456
|
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
572
457
|
throw new NotFoundError('Page', pageId);
|
|
@@ -685,8 +570,8 @@ export async function createTag(tagData) {
|
|
|
685
570
|
if (error instanceof GhostAPIError && error.ghostStatusCode === 422) {
|
|
686
571
|
// Check if it's a duplicate tag error
|
|
687
572
|
if (error.originalError.includes('already exists')) {
|
|
688
|
-
// Try to fetch the existing tag
|
|
689
|
-
const existingTags = await getTags(tagData.name);
|
|
573
|
+
// Try to fetch the existing tag by name filter
|
|
574
|
+
const existingTags = await getTags({ filter: `name:'${tagData.name}'` });
|
|
690
575
|
if (existingTags.length > 0) {
|
|
691
576
|
return existingTags[0]; // Return existing tag instead of failing
|
|
692
577
|
}
|
|
@@ -699,17 +584,20 @@ export async function createTag(tagData) {
|
|
|
699
584
|
}
|
|
700
585
|
}
|
|
701
586
|
|
|
702
|
-
export async function getTags(
|
|
703
|
-
const options = {
|
|
704
|
-
limit: 'all',
|
|
705
|
-
...(name && { filter: `name:'${name}'` }),
|
|
706
|
-
};
|
|
707
|
-
|
|
587
|
+
export async function getTags(options = {}) {
|
|
708
588
|
try {
|
|
709
|
-
const tags = await handleApiRequest(
|
|
589
|
+
const tags = await handleApiRequest(
|
|
590
|
+
'tags',
|
|
591
|
+
'browse',
|
|
592
|
+
{},
|
|
593
|
+
{
|
|
594
|
+
limit: 15,
|
|
595
|
+
...options,
|
|
596
|
+
}
|
|
597
|
+
);
|
|
710
598
|
return tags || [];
|
|
711
599
|
} catch (error) {
|
|
712
|
-
|
|
600
|
+
logger.error('Failed to get tags', { error: error.message });
|
|
713
601
|
throw error;
|
|
714
602
|
}
|
|
715
603
|
}
|
|
@@ -737,13 +625,11 @@ export async function updateTag(tagId, updateData) {
|
|
|
737
625
|
validators.validateTagUpdateData(updateData); // Validate update data
|
|
738
626
|
|
|
739
627
|
try {
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
...existingTag,
|
|
743
|
-
...updateData,
|
|
744
|
-
};
|
|
628
|
+
// Verify tag exists before updating
|
|
629
|
+
await getTag(tagId);
|
|
745
630
|
|
|
746
|
-
|
|
631
|
+
// Send only changed fields (tags don't use updated_at for OCC)
|
|
632
|
+
return await handleApiRequest('tags', 'edit', { ...updateData }, { id: tagId });
|
|
747
633
|
} catch (error) {
|
|
748
634
|
if (error instanceof NotFoundError) {
|
|
749
635
|
throw error;
|
|
@@ -831,14 +717,13 @@ export async function updateMember(memberId, updateData, options = {}) {
|
|
|
831
717
|
// Get existing member to retrieve updated_at for conflict resolution
|
|
832
718
|
const existingMember = await handleApiRequest('members', 'read', { id: memberId });
|
|
833
719
|
|
|
834
|
-
//
|
|
835
|
-
const
|
|
836
|
-
...existingMember,
|
|
720
|
+
// Send only changed fields + updated_at for OCC (optimistic concurrency control)
|
|
721
|
+
const editData = {
|
|
837
722
|
...updateData,
|
|
838
723
|
updated_at: existingMember.updated_at,
|
|
839
724
|
};
|
|
840
725
|
|
|
841
|
-
return await handleApiRequest('members', 'edit',
|
|
726
|
+
return await handleApiRequest('members', 'edit', editData, { id: memberId, ...options });
|
|
842
727
|
} catch (error) {
|
|
843
728
|
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
844
729
|
throw new NotFoundError('Member', memberId);
|
|
@@ -1031,14 +916,13 @@ export async function updateNewsletter(newsletterId, updateData) {
|
|
|
1031
916
|
id: newsletterId,
|
|
1032
917
|
});
|
|
1033
918
|
|
|
1034
|
-
//
|
|
1035
|
-
const
|
|
1036
|
-
...existingNewsletter,
|
|
919
|
+
// Send only changed fields + updated_at for OCC (optimistic concurrency control)
|
|
920
|
+
const editData = {
|
|
1037
921
|
...updateData,
|
|
1038
922
|
updated_at: existingNewsletter.updated_at,
|
|
1039
923
|
};
|
|
1040
924
|
|
|
1041
|
-
return await handleApiRequest('newsletters', 'edit',
|
|
925
|
+
return await handleApiRequest('newsletters', 'edit', editData, { id: newsletterId });
|
|
1042
926
|
} catch (error) {
|
|
1043
927
|
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
1044
928
|
throw new NotFoundError('Newsletter', newsletterId);
|
|
@@ -1105,17 +989,16 @@ export async function updateTier(id, updateData, options = {}) {
|
|
|
1105
989
|
validateTierUpdateData(updateData);
|
|
1106
990
|
|
|
1107
991
|
try {
|
|
1108
|
-
// Get existing tier for
|
|
992
|
+
// Get existing tier to retrieve updated_at for conflict resolution
|
|
1109
993
|
const existingTier = await handleApiRequest('tiers', 'read', { id }, { id });
|
|
1110
994
|
|
|
1111
|
-
//
|
|
1112
|
-
const
|
|
1113
|
-
...existingTier,
|
|
995
|
+
// Send only changed fields + updated_at for OCC (optimistic concurrency control)
|
|
996
|
+
const editData = {
|
|
1114
997
|
...updateData,
|
|
1115
998
|
updated_at: existingTier.updated_at,
|
|
1116
999
|
};
|
|
1117
1000
|
|
|
1118
|
-
return await handleApiRequest('tiers', 'edit',
|
|
1001
|
+
return await handleApiRequest('tiers', 'edit', editData, { id, ...options });
|
|
1119
1002
|
} catch (error) {
|
|
1120
1003
|
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
1121
1004
|
throw new NotFoundError('Tier', id);
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
validateImageUrl,
|
|
4
|
+
createSecureAxiosConfig,
|
|
5
|
+
createBeforeRedirect,
|
|
6
|
+
isSafeHost,
|
|
7
|
+
ALLOWED_DOMAINS,
|
|
8
|
+
} from '../urlValidator.js';
|
|
3
9
|
|
|
4
10
|
describe('urlValidator', () => {
|
|
5
11
|
describe('ALLOWED_DOMAINS', () => {
|
|
@@ -446,5 +452,135 @@ describe('urlValidator', () => {
|
|
|
446
452
|
expect(config2.url).toBe(url2);
|
|
447
453
|
expect(config1.url).not.toBe(config2.url);
|
|
448
454
|
});
|
|
455
|
+
|
|
456
|
+
it('should include beforeRedirect callback', () => {
|
|
457
|
+
const config = createSecureAxiosConfig('https://imgur.com/image.jpg');
|
|
458
|
+
expect(config).toHaveProperty('beforeRedirect');
|
|
459
|
+
expect(typeof config.beforeRedirect).toBe('function');
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
describe('isSafeHost', () => {
|
|
464
|
+
it('should allow domains on the allowlist', () => {
|
|
465
|
+
expect(isSafeHost('imgur.com')).toBe(true);
|
|
466
|
+
expect(isSafeHost('i.imgur.com')).toBe(true);
|
|
467
|
+
expect(isSafeHost('github.com')).toBe(true);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it('should allow subdomains of allowed domains', () => {
|
|
471
|
+
expect(isSafeHost('cdn.images.unsplash.com')).toBe(true);
|
|
472
|
+
expect(isSafeHost('my-bucket.s3.amazonaws.com')).toBe(true);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it('should reject domains not on the allowlist', () => {
|
|
476
|
+
expect(isSafeHost('evil.com')).toBe(false);
|
|
477
|
+
expect(isSafeHost('fakeimgur.com')).toBe(false);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it('should block localhost IPs', () => {
|
|
481
|
+
expect(isSafeHost('127.0.0.1')).toBe(false);
|
|
482
|
+
expect(isSafeHost('127.0.0.2')).toBe(false);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it('should block private network IPs', () => {
|
|
486
|
+
expect(isSafeHost('10.0.0.1')).toBe(false);
|
|
487
|
+
expect(isSafeHost('192.168.1.1')).toBe(false);
|
|
488
|
+
expect(isSafeHost('172.16.0.1')).toBe(false);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it('should block link-local and cloud metadata IPs', () => {
|
|
492
|
+
expect(isSafeHost('169.254.169.254')).toBe(false);
|
|
493
|
+
expect(isSafeHost('169.254.0.1')).toBe(false);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it('should block IPv6 private addresses', () => {
|
|
497
|
+
expect(isSafeHost('::1')).toBe(false);
|
|
498
|
+
expect(isSafeHost('fc00::1')).toBe(false);
|
|
499
|
+
expect(isSafeHost('fe80::1')).toBe(false);
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it('should allow public IPs not on blocklist', () => {
|
|
503
|
+
expect(isSafeHost('8.8.8.8')).toBe(true);
|
|
504
|
+
expect(isSafeHost('1.1.1.1')).toBe(true);
|
|
505
|
+
});
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
describe('createBeforeRedirect', () => {
|
|
509
|
+
it('should not throw for redirects to allowed domains', () => {
|
|
510
|
+
const beforeRedirect = createBeforeRedirect();
|
|
511
|
+
expect(() =>
|
|
512
|
+
beforeRedirect({
|
|
513
|
+
protocol: 'https:',
|
|
514
|
+
hostname: 'imgur.com',
|
|
515
|
+
path: '/image.jpg',
|
|
516
|
+
})
|
|
517
|
+
).not.toThrow();
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
it('should throw for redirect to 127.0.0.1 (localhost)', () => {
|
|
521
|
+
const beforeRedirect = createBeforeRedirect();
|
|
522
|
+
expect(() =>
|
|
523
|
+
beforeRedirect({
|
|
524
|
+
protocol: 'https:',
|
|
525
|
+
hostname: '127.0.0.1',
|
|
526
|
+
path: '/secret',
|
|
527
|
+
})
|
|
528
|
+
).toThrow('Redirect blocked');
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it('should throw for redirect to 169.254.169.254 (AWS metadata)', () => {
|
|
532
|
+
const beforeRedirect = createBeforeRedirect();
|
|
533
|
+
expect(() =>
|
|
534
|
+
beforeRedirect({
|
|
535
|
+
protocol: 'http:',
|
|
536
|
+
hostname: '169.254.169.254',
|
|
537
|
+
path: '/latest/meta-data/',
|
|
538
|
+
})
|
|
539
|
+
).toThrow('Redirect blocked');
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it('should throw for redirect to 192.168.x.x (private network)', () => {
|
|
543
|
+
const beforeRedirect = createBeforeRedirect();
|
|
544
|
+
expect(() =>
|
|
545
|
+
beforeRedirect({
|
|
546
|
+
protocol: 'https:',
|
|
547
|
+
hostname: '192.168.1.1',
|
|
548
|
+
path: '/admin',
|
|
549
|
+
})
|
|
550
|
+
).toThrow('Redirect blocked');
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
it('should throw for redirect to 10.x.x.x (private network)', () => {
|
|
554
|
+
const beforeRedirect = createBeforeRedirect();
|
|
555
|
+
expect(() =>
|
|
556
|
+
beforeRedirect({
|
|
557
|
+
protocol: 'https:',
|
|
558
|
+
hostname: '10.0.0.1',
|
|
559
|
+
path: '/internal',
|
|
560
|
+
})
|
|
561
|
+
).toThrow('Redirect blocked');
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
it('should throw for redirect to disallowed domain', () => {
|
|
565
|
+
const beforeRedirect = createBeforeRedirect();
|
|
566
|
+
expect(() =>
|
|
567
|
+
beforeRedirect({
|
|
568
|
+
protocol: 'https:',
|
|
569
|
+
hostname: 'evil.com',
|
|
570
|
+
path: '/payload',
|
|
571
|
+
})
|
|
572
|
+
).toThrow('Redirect blocked');
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it('should allow redirect between allowed domains', () => {
|
|
576
|
+
const beforeRedirect = createBeforeRedirect();
|
|
577
|
+
expect(() =>
|
|
578
|
+
beforeRedirect({
|
|
579
|
+
protocol: 'https:',
|
|
580
|
+
hostname: 'images.unsplash.com',
|
|
581
|
+
path: '/photo-123',
|
|
582
|
+
})
|
|
583
|
+
).not.toThrow();
|
|
584
|
+
});
|
|
449
585
|
});
|
|
450
586
|
});
|
|
@@ -147,7 +147,23 @@ const validateImageUrl = (url) => {
|
|
|
147
147
|
};
|
|
148
148
|
|
|
149
149
|
/**
|
|
150
|
-
*
|
|
150
|
+
* Creates a beforeRedirect callback that validates each redirect target against
|
|
151
|
+
* the same SSRF rules applied to the initial URL.
|
|
152
|
+
* @returns {function} Axios beforeRedirect callback
|
|
153
|
+
*/
|
|
154
|
+
const createBeforeRedirect = () => {
|
|
155
|
+
return (options) => {
|
|
156
|
+
const redirectUrl = `${options.protocol}//${options.hostname}${options.path}`;
|
|
157
|
+
const validation = validateImageUrl(redirectUrl);
|
|
158
|
+
if (!validation.isValid) {
|
|
159
|
+
throw new Error(`Redirect blocked: ${validation.error}`);
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
// Note: DNS rebinding (TOCTOU) attacks are not mitigated by hostname-based validation
|
|
165
|
+
/**
|
|
166
|
+
* Configures axios with security settings for external requests.
|
|
151
167
|
* @param {string} url - The validated URL to request
|
|
152
168
|
* @returns {object} Axios configuration with security settings
|
|
153
169
|
*/
|
|
@@ -159,10 +175,17 @@ const createSecureAxiosConfig = (url) => {
|
|
|
159
175
|
maxRedirects: 3, // Limit redirects
|
|
160
176
|
maxContentLength: 50 * 1024 * 1024, // 50MB max response
|
|
161
177
|
validateStatus: (status) => status >= 200 && status < 300, // Only accept 2xx
|
|
178
|
+
beforeRedirect: createBeforeRedirect(),
|
|
162
179
|
headers: {
|
|
163
180
|
'User-Agent': 'Ghost-MCP-Server/1.0',
|
|
164
181
|
},
|
|
165
182
|
};
|
|
166
183
|
};
|
|
167
184
|
|
|
168
|
-
export {
|
|
185
|
+
export {
|
|
186
|
+
validateImageUrl,
|
|
187
|
+
createSecureAxiosConfig,
|
|
188
|
+
createBeforeRedirect,
|
|
189
|
+
isSafeHost,
|
|
190
|
+
ALLOWED_DOMAINS,
|
|
191
|
+
};
|