@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 +2 -6
- package/src/mcp_server.js +2 -2
- package/src/schemas/pageSchemas.js +0 -2
- package/src/schemas/postSchemas.js +6 -4
- 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 +13 -2
- package/src/services/__tests__/ghostServiceImproved.tiers.test.js +12 -8
- package/src/services/ghostServiceImproved.js +61 -184
- package/src/utils/__tests__/urlValidator.test.js +137 -1
- package/src/utils/urlValidator.js +25 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jgardner04/ghost-mcp-server",
|
|
3
|
-
"version": "1.13.
|
|
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.
|
|
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(
|
|
61
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
240
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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).
|
|
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
|
|
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
|
-
{
|
|
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
|
|
331
|
+
it('should throw ValidationError when updating to scheduled without published_at', async () => {
|
|
321
332
|
const pageId = 'page-123';
|
|
322
|
-
const existingPage = {
|
|
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, {
|
|
327
|
-
|
|
328
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
333
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
354
|
-
const
|
|
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,
|
|
315
|
+
updated_at: existingPost.updated_at,
|
|
358
316
|
};
|
|
359
317
|
|
|
360
|
-
return await handleApiRequest('posts', 'edit',
|
|
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
|
-
//
|
|
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
|
-
//
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
-
//
|
|
566
|
-
const
|
|
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',
|
|
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
|
-
|
|
747
|
-
|
|
748
|
-
...existingTag,
|
|
749
|
-
...updateData,
|
|
750
|
-
};
|
|
628
|
+
// Verify tag exists before updating
|
|
629
|
+
await getTag(tagId);
|
|
751
630
|
|
|
752
|
-
|
|
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
|
-
//
|
|
841
|
-
const
|
|
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',
|
|
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
|
-
//
|
|
1041
|
-
const
|
|
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',
|
|
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
|
|
992
|
+
// Get existing tier to retrieve updated_at for conflict resolution
|
|
1115
993
|
const existingTier = await handleApiRequest('tiers', 'read', { id }, { id });
|
|
1116
994
|
|
|
1117
|
-
//
|
|
1118
|
-
const
|
|
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',
|
|
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 {
|
|
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
|
+
};
|