@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.
@@ -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
- // Sanitize HTML content if provided
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
- // Merge with existing data
351
- const mergedData = {
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, // Required for Ghost API
315
+ updated_at: existingPost.updated_at,
355
316
  };
356
317
 
357
- return await handleApiRequest('posts', 'edit', mergedData, { id: postId, ...options });
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
- // Sanitize HTML content if provided (use same sanitization as posts)
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
- // Sanitize HTML if being updated
517
- if (updateData.html) {
518
- updateData.html = sanitizeHtml(updateData.html, {
519
- allowedTags: [
520
- 'h1',
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
- // Merge existing data with updates, preserving updated_at
563
- const mergedData = {
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', mergedData, { id: pageId, ...options });
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(name) {
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('tags', 'browse', {}, options);
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
- console.error('Failed to get tags:', error);
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
- const existingTag = await getTag(tagId);
741
- const mergedData = {
742
- ...existingTag,
743
- ...updateData,
744
- };
628
+ // Verify tag exists before updating
629
+ await getTag(tagId);
745
630
 
746
- return await handleApiRequest('tags', 'edit', mergedData, { id: tagId });
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
- // Merge existing data with updates, preserving updated_at
835
- const mergedData = {
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', mergedData, { id: memberId, ...options });
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
- // Merge existing data with updates, preserving updated_at
1035
- const mergedData = {
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', mergedData, { id: newsletterId });
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 merge
992
+ // Get existing tier to retrieve updated_at for conflict resolution
1109
993
  const existingTier = await handleApiRequest('tiers', 'read', { id }, { id });
1110
994
 
1111
- // Merge updates with existing data
1112
- const mergedData = {
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', mergedData, { id, ...options });
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 { validateImageUrl, createSecureAxiosConfig, ALLOWED_DOMAINS } from '../urlValidator.js';
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
- * Configures axios with security settings for external requests
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 { validateImageUrl, createSecureAxiosConfig, ALLOWED_DOMAINS };
185
+ export {
186
+ validateImageUrl,
187
+ createSecureAxiosConfig,
188
+ createBeforeRedirect,
189
+ isSafeHost,
190
+ ALLOWED_DOMAINS,
191
+ };