@jgardner04/ghost-mcp-server 1.13.1 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgardner04/ghost-mcp-server",
3
- "version": "1.13.1",
3
+ "version": "1.13.2",
4
4
  "description": "A Model Context Protocol (MCP) server for interacting with Ghost CMS via the Admin API",
5
5
  "author": "Jonathan Gardner",
6
6
  "type": "module",
@@ -30,10 +30,6 @@
30
30
  "url": "https://github.com/jgardner04/Ghost-MCP-Server/issues"
31
31
  },
32
32
  "scripts": {
33
- "dev": "node scripts/dev.js",
34
- "list": "node scripts/dev.js list",
35
- "generate": "node scripts/dev.js generate",
36
- "parse-prd": "node scripts/dev.js parse-prd",
37
33
  "build": "mkdir -p build && cp -r src/* build/",
38
34
  "start": "node src/index.js",
39
35
  "start:mcp": "node src/mcp_server.js",
@@ -107,7 +103,7 @@
107
103
  "husky": "^9.1.7",
108
104
  "lint-staged": "^16.2.7",
109
105
  "prettier": "^3.7.4",
110
- "semantic-release": "^25.0.2",
106
+ "semantic-release": "^25.0.3",
111
107
  "supertest": "^7.1.4",
112
108
  "vitest": "^4.0.15"
113
109
  }
package/src/mcp_server.js CHANGED
@@ -672,7 +672,7 @@ server.registerTool(
672
672
  'ghost_update_post',
673
673
  {
674
674
  description:
675
- 'Updates an existing post in Ghost CMS. Can update title, content, status, tags, images, and SEO fields.',
675
+ 'Updates an existing post in Ghost CMS. Can update title, content, status, tags, images, and SEO fields. Only the provided fields are changed; omitted fields remain unchanged. Note: tags and authors arrays are fully replaced, not merged with existing values.',
676
676
  inputSchema: updatePostInputSchema,
677
677
  },
678
678
  async (rawInput) => {
@@ -939,7 +939,7 @@ server.registerTool(
939
939
  'ghost_update_page',
940
940
  {
941
941
  description:
942
- 'Updates an existing page in Ghost CMS. Can update title, content, status, images, and SEO fields.',
942
+ 'Updates an existing page in Ghost CMS. Can update title, content, status, images, and SEO fields. Only the provided fields are changed; omitted fields remain unchanged.',
943
943
  inputSchema: updatePageInputSchema,
944
944
  },
945
945
  async (rawInput) => {
@@ -62,8 +62,6 @@ export const createPageSchema = z.object({
62
62
  tags: tagsSchema.describe('Array of tag names or IDs (rarely used for pages)'),
63
63
  authors: authorsSchema.describe('Array of author IDs or emails'),
64
64
  published_at: isoDateSchema.optional().describe('Scheduled publish time (ISO 8601 format)'),
65
- updated_at: isoDateSchema.optional(),
66
- created_at: isoDateSchema.optional(),
67
65
  codeinjection_head: z.string().optional(),
68
66
  codeinjection_foot: z.string().optional(),
69
67
  custom_template: z.string().optional().describe('Custom template filename'),
@@ -57,11 +57,13 @@ export const createPostSchema = z.object({
57
57
  .max(500, 'Twitter description cannot exceed 500 characters')
58
58
  .optional(),
59
59
  canonical_url: canonicalUrlSchema,
60
- tags: tagsSchema.describe('Array of tag names or IDs to associate with the post'),
61
- authors: authorsSchema.describe('Array of author IDs or emails'),
60
+ tags: tagsSchema.describe(
61
+ 'Array of tag names or IDs to associate with the post. On update, this fully replaces the existing tags array (not merged).'
62
+ ),
63
+ authors: authorsSchema.describe(
64
+ 'Array of author IDs or emails. On update, this fully replaces the existing authors array (not merged).'
65
+ ),
62
66
  published_at: isoDateSchema.optional().describe('Scheduled publish time (ISO 8601 format)'),
63
- updated_at: isoDateSchema.optional(),
64
- created_at: isoDateSchema.optional(),
65
67
  codeinjection_head: z.string().optional(),
66
68
  codeinjection_foot: z.string().optional(),
67
69
  custom_template: z.string().optional().describe('Custom template filename'),
@@ -144,7 +144,7 @@ describe('ghostServiceImproved - Members', () => {
144
144
  });
145
145
 
146
146
  describe('updateMember', () => {
147
- it('should update a member with valid ID and data', async () => {
147
+ it('should send only update fields and updated_at, not the full existing member', async () => {
148
148
  const memberId = 'member-1';
149
149
  const updateData = {
150
150
  name: 'Jane Doe',
@@ -153,8 +153,10 @@ describe('ghostServiceImproved - Members', () => {
153
153
 
154
154
  const mockExistingMember = {
155
155
  id: memberId,
156
+ uuid: 'abc-def-123',
156
157
  email: 'test@example.com',
157
158
  name: 'John Doe',
159
+ status: 'free',
158
160
  updated_at: '2023-01-01T00:00:00.000Z',
159
161
  };
160
162
 
@@ -169,13 +171,15 @@ describe('ghostServiceImproved - Members', () => {
169
171
  const result = await updateMember(memberId, updateData);
170
172
 
171
173
  expect(api.members.read).toHaveBeenCalledWith(expect.any(Object), { id: memberId });
174
+ // Should send ONLY updateData + updated_at, NOT the full existing member
172
175
  expect(api.members.edit).toHaveBeenCalledWith(
173
- expect.objectContaining({
174
- ...mockExistingMember,
175
- ...updateData,
176
- }),
176
+ { name: 'Jane Doe', note: 'Updated note', updated_at: '2023-01-01T00:00:00.000Z' },
177
177
  expect.objectContaining({ id: memberId })
178
178
  );
179
+ // Verify read-only fields are NOT sent
180
+ const editCallData = api.members.edit.mock.calls[0][0];
181
+ expect(editCallData).not.toHaveProperty('uuid');
182
+ expect(editCallData).not.toHaveProperty('status');
179
183
  expect(result).toEqual(mockUpdatedMember);
180
184
  });
181
185
 
@@ -194,10 +194,12 @@ describe('ghostServiceImproved - Newsletter Operations', () => {
194
194
  });
195
195
 
196
196
  describe('updateNewsletter', () => {
197
- it('should update a newsletter successfully', async () => {
197
+ it('should send only update fields and updated_at, not the full existing newsletter', async () => {
198
198
  const existingNewsletter = {
199
199
  id: 'newsletter-123',
200
+ uuid: 'abc-def-123',
200
201
  name: 'Old Name',
202
+ slug: 'old-name',
201
203
  updated_at: '2024-01-01T00:00:00.000Z',
202
204
  };
203
205
  const updateData = { name: 'New Name' };
@@ -210,13 +212,15 @@ describe('ghostServiceImproved - Newsletter Operations', () => {
210
212
 
211
213
  expect(result).toEqual(updatedNewsletter);
212
214
  expect(mockNewslettersApi.read).toHaveBeenCalledWith({}, { id: 'newsletter-123' });
215
+ // Should send ONLY updateData + updated_at, NOT the full existing newsletter
213
216
  expect(mockNewslettersApi.edit).toHaveBeenCalledWith(
214
- {
215
- ...existingNewsletter,
216
- ...updateData,
217
- },
217
+ { name: 'New Name', updated_at: '2024-01-01T00:00:00.000Z' },
218
218
  { id: 'newsletter-123' }
219
219
  );
220
+ // Verify read-only fields are NOT sent
221
+ const editCallData = mockNewslettersApi.edit.mock.calls[0][0];
222
+ expect(editCallData).not.toHaveProperty('uuid');
223
+ expect(editCallData).not.toHaveProperty('slug');
220
224
  });
221
225
 
222
226
  it('should update newsletter with email settings', async () => {
@@ -236,9 +240,11 @@ describe('ghostServiceImproved - Newsletter Operations', () => {
236
240
 
237
241
  await updateNewsletter('newsletter-123', updateData);
238
242
 
239
- expect(mockNewslettersApi.edit).toHaveBeenCalledWith(expect.objectContaining(updateData), {
240
- id: 'newsletter-123',
241
- });
243
+ // Should send ONLY updateData + updated_at
244
+ expect(mockNewslettersApi.edit).toHaveBeenCalledWith(
245
+ { ...updateData, updated_at: '2024-01-01T00:00:00.000Z' },
246
+ { id: 'newsletter-123' }
247
+ );
242
248
  });
243
249
 
244
250
  it('should throw ValidationError if ID is missing', async () => {
@@ -270,10 +276,9 @@ describe('ghostServiceImproved - Newsletter Operations', () => {
270
276
 
271
277
  await updateNewsletter('newsletter-123', updateData);
272
278
 
279
+ // Should send ONLY updateData + updated_at
273
280
  expect(mockNewslettersApi.edit).toHaveBeenCalledWith(
274
- expect.objectContaining({
275
- updated_at: '2024-01-01T00:00:00.000Z',
276
- }),
281
+ { description: 'Updated description', updated_at: '2024-01-01T00:00:00.000Z' },
277
282
  { id: 'newsletter-123' }
278
283
  );
279
284
  });
@@ -63,6 +63,7 @@ import {
63
63
  api,
64
64
  validators,
65
65
  } from '../ghostServiceImproved.js';
66
+ import { createPageSchema, updatePageSchema } from '../../schemas/pageSchemas.js';
66
67
 
67
68
  describe('ghostServiceImproved - Pages', () => {
68
69
  beforeEach(() => {
@@ -219,7 +220,7 @@ describe('ghostServiceImproved - Pages', () => {
219
220
  expect(api.pages.add).toHaveBeenCalledWith(pageData, { source: 'html' });
220
221
  });
221
222
 
222
- it('should sanitize HTML content', async () => {
223
+ it('should pass HTML through without service-layer sanitization (schema layer handles it)', async () => {
223
224
  const pageData = {
224
225
  title: 'Test Page',
225
226
  html: '<p>Safe content</p><script>alert("xss")</script>',
@@ -228,10 +229,10 @@ describe('ghostServiceImproved - Pages', () => {
228
229
 
229
230
  await createPage(pageData);
230
231
 
231
- // Verify that the HTML was sanitized (script tags removed)
232
+ // HTML sanitization is enforced at the schema layer via htmlContentSchema,
233
+ // not at the service layer
232
234
  const calledWith = api.pages.add.mock.calls[0][0];
233
- expect(calledWith.html).not.toContain('<script>');
234
- expect(calledWith.html).toContain('<p>Safe content</p>');
235
+ expect(calledWith.html).toBeDefined();
235
236
  });
236
237
 
237
238
  it('should handle Ghost API validation errors (422)', async () => {
@@ -266,12 +267,16 @@ describe('ghostServiceImproved - Pages', () => {
266
267
  await expect(updatePage('', { title: 'Updated' })).rejects.toThrow('Page ID is required');
267
268
  });
268
269
 
269
- it('should update page with valid data', async () => {
270
+ it('should send only update fields and updated_at, not the full existing page', async () => {
270
271
  const pageId = 'page-123';
271
272
  const existingPage = {
272
273
  id: pageId,
274
+ uuid: 'abc-def-123',
273
275
  title: 'Original Title',
276
+ slug: 'original-title',
274
277
  html: '<p>Original content</p>',
278
+ url: 'https://example.com/original-title',
279
+ reading_time: 2,
275
280
  updated_at: '2024-01-01T00:00:00.000Z',
276
281
  };
277
282
  const updateData = { title: 'Updated Title' };
@@ -285,10 +290,16 @@ describe('ghostServiceImproved - Pages', () => {
285
290
  expect(result).toEqual(expectedPage);
286
291
  // handleApiRequest calls read with (options, data), where options={} and data={id}
287
292
  expect(api.pages.read).toHaveBeenCalledWith({}, { id: pageId });
293
+ // Should send ONLY updateData + updated_at, NOT the full existing page
288
294
  expect(api.pages.edit).toHaveBeenCalledWith(
289
- { ...existingPage, ...updateData },
295
+ { title: 'Updated Title', updated_at: '2024-01-01T00:00:00.000Z' },
290
296
  { id: pageId }
291
297
  );
298
+ // Verify read-only fields are NOT sent
299
+ const editCallData = api.pages.edit.mock.calls[0][0];
300
+ expect(editCallData).not.toHaveProperty('uuid');
301
+ expect(editCallData).not.toHaveProperty('url');
302
+ expect(editCallData).not.toHaveProperty('reading_time');
292
303
  });
293
304
 
294
305
  it('should handle page not found (404)', async () => {
@@ -317,16 +328,18 @@ describe('ghostServiceImproved - Pages', () => {
317
328
  expect(editCall.updated_at).toBe('2024-01-01T00:00:00.000Z');
318
329
  });
319
330
 
320
- it('should sanitize HTML content in updates', async () => {
331
+ it('should throw ValidationError when updating to scheduled without published_at', async () => {
321
332
  const pageId = 'page-123';
322
- const existingPage = { id: pageId, title: 'Test', updated_at: '2024-01-01T00:00:00.000Z' };
333
+ const existingPage = {
334
+ id: pageId,
335
+ title: 'Test Page',
336
+ updated_at: '2024-01-01T00:00:00.000Z',
337
+ };
323
338
  api.pages.read.mockResolvedValue(existingPage);
324
- api.pages.edit.mockResolvedValue({ ...existingPage });
325
339
 
326
- await updatePage(pageId, { html: '<p>Safe</p><script>alert("xss")</script>' });
327
-
328
- const editCall = api.pages.edit.mock.calls[0][0];
329
- expect(editCall.html).not.toContain('<script>');
340
+ await expect(updatePage(pageId, { status: 'scheduled' })).rejects.toThrow(
341
+ 'Page validation failed'
342
+ );
330
343
  });
331
344
  });
332
345
 
@@ -558,4 +571,34 @@ describe('ghostServiceImproved - Pages', () => {
558
571
  expect(browseCall.filter).toContain('+');
559
572
  });
560
573
  });
574
+
575
+ describe('HTML sanitization (schema + service integration)', () => {
576
+ it('should strip XSS from page HTML when input flows through schema validation (production path)', async () => {
577
+ const rawInput = { title: 'Test Page', html: '<p>Safe</p><script>alert("xss")</script>' };
578
+ const validated = createPageSchema.parse(rawInput);
579
+
580
+ api.pages.add.mockResolvedValue({ id: '1', ...validated });
581
+ await createPage(validated);
582
+
583
+ const sentToApi = api.pages.add.mock.calls[0][0];
584
+ expect(sentToApi.html).not.toContain('<script>');
585
+ expect(sentToApi.html).not.toContain('alert');
586
+ expect(sentToApi.html).toContain('<p>Safe</p>');
587
+ });
588
+
589
+ it('should strip XSS from page HTML on update when input flows through schema validation', async () => {
590
+ const rawUpdate = { html: '<p>Updated</p><img src=x onerror="alert(1)">' };
591
+ const validated = updatePageSchema.parse(rawUpdate);
592
+
593
+ const existingPage = { id: 'page-1', updated_at: '2024-01-01T00:00:00.000Z' };
594
+ api.pages.read.mockResolvedValue(existingPage);
595
+ api.pages.edit.mockResolvedValue({ ...existingPage, ...validated });
596
+
597
+ await updatePage('page-1', validated);
598
+
599
+ const sentToApi = api.pages.edit.mock.calls[0][0];
600
+ expect(sentToApi.html).not.toContain('onerror');
601
+ expect(sentToApi.html).toContain('<img src="x"');
602
+ });
603
+ });
561
604
  });
@@ -0,0 +1,233 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { createMockContextLogger } from '../../__tests__/helpers/mockLogger.js';
3
+ import { mockDotenv } from '../../__tests__/helpers/testUtils.js';
4
+
5
+ // Mock the Ghost Admin API with posts support
6
+ vi.mock('@tryghost/admin-api', () => ({
7
+ default: vi.fn(function () {
8
+ return {
9
+ posts: {
10
+ add: vi.fn(),
11
+ browse: vi.fn(),
12
+ read: vi.fn(),
13
+ edit: vi.fn(),
14
+ delete: vi.fn(),
15
+ },
16
+ pages: {
17
+ add: vi.fn(),
18
+ browse: vi.fn(),
19
+ read: vi.fn(),
20
+ edit: vi.fn(),
21
+ delete: vi.fn(),
22
+ },
23
+ tags: {
24
+ add: vi.fn(),
25
+ browse: vi.fn(),
26
+ read: vi.fn(),
27
+ edit: vi.fn(),
28
+ delete: vi.fn(),
29
+ },
30
+ members: {
31
+ add: vi.fn(),
32
+ browse: vi.fn(),
33
+ read: vi.fn(),
34
+ edit: vi.fn(),
35
+ delete: vi.fn(),
36
+ },
37
+ site: {
38
+ read: vi.fn(),
39
+ },
40
+ images: {
41
+ upload: vi.fn(),
42
+ },
43
+ };
44
+ }),
45
+ }));
46
+
47
+ // Mock dotenv
48
+ vi.mock('dotenv', () => mockDotenv());
49
+
50
+ // Mock logger
51
+ vi.mock('../../utils/logger.js', () => ({
52
+ createContextLogger: createMockContextLogger(),
53
+ }));
54
+
55
+ // Mock fs for validateImagePath
56
+ vi.mock('fs/promises', () => ({
57
+ default: {
58
+ access: vi.fn(),
59
+ },
60
+ }));
61
+
62
+ // Import after setting up mocks
63
+ import { updatePost, api, validators } from '../ghostServiceImproved.js';
64
+ import { updatePostSchema } from '../../schemas/postSchemas.js';
65
+
66
+ describe('ghostServiceImproved - Posts (updatePost)', () => {
67
+ beforeEach(() => {
68
+ vi.clearAllMocks();
69
+ });
70
+
71
+ describe('updatePost', () => {
72
+ it('should send only update fields and updated_at, not the full existing post', async () => {
73
+ const postId = 'post-123';
74
+ const existingPost = {
75
+ id: postId,
76
+ uuid: 'abc-def-123',
77
+ title: 'Original Title',
78
+ slug: 'original-title',
79
+ html: '<p>Original content</p>',
80
+ status: 'published',
81
+ url: 'https://example.com/original-title',
82
+ comment_id: 'comment-123',
83
+ reading_time: 3,
84
+ updated_at: '2024-01-01T00:00:00.000Z',
85
+ created_at: '2023-12-01T00:00:00.000Z',
86
+ };
87
+ const updateData = { title: 'Updated Title' };
88
+ const expectedResult = { ...existingPost, title: 'Updated Title' };
89
+
90
+ api.posts.read.mockResolvedValue(existingPost);
91
+ api.posts.edit.mockResolvedValue(expectedResult);
92
+
93
+ const result = await updatePost(postId, updateData);
94
+
95
+ expect(result).toEqual(expectedResult);
96
+ // Should send ONLY updateData + updated_at, NOT the full existing post
97
+ expect(api.posts.edit).toHaveBeenCalledWith(
98
+ { title: 'Updated Title', updated_at: '2024-01-01T00:00:00.000Z' },
99
+ { id: postId }
100
+ );
101
+ // Verify read-only fields are NOT sent
102
+ const editCallData = api.posts.edit.mock.calls[0][0];
103
+ expect(editCallData).not.toHaveProperty('uuid');
104
+ expect(editCallData).not.toHaveProperty('url');
105
+ expect(editCallData).not.toHaveProperty('comment_id');
106
+ expect(editCallData).not.toHaveProperty('reading_time');
107
+ expect(editCallData).not.toHaveProperty('created_at');
108
+ });
109
+
110
+ it('should preserve updated_at from existing post for OCC', async () => {
111
+ const postId = 'post-123';
112
+ const existingPost = {
113
+ id: postId,
114
+ title: 'Original',
115
+ updated_at: '2024-06-15T12:30:00.000Z',
116
+ };
117
+ api.posts.read.mockResolvedValue(existingPost);
118
+ api.posts.edit.mockResolvedValue({ ...existingPost, title: 'Updated' });
119
+
120
+ await updatePost(postId, { title: 'Updated' });
121
+
122
+ const editCall = api.posts.edit.mock.calls[0][0];
123
+ expect(editCall.updated_at).toBe('2024-06-15T12:30:00.000Z');
124
+ });
125
+
126
+ it('should throw error when post ID is missing', async () => {
127
+ await expect(updatePost(null, { title: 'Updated' })).rejects.toThrow(
128
+ 'Post ID is required for update'
129
+ );
130
+ await expect(updatePost('', { title: 'Updated' })).rejects.toThrow(
131
+ 'Post ID is required for update'
132
+ );
133
+ });
134
+
135
+ it('should handle post not found (404)', async () => {
136
+ const error404 = new Error('Post not found');
137
+ error404.response = { status: 404 };
138
+ api.posts.read.mockRejectedValue(error404);
139
+
140
+ await expect(updatePost('nonexistent-id', { title: 'Updated' })).rejects.toThrow(
141
+ 'Post not found'
142
+ );
143
+ });
144
+
145
+ it('should throw ValidationError when updating to scheduled without published_at', async () => {
146
+ const postId = 'post-123';
147
+ const existingPost = {
148
+ id: postId,
149
+ title: 'Test Post',
150
+ updated_at: '2024-01-01T00:00:00.000Z',
151
+ };
152
+ api.posts.read.mockResolvedValue(existingPost);
153
+
154
+ await expect(updatePost(postId, { status: 'scheduled' })).rejects.toThrow(
155
+ 'Post validation failed'
156
+ );
157
+ });
158
+
159
+ it('should allow updating to scheduled with valid future published_at', async () => {
160
+ const postId = 'post-123';
161
+ const existingPost = {
162
+ id: postId,
163
+ title: 'Test Post',
164
+ updated_at: '2024-01-01T00:00:00.000Z',
165
+ };
166
+ const futureDate = new Date(Date.now() + 86400000).toISOString();
167
+ const updateData = { status: 'scheduled', published_at: futureDate };
168
+
169
+ api.posts.read.mockResolvedValue(existingPost);
170
+ api.posts.edit.mockResolvedValue({ ...existingPost, ...updateData });
171
+
172
+ const result = await updatePost(postId, updateData);
173
+
174
+ expect(result).toBeDefined();
175
+ expect(api.posts.edit).toHaveBeenCalledWith(
176
+ { ...updateData, updated_at: existingPost.updated_at },
177
+ { id: postId }
178
+ );
179
+ });
180
+ });
181
+
182
+ describe('validators.validateScheduledStatus', () => {
183
+ it('should throw when status is scheduled without published_at', () => {
184
+ expect(() => validators.validateScheduledStatus({ status: 'scheduled' })).toThrow(
185
+ 'validation failed'
186
+ );
187
+ });
188
+
189
+ it('should throw when published_at has invalid date format', () => {
190
+ expect(() => validators.validateScheduledStatus({ published_at: 'not-a-date' })).toThrow(
191
+ 'validation failed'
192
+ );
193
+ });
194
+
195
+ it('should throw when scheduled date is in the past', () => {
196
+ const pastDate = new Date(Date.now() - 86400000).toISOString();
197
+ expect(() =>
198
+ validators.validateScheduledStatus({ status: 'scheduled', published_at: pastDate })
199
+ ).toThrow('validation failed');
200
+ });
201
+
202
+ it('should not throw for valid scheduled status with future date', () => {
203
+ const futureDate = new Date(Date.now() + 86400000).toISOString();
204
+ expect(() =>
205
+ validators.validateScheduledStatus({ status: 'scheduled', published_at: futureDate })
206
+ ).not.toThrow();
207
+ });
208
+
209
+ it('should not throw when status is not scheduled', () => {
210
+ expect(() => validators.validateScheduledStatus({ status: 'draft' })).not.toThrow();
211
+ expect(() => validators.validateScheduledStatus({ status: 'published' })).not.toThrow();
212
+ expect(() => validators.validateScheduledStatus({})).not.toThrow();
213
+ });
214
+ });
215
+
216
+ describe('HTML sanitization (schema + service integration)', () => {
217
+ it('should strip XSS from post HTML on update when input flows through schema validation (production path)', async () => {
218
+ const rawUpdate = { html: '<p>Safe</p><script>alert("xss")</script>' };
219
+ const validated = updatePostSchema.parse(rawUpdate);
220
+
221
+ const existingPost = { id: 'post-1', updated_at: '2024-01-01T00:00:00.000Z' };
222
+ api.posts.read.mockResolvedValue(existingPost);
223
+ api.posts.edit.mockResolvedValue({ ...existingPost, ...validated });
224
+
225
+ await updatePost('post-1', validated);
226
+
227
+ const sentToApi = api.posts.edit.mock.calls[0][0];
228
+ expect(sentToApi.html).not.toContain('<script>');
229
+ expect(sentToApi.html).not.toContain('alert');
230
+ expect(sentToApi.html).toContain('<p>Safe</p>');
231
+ });
232
+ });
233
+ });
@@ -403,7 +403,7 @@ describe('ghostServiceImproved - Tags', () => {
403
403
  });
404
404
 
405
405
  describe('updateTag', () => {
406
- it('should update tag with valid ID and data', async () => {
406
+ it('should send only update fields, not the full existing tag', async () => {
407
407
  const tagId = 'tag-1';
408
408
  const updateData = {
409
409
  name: 'Updated JavaScript',
@@ -414,6 +414,8 @@ describe('ghostServiceImproved - Tags', () => {
414
414
  id: tagId,
415
415
  name: 'JavaScript',
416
416
  slug: 'javascript',
417
+ url: 'https://example.com/tag/javascript',
418
+ visibility: 'public',
417
419
  };
418
420
 
419
421
  const mockUpdatedTag = {
@@ -427,7 +429,16 @@ describe('ghostServiceImproved - Tags', () => {
427
429
  const result = await updateTag(tagId, updateData);
428
430
 
429
431
  expect(api.tags.read).toHaveBeenCalled();
430
- expect(api.tags.edit).toHaveBeenCalled();
432
+ // Should send ONLY updateData, NOT the full existing tag
433
+ expect(api.tags.edit).toHaveBeenCalledWith(
434
+ { name: 'Updated JavaScript', description: 'Updated description' },
435
+ { id: tagId }
436
+ );
437
+ // Verify read-only fields are NOT sent
438
+ const editCallData = api.tags.edit.mock.calls[0][0];
439
+ expect(editCallData).not.toHaveProperty('url');
440
+ expect(editCallData).not.toHaveProperty('visibility');
441
+ expect(editCallData).not.toHaveProperty('slug');
431
442
  expect(result).toEqual(mockUpdatedTag);
432
443
  });
433
444
 
@@ -300,12 +300,15 @@ describe('ghostServiceImproved - Tiers', () => {
300
300
  });
301
301
 
302
302
  describe('updateTier', () => {
303
- it('should update a tier', async () => {
303
+ it('should send only update fields and updated_at, not the full existing tier', async () => {
304
304
  const existingTier = {
305
305
  id: 'tier-1',
306
+ slug: 'premium',
306
307
  name: 'Premium',
307
308
  currency: 'USD',
308
309
  monthly_price: 999,
310
+ type: 'paid',
311
+ active: true,
309
312
  updated_at: '2024-01-01T00:00:00.000Z',
310
313
  };
311
314
 
@@ -328,15 +331,16 @@ describe('ghostServiceImproved - Tiers', () => {
328
331
  expect.objectContaining({ id: 'tier-1' }),
329
332
  expect.objectContaining({ id: 'tier-1' })
330
333
  );
334
+ // Should send ONLY updateData + updated_at, NOT the full existing tier
331
335
  expect(api.tiers.edit).toHaveBeenCalledWith(
332
- expect.objectContaining({
333
- ...existingTier,
334
- ...updateData,
335
- }),
336
- expect.objectContaining({
337
- id: 'tier-1',
338
- })
336
+ { name: 'Premium Plus', monthly_price: 1299, updated_at: '2024-01-01T00:00:00.000Z' },
337
+ expect.objectContaining({ id: 'tier-1' })
339
338
  );
339
+ // Verify read-only fields are NOT sent
340
+ const editCallData = api.tiers.edit.mock.calls[0][0];
341
+ expect(editCallData).not.toHaveProperty('slug');
342
+ expect(editCallData).not.toHaveProperty('type');
343
+ expect(editCallData).not.toHaveProperty('active');
340
344
  expect(result).toEqual(mockUpdatedTier);
341
345
  });
342
346
 
@@ -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 {
@@ -118,6 +117,30 @@ const handleApiRequest = async (resource, action, data = {}, options = {}, confi
118
117
  * Input validation helpers
119
118
  */
120
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
+
121
144
  validatePostData(postData) {
122
145
  const errors = [];
123
146
 
@@ -136,25 +159,11 @@ const validators = {
136
159
  });
137
160
  }
138
161
 
139
- if (postData.status === 'scheduled' && !postData.published_at) {
140
- errors.push({
141
- field: 'published_at',
142
- message: 'published_at is required when status is scheduled',
143
- });
144
- }
145
-
146
- if (postData.published_at) {
147
- const publishDate = new Date(postData.published_at);
148
- if (isNaN(publishDate.getTime())) {
149
- errors.push({ field: 'published_at', message: 'Invalid date format' });
150
- } else if (postData.status === 'scheduled' && publishDate <= new Date()) {
151
- errors.push({ field: 'published_at', message: 'Scheduled date must be in the future' });
152
- }
153
- }
154
-
155
162
  if (errors.length > 0) {
156
163
  throw new ValidationError('Post validation failed', errors);
157
164
  }
165
+
166
+ this.validateScheduledStatus(postData, 'Post');
158
167
  },
159
168
 
160
169
  validateTagData(tagData) {
@@ -228,25 +237,11 @@ const validators = {
228
237
  });
229
238
  }
230
239
 
231
- if (pageData.status === 'scheduled' && !pageData.published_at) {
232
- errors.push({
233
- field: 'published_at',
234
- message: 'published_at is required when status is scheduled',
235
- });
236
- }
237
-
238
- if (pageData.published_at) {
239
- const publishDate = new Date(pageData.published_at);
240
- if (isNaN(publishDate.getTime())) {
241
- errors.push({ field: 'published_at', message: 'Invalid date format' });
242
- } else if (pageData.status === 'scheduled' && publishDate <= new Date()) {
243
- errors.push({ field: 'published_at', message: 'Scheduled date must be in the future' });
244
- }
245
- }
246
-
247
240
  if (errors.length > 0) {
248
241
  throw new ValidationError('Page validation failed', errors);
249
242
  }
243
+
244
+ this.validateScheduledStatus(pageData, 'Page');
250
245
  },
251
246
 
252
247
  validateNewsletterData(newsletterData) {
@@ -285,48 +280,7 @@ export async function createPost(postData, options = { source: 'html' }) {
285
280
  ...postData,
286
281
  };
287
282
 
288
- // Sanitize HTML content if provided
289
- if (dataWithDefaults.html) {
290
- // Use proper HTML sanitization library to prevent XSS
291
- dataWithDefaults.html = sanitizeHtml(dataWithDefaults.html, {
292
- allowedTags: [
293
- 'h1',
294
- 'h2',
295
- 'h3',
296
- 'h4',
297
- 'h5',
298
- 'h6',
299
- 'blockquote',
300
- 'p',
301
- 'a',
302
- 'ul',
303
- 'ol',
304
- 'nl',
305
- 'li',
306
- 'b',
307
- 'i',
308
- 'strong',
309
- 'em',
310
- 'strike',
311
- 'code',
312
- 'hr',
313
- 'br',
314
- 'div',
315
- 'span',
316
- 'img',
317
- 'pre',
318
- ],
319
- allowedAttributes: {
320
- a: ['href', 'title'],
321
- img: ['src', 'alt', 'title', 'width', 'height'],
322
- '*': ['class', 'id'],
323
- },
324
- allowedSchemes: ['http', 'https', 'mailto'],
325
- allowedSchemesByTag: {
326
- img: ['http', 'https', 'data'],
327
- },
328
- });
329
- }
283
+ // SECURITY: HTML must be sanitized before reaching this function. See htmlContentSchema in schemas/common.js
330
284
 
331
285
  try {
332
286
  return await handleApiRequest('posts', 'add', dataWithDefaults, options);
@@ -346,18 +300,22 @@ export async function updatePost(postId, updateData, options = {}) {
346
300
  throw new ValidationError('Post ID is required for update');
347
301
  }
348
302
 
303
+ // Validate scheduled status if status is being updated
304
+ if (updateData.status) {
305
+ validators.validateScheduledStatus(updateData, 'Post');
306
+ }
307
+
349
308
  // Get the current post first to ensure it exists
350
309
  try {
351
310
  const existingPost = await handleApiRequest('posts', 'read', { id: postId });
352
311
 
353
- // Merge with existing data
354
- const mergedData = {
355
- ...existingPost,
312
+ // Send only changed fields + updated_at for OCC (optimistic concurrency control)
313
+ const editData = {
356
314
  ...updateData,
357
- updated_at: existingPost.updated_at, // Required for Ghost API
315
+ updated_at: existingPost.updated_at,
358
316
  };
359
317
 
360
- return await handleApiRequest('posts', 'edit', mergedData, { id: postId, ...options });
318
+ return await handleApiRequest('posts', 'edit', editData, { id: postId, ...options });
361
319
  } catch (error) {
362
320
  if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
363
321
  throw new NotFoundError('Post', postId);
@@ -457,47 +415,7 @@ export async function createPage(pageData, options = { source: 'html' }) {
457
415
  ...pageData,
458
416
  };
459
417
 
460
- // Sanitize HTML content if provided (use same sanitization as posts)
461
- if (dataWithDefaults.html) {
462
- dataWithDefaults.html = sanitizeHtml(dataWithDefaults.html, {
463
- allowedTags: [
464
- 'h1',
465
- 'h2',
466
- 'h3',
467
- 'h4',
468
- 'h5',
469
- 'h6',
470
- 'blockquote',
471
- 'p',
472
- 'a',
473
- 'ul',
474
- 'ol',
475
- 'nl',
476
- 'li',
477
- 'b',
478
- 'i',
479
- 'strong',
480
- 'em',
481
- 'strike',
482
- 'code',
483
- 'hr',
484
- 'br',
485
- 'div',
486
- 'span',
487
- 'img',
488
- 'pre',
489
- ],
490
- allowedAttributes: {
491
- a: ['href', 'title'],
492
- img: ['src', 'alt', 'title', 'width', 'height'],
493
- '*': ['class', 'id'],
494
- },
495
- allowedSchemes: ['http', 'https', 'mailto'],
496
- allowedSchemesByTag: {
497
- img: ['http', 'https', 'data'],
498
- },
499
- });
500
- }
418
+ // SECURITY: HTML must be sanitized before reaching this function. See htmlContentSchema in schemas/common.js
501
419
 
502
420
  try {
503
421
  return await handleApiRequest('pages', 'add', dataWithDefaults, options);
@@ -516,60 +434,24 @@ export async function updatePage(pageId, updateData, options = {}) {
516
434
  throw new ValidationError('Page ID is required for update');
517
435
  }
518
436
 
519
- // Sanitize HTML if being updated
520
- if (updateData.html) {
521
- updateData.html = sanitizeHtml(updateData.html, {
522
- allowedTags: [
523
- 'h1',
524
- 'h2',
525
- 'h3',
526
- 'h4',
527
- 'h5',
528
- 'h6',
529
- 'blockquote',
530
- 'p',
531
- 'a',
532
- 'ul',
533
- 'ol',
534
- 'nl',
535
- 'li',
536
- 'b',
537
- 'i',
538
- 'strong',
539
- 'em',
540
- 'strike',
541
- 'code',
542
- 'hr',
543
- 'br',
544
- 'div',
545
- 'span',
546
- 'img',
547
- 'pre',
548
- ],
549
- allowedAttributes: {
550
- a: ['href', 'title'],
551
- img: ['src', 'alt', 'title', 'width', 'height'],
552
- '*': ['class', 'id'],
553
- },
554
- allowedSchemes: ['http', 'https', 'mailto'],
555
- allowedSchemesByTag: {
556
- img: ['http', 'https', 'data'],
557
- },
558
- });
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');
559
442
  }
560
443
 
561
444
  try {
562
445
  // Get existing page to retrieve updated_at for conflict resolution
563
446
  const existingPage = await handleApiRequest('pages', 'read', { id: pageId });
564
447
 
565
- // Merge existing data with updates, preserving updated_at
566
- const mergedData = {
567
- ...existingPage,
448
+ // Send only changed fields + updated_at for OCC (optimistic concurrency control)
449
+ const editData = {
568
450
  ...updateData,
569
451
  updated_at: existingPage.updated_at,
570
452
  };
571
453
 
572
- return await handleApiRequest('pages', 'edit', mergedData, { id: pageId, ...options });
454
+ return await handleApiRequest('pages', 'edit', editData, { id: pageId, ...options });
573
455
  } catch (error) {
574
456
  if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
575
457
  throw new NotFoundError('Page', pageId);
@@ -743,13 +625,11 @@ export async function updateTag(tagId, updateData) {
743
625
  validators.validateTagUpdateData(updateData); // Validate update data
744
626
 
745
627
  try {
746
- const existingTag = await getTag(tagId);
747
- const mergedData = {
748
- ...existingTag,
749
- ...updateData,
750
- };
628
+ // Verify tag exists before updating
629
+ await getTag(tagId);
751
630
 
752
- 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 });
753
633
  } catch (error) {
754
634
  if (error instanceof NotFoundError) {
755
635
  throw error;
@@ -837,14 +717,13 @@ export async function updateMember(memberId, updateData, options = {}) {
837
717
  // Get existing member to retrieve updated_at for conflict resolution
838
718
  const existingMember = await handleApiRequest('members', 'read', { id: memberId });
839
719
 
840
- // Merge existing data with updates, preserving updated_at
841
- const mergedData = {
842
- ...existingMember,
720
+ // Send only changed fields + updated_at for OCC (optimistic concurrency control)
721
+ const editData = {
843
722
  ...updateData,
844
723
  updated_at: existingMember.updated_at,
845
724
  };
846
725
 
847
- return await handleApiRequest('members', 'edit', mergedData, { id: memberId, ...options });
726
+ return await handleApiRequest('members', 'edit', editData, { id: memberId, ...options });
848
727
  } catch (error) {
849
728
  if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
850
729
  throw new NotFoundError('Member', memberId);
@@ -1037,14 +916,13 @@ export async function updateNewsletter(newsletterId, updateData) {
1037
916
  id: newsletterId,
1038
917
  });
1039
918
 
1040
- // Merge existing data with updates, preserving updated_at
1041
- const mergedData = {
1042
- ...existingNewsletter,
919
+ // Send only changed fields + updated_at for OCC (optimistic concurrency control)
920
+ const editData = {
1043
921
  ...updateData,
1044
922
  updated_at: existingNewsletter.updated_at,
1045
923
  };
1046
924
 
1047
- return await handleApiRequest('newsletters', 'edit', mergedData, { id: newsletterId });
925
+ return await handleApiRequest('newsletters', 'edit', editData, { id: newsletterId });
1048
926
  } catch (error) {
1049
927
  if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
1050
928
  throw new NotFoundError('Newsletter', newsletterId);
@@ -1111,17 +989,16 @@ export async function updateTier(id, updateData, options = {}) {
1111
989
  validateTierUpdateData(updateData);
1112
990
 
1113
991
  try {
1114
- // Get existing tier for merge
992
+ // Get existing tier to retrieve updated_at for conflict resolution
1115
993
  const existingTier = await handleApiRequest('tiers', 'read', { id }, { id });
1116
994
 
1117
- // Merge updates with existing data
1118
- const mergedData = {
1119
- ...existingTier,
995
+ // Send only changed fields + updated_at for OCC (optimistic concurrency control)
996
+ const editData = {
1120
997
  ...updateData,
1121
998
  updated_at: existingTier.updated_at,
1122
999
  };
1123
1000
 
1124
- return await handleApiRequest('tiers', 'edit', mergedData, { id, ...options });
1001
+ return await handleApiRequest('tiers', 'edit', editData, { id, ...options });
1125
1002
  } catch (error) {
1126
1003
  if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
1127
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
+ };