@jgardner04/ghost-mcp-server 1.0.0
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/LICENSE +21 -0
- package/README.md +118 -0
- package/package.json +89 -0
- package/src/config/mcp-config.js +131 -0
- package/src/controllers/imageController.js +271 -0
- package/src/controllers/postController.js +46 -0
- package/src/controllers/tagController.js +79 -0
- package/src/errors/index.js +447 -0
- package/src/index.js +110 -0
- package/src/mcp_server.js +509 -0
- package/src/mcp_server_enhanced.js +675 -0
- package/src/mcp_server_improved.js +657 -0
- package/src/middleware/errorMiddleware.js +489 -0
- package/src/resources/ResourceManager.js +666 -0
- package/src/routes/imageRoutes.js +33 -0
- package/src/routes/postRoutes.js +72 -0
- package/src/routes/tagRoutes.js +47 -0
- package/src/services/ghostService.js +221 -0
- package/src/services/ghostServiceImproved.js +489 -0
- package/src/services/imageProcessingService.js +96 -0
- package/src/services/postService.js +174 -0
- package/src/utils/logger.js +153 -0
- package/src/utils/urlValidator.js +169 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import sanitizeHtml from "sanitize-html";
|
|
2
|
+
import Joi from "joi";
|
|
3
|
+
import { createContextLogger } from "../utils/logger.js";
|
|
4
|
+
import {
|
|
5
|
+
createPost as createGhostPost,
|
|
6
|
+
getTags as getGhostTags,
|
|
7
|
+
createTag as createGhostTag,
|
|
8
|
+
// Import other necessary functions from ghostService later
|
|
9
|
+
} from "./ghostService.js"; // Note the relative path
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Helper to generate a simple meta description from HTML content.
|
|
13
|
+
* Uses sanitize-html to safely strip HTML tags and truncates.
|
|
14
|
+
* @param {string} htmlContent - The HTML content of the post.
|
|
15
|
+
* @param {number} maxLength - The maximum length of the description.
|
|
16
|
+
* @returns {string} A plain text truncated description.
|
|
17
|
+
*/
|
|
18
|
+
const generateSimpleMetaDescription = (htmlContent, maxLength = 500) => {
|
|
19
|
+
if (!htmlContent) return "";
|
|
20
|
+
|
|
21
|
+
// Use sanitize-html to safely remove all HTML tags
|
|
22
|
+
// This prevents ReDoS attacks and properly handles malformed HTML
|
|
23
|
+
const textContent = sanitizeHtml(htmlContent, {
|
|
24
|
+
allowedTags: [], // Remove all HTML tags
|
|
25
|
+
allowedAttributes: {},
|
|
26
|
+
textFilter: function(text) {
|
|
27
|
+
return text.replace(/\s\s+/g, ' ').trim();
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Truncate and add ellipsis if needed
|
|
32
|
+
return textContent.length > maxLength
|
|
33
|
+
? textContent.substring(0, maxLength - 3) + "..."
|
|
34
|
+
: textContent;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Service layer function to handle the business logic of creating a post.
|
|
39
|
+
* Transforms input data, handles/resolves tags, includes feature image and metadata.
|
|
40
|
+
* @param {object} postInput - Data received from the controller.
|
|
41
|
+
* @returns {Promise<object>} The created post object from the Ghost API.
|
|
42
|
+
*/
|
|
43
|
+
// Validation schema for post input
|
|
44
|
+
const postInputSchema = Joi.object({
|
|
45
|
+
title: Joi.string().max(255).required(),
|
|
46
|
+
html: Joi.string().required(),
|
|
47
|
+
custom_excerpt: Joi.string().max(500).optional(),
|
|
48
|
+
status: Joi.string().valid('draft', 'published', 'scheduled').optional(),
|
|
49
|
+
published_at: Joi.string().isoDate().optional(),
|
|
50
|
+
tags: Joi.array().items(Joi.string().max(50)).max(10).optional(),
|
|
51
|
+
feature_image: Joi.string().uri().optional(),
|
|
52
|
+
feature_image_alt: Joi.string().max(255).optional(),
|
|
53
|
+
feature_image_caption: Joi.string().max(500).optional(),
|
|
54
|
+
meta_title: Joi.string().max(70).optional(),
|
|
55
|
+
meta_description: Joi.string().max(160).optional()
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const createPostService = async (postInput) => {
|
|
59
|
+
const logger = createContextLogger('post-service');
|
|
60
|
+
|
|
61
|
+
// Validate input to prevent format string vulnerabilities
|
|
62
|
+
const { error, value: validatedInput } = postInputSchema.validate(postInput);
|
|
63
|
+
if (error) {
|
|
64
|
+
logger.error('Post input validation failed', {
|
|
65
|
+
error: error.details[0].message,
|
|
66
|
+
inputKeys: Object.keys(postInput)
|
|
67
|
+
});
|
|
68
|
+
throw new Error(`Invalid post input: ${error.details[0].message}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const {
|
|
72
|
+
title,
|
|
73
|
+
html,
|
|
74
|
+
custom_excerpt,
|
|
75
|
+
status,
|
|
76
|
+
published_at,
|
|
77
|
+
tags, // Expecting array of strings (tag names) here now
|
|
78
|
+
feature_image,
|
|
79
|
+
feature_image_alt,
|
|
80
|
+
feature_image_caption,
|
|
81
|
+
meta_title,
|
|
82
|
+
meta_description,
|
|
83
|
+
} = validatedInput;
|
|
84
|
+
|
|
85
|
+
// --- Resolve Tag Names to Tag Objects (ID/Slug/Name) ---
|
|
86
|
+
let resolvedTags = [];
|
|
87
|
+
if (tags && Array.isArray(tags) && tags.length > 0) {
|
|
88
|
+
logger.info('Resolving provided tag names', { tagCount: tags.length, tags });
|
|
89
|
+
resolvedTags = await Promise.all(
|
|
90
|
+
tags.map(async (tagName) => {
|
|
91
|
+
if (typeof tagName !== "string" || !tagName.trim()) {
|
|
92
|
+
logger.warn('Skipping invalid tag name', { tagName, type: typeof tagName });
|
|
93
|
+
return null; // Skip invalid entries
|
|
94
|
+
}
|
|
95
|
+
tagName = tagName.trim();
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
// Check if tag exists by name
|
|
99
|
+
const existingTags = await getGhostTags(tagName);
|
|
100
|
+
if (existingTags && existingTags.length > 0) {
|
|
101
|
+
logger.debug('Found existing tag', {
|
|
102
|
+
tagName,
|
|
103
|
+
tagId: existingTags[0].id
|
|
104
|
+
});
|
|
105
|
+
// Use the existing tag (Ghost usually accepts name, slug, or id)
|
|
106
|
+
return { name: tagName }; // Or { id: existingTags[0].id } or { slug: existingTags[0].slug }
|
|
107
|
+
} else {
|
|
108
|
+
// Tag doesn't exist, create it
|
|
109
|
+
logger.info('Creating new tag', { tagName });
|
|
110
|
+
const newTag = await createGhostTag({ name: tagName });
|
|
111
|
+
logger.info('Created new tag successfully', {
|
|
112
|
+
tagName,
|
|
113
|
+
tagId: newTag.id
|
|
114
|
+
});
|
|
115
|
+
// Use the new tag
|
|
116
|
+
return { name: tagName }; // Or { id: newTag.id }
|
|
117
|
+
}
|
|
118
|
+
} catch (tagError) {
|
|
119
|
+
logger.error('Error processing tag', {
|
|
120
|
+
tagName,
|
|
121
|
+
error: tagError.message
|
|
122
|
+
});
|
|
123
|
+
return null; // Skip tags that cause errors during processing
|
|
124
|
+
}
|
|
125
|
+
})
|
|
126
|
+
);
|
|
127
|
+
// Filter out any nulls from skipped/errored tags
|
|
128
|
+
resolvedTags = resolvedTags.filter((tag) => tag !== null);
|
|
129
|
+
logger.debug('Resolved tags for API', {
|
|
130
|
+
resolvedTagCount: resolvedTags.length,
|
|
131
|
+
resolvedTags
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
// --- End Tag Resolution ---
|
|
135
|
+
|
|
136
|
+
// --- Metadata Defaults/Generation ---
|
|
137
|
+
const finalMetaTitle = meta_title || title; // Default meta_title to title
|
|
138
|
+
const finalMetaDescription =
|
|
139
|
+
meta_description || custom_excerpt || generateSimpleMetaDescription(html); // Default meta_description
|
|
140
|
+
// Ensure description does not exceed limit even after defaulting
|
|
141
|
+
const truncatedMetaDescription =
|
|
142
|
+
finalMetaDescription.length > 500
|
|
143
|
+
? finalMetaDescription.substring(0, 497) + "..."
|
|
144
|
+
: finalMetaDescription;
|
|
145
|
+
// --- End Metadata Defaults ---
|
|
146
|
+
|
|
147
|
+
// Prepare data for the Ghost API, including feature image fields
|
|
148
|
+
const postDataForApi = {
|
|
149
|
+
title,
|
|
150
|
+
html,
|
|
151
|
+
custom_excerpt,
|
|
152
|
+
status: status || "draft", // Default to draft
|
|
153
|
+
published_at,
|
|
154
|
+
tags: resolvedTags,
|
|
155
|
+
feature_image,
|
|
156
|
+
feature_image_alt,
|
|
157
|
+
feature_image_caption,
|
|
158
|
+
meta_title: finalMetaTitle, // Use final value
|
|
159
|
+
meta_description: truncatedMetaDescription, // Use final, truncated value
|
|
160
|
+
// Add metadata fields here if needed in the future
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
logger.info('Creating Ghost post', {
|
|
164
|
+
title: postDataForApi.title,
|
|
165
|
+
status: postDataForApi.status,
|
|
166
|
+
tagCount: postDataForApi.tags?.length || 0,
|
|
167
|
+
hasFeatureImage: !!postDataForApi.feature_image
|
|
168
|
+
});
|
|
169
|
+
// Call the lower-level ghostService function
|
|
170
|
+
const newPost = await createGhostPost(postDataForApi);
|
|
171
|
+
return newPost;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
export { createPostService };
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import winston from 'winston';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
|
|
5
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
6
|
+
const __dirname = path.dirname(__filename);
|
|
7
|
+
|
|
8
|
+
// Get log level from environment or default to 'info'
|
|
9
|
+
const logLevel = process.env.LOG_LEVEL || 'info';
|
|
10
|
+
const isDevelopment = process.env.NODE_ENV === 'development';
|
|
11
|
+
|
|
12
|
+
// Define custom log format
|
|
13
|
+
const logFormat = winston.format.combine(
|
|
14
|
+
winston.format.timestamp({
|
|
15
|
+
format: 'YYYY-MM-DD HH:mm:ss'
|
|
16
|
+
}),
|
|
17
|
+
winston.format.errors({ stack: true }),
|
|
18
|
+
winston.format.json(),
|
|
19
|
+
winston.format.metadata({ fillExcept: ['message', 'level', 'timestamp'] })
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
// Development format (more readable)
|
|
23
|
+
const devFormat = winston.format.combine(
|
|
24
|
+
winston.format.timestamp({
|
|
25
|
+
format: 'HH:mm:ss'
|
|
26
|
+
}),
|
|
27
|
+
winston.format.colorize(),
|
|
28
|
+
winston.format.printf(({ level, message, timestamp, metadata }) => {
|
|
29
|
+
let metaString = '';
|
|
30
|
+
if (metadata && Object.keys(metadata).length > 0) {
|
|
31
|
+
metaString = ` ${JSON.stringify(metadata)}`;
|
|
32
|
+
}
|
|
33
|
+
return `${timestamp} [${level}] ${message}${metaString}`;
|
|
34
|
+
})
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
// Create the logger instance
|
|
38
|
+
const logger = winston.createLogger({
|
|
39
|
+
level: logLevel,
|
|
40
|
+
format: isDevelopment ? devFormat : logFormat,
|
|
41
|
+
defaultMeta: {
|
|
42
|
+
service: 'ghost-mcp-server',
|
|
43
|
+
pid: process.pid
|
|
44
|
+
},
|
|
45
|
+
transports: [
|
|
46
|
+
// Console output
|
|
47
|
+
new winston.transports.Console({
|
|
48
|
+
level: isDevelopment ? 'debug' : 'info'
|
|
49
|
+
})
|
|
50
|
+
],
|
|
51
|
+
// Handle uncaught exceptions
|
|
52
|
+
exceptionHandlers: [
|
|
53
|
+
new winston.transports.Console()
|
|
54
|
+
],
|
|
55
|
+
// Handle unhandled promise rejections
|
|
56
|
+
rejectionHandlers: [
|
|
57
|
+
new winston.transports.Console()
|
|
58
|
+
]
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Add file logging in production
|
|
62
|
+
if (!isDevelopment) {
|
|
63
|
+
const logDir = path.join(__dirname, '../../logs');
|
|
64
|
+
|
|
65
|
+
// Create logs directory if it doesn't exist
|
|
66
|
+
import('fs').then(fs => {
|
|
67
|
+
if (!fs.existsSync(logDir)) {
|
|
68
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Add file transports for production
|
|
73
|
+
logger.add(new winston.transports.File({
|
|
74
|
+
filename: path.join(logDir, 'error.log'),
|
|
75
|
+
level: 'error',
|
|
76
|
+
maxsize: 10 * 1024 * 1024, // 10MB
|
|
77
|
+
maxFiles: 5,
|
|
78
|
+
tailable: true
|
|
79
|
+
}));
|
|
80
|
+
|
|
81
|
+
logger.add(new winston.transports.File({
|
|
82
|
+
filename: path.join(logDir, 'combined.log'),
|
|
83
|
+
maxsize: 10 * 1024 * 1024, // 10MB
|
|
84
|
+
maxFiles: 10,
|
|
85
|
+
tailable: true
|
|
86
|
+
}));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Create helper functions for common logging patterns
|
|
90
|
+
const createContextLogger = (context) => {
|
|
91
|
+
return {
|
|
92
|
+
debug: (message, meta = {}) => logger.debug(message, { ...meta, context }),
|
|
93
|
+
info: (message, meta = {}) => logger.info(message, { ...meta, context }),
|
|
94
|
+
warn: (message, meta = {}) => logger.warn(message, { ...meta, context }),
|
|
95
|
+
error: (message, meta = {}) => logger.error(message, { ...meta, context }),
|
|
96
|
+
|
|
97
|
+
// Convenience methods for common patterns
|
|
98
|
+
apiRequest: (method, url, meta = {}) =>
|
|
99
|
+
logger.info(`${method} ${url}`, { ...meta, context, type: 'api_request' }),
|
|
100
|
+
|
|
101
|
+
apiResponse: (method, url, status, meta = {}) =>
|
|
102
|
+
logger.info(`${method} ${url} -> ${status}`, { ...meta, context, type: 'api_response' }),
|
|
103
|
+
|
|
104
|
+
apiError: (method, url, error, meta = {}) =>
|
|
105
|
+
logger.error(`${method} ${url} failed`, {
|
|
106
|
+
...meta,
|
|
107
|
+
context,
|
|
108
|
+
type: 'api_error',
|
|
109
|
+
error: error.message,
|
|
110
|
+
stack: error.stack
|
|
111
|
+
}),
|
|
112
|
+
|
|
113
|
+
toolExecution: (toolName, input, meta = {}) =>
|
|
114
|
+
logger.info(`Executing tool: ${toolName}`, {
|
|
115
|
+
...meta,
|
|
116
|
+
context,
|
|
117
|
+
type: 'tool_execution',
|
|
118
|
+
tool: toolName,
|
|
119
|
+
inputKeys: Object.keys(input || {})
|
|
120
|
+
}),
|
|
121
|
+
|
|
122
|
+
toolSuccess: (toolName, result, meta = {}) =>
|
|
123
|
+
logger.info(`Tool ${toolName} completed successfully`, {
|
|
124
|
+
...meta,
|
|
125
|
+
context,
|
|
126
|
+
type: 'tool_success',
|
|
127
|
+
tool: toolName,
|
|
128
|
+
resultType: typeof result
|
|
129
|
+
}),
|
|
130
|
+
|
|
131
|
+
toolError: (toolName, error, meta = {}) =>
|
|
132
|
+
logger.error(`Tool ${toolName} failed`, {
|
|
133
|
+
...meta,
|
|
134
|
+
context,
|
|
135
|
+
type: 'tool_error',
|
|
136
|
+
tool: toolName,
|
|
137
|
+
error: error.message,
|
|
138
|
+
stack: error.stack
|
|
139
|
+
}),
|
|
140
|
+
|
|
141
|
+
fileOperation: (operation, filePath, meta = {}) =>
|
|
142
|
+
logger.debug(`File operation: ${operation}`, {
|
|
143
|
+
...meta,
|
|
144
|
+
context,
|
|
145
|
+
type: 'file_operation',
|
|
146
|
+
operation,
|
|
147
|
+
file: path.basename(filePath)
|
|
148
|
+
})
|
|
149
|
+
};
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
export default logger;
|
|
153
|
+
export { createContextLogger };
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import Joi from 'joi';
|
|
2
|
+
import { URL } from 'url';
|
|
3
|
+
|
|
4
|
+
// Configure allowed domains for image downloads
|
|
5
|
+
const ALLOWED_DOMAINS = [
|
|
6
|
+
// Common CDNs and image hosting services
|
|
7
|
+
'imgur.com',
|
|
8
|
+
'i.imgur.com',
|
|
9
|
+
'github.com',
|
|
10
|
+
'githubusercontent.com',
|
|
11
|
+
'unsplash.com',
|
|
12
|
+
'images.unsplash.com',
|
|
13
|
+
'cloudinary.com',
|
|
14
|
+
'res.cloudinary.com',
|
|
15
|
+
'amazonaws.com',
|
|
16
|
+
's3.amazonaws.com',
|
|
17
|
+
'googleusercontent.com',
|
|
18
|
+
'gravatar.com',
|
|
19
|
+
'secure.gravatar.com',
|
|
20
|
+
'wp.com',
|
|
21
|
+
'wordpress.com',
|
|
22
|
+
'flickr.com',
|
|
23
|
+
'staticflickr.com',
|
|
24
|
+
'dropbox.com',
|
|
25
|
+
'dl.dropboxusercontent.com',
|
|
26
|
+
'pexels.com',
|
|
27
|
+
'images.pexels.com',
|
|
28
|
+
'pixabay.com',
|
|
29
|
+
'cdn.pixabay.com'
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
// Private/internal IP ranges to block
|
|
33
|
+
const BLOCKED_IP_PATTERNS = [
|
|
34
|
+
/^127\./, // 127.0.0.0/8 - localhost
|
|
35
|
+
/^10\./, // 10.0.0.0/8 - private
|
|
36
|
+
/^172\.(1[6-9]|2[0-9]|3[0-1])\./, // 172.16.0.0/12 - private
|
|
37
|
+
/^192\.168\./, // 192.168.0.0/16 - private
|
|
38
|
+
/^169\.254\./, // 169.254.0.0/16 - link local
|
|
39
|
+
/^0\./, // 0.0.0.0/8
|
|
40
|
+
/^224\./, // 224.0.0.0/4 - multicast
|
|
41
|
+
/^240\./, // 240.0.0.0/4 - reserved
|
|
42
|
+
/^255\.255\.255\.255$/, // broadcast
|
|
43
|
+
/^::1$/, // IPv6 localhost
|
|
44
|
+
/^::/, // IPv6 unspecified
|
|
45
|
+
/^fc00:/, // IPv6 unique local
|
|
46
|
+
/^fe80:/, // IPv6 link local
|
|
47
|
+
/^ff00:/ // IPv6 multicast
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Validates if a hostname/IP is safe for external requests
|
|
52
|
+
* @param {string} hostname - The hostname or IP to validate
|
|
53
|
+
* @returns {boolean} True if safe, false if blocked
|
|
54
|
+
*/
|
|
55
|
+
const isSafeHost = (hostname) => {
|
|
56
|
+
// Check if hostname is an IP address
|
|
57
|
+
const ipv4Pattern = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/;
|
|
58
|
+
const ipv6Pattern = /^[0-9a-f:]+$/i;
|
|
59
|
+
|
|
60
|
+
if (ipv4Pattern.test(hostname) || ipv6Pattern.test(hostname)) {
|
|
61
|
+
// Check against blocked IP patterns
|
|
62
|
+
return !BLOCKED_IP_PATTERNS.some(pattern => pattern.test(hostname));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// For domain names, check against allowlist
|
|
66
|
+
const normalizedHost = hostname.toLowerCase();
|
|
67
|
+
return ALLOWED_DOMAINS.some(allowed =>
|
|
68
|
+
normalizedHost === allowed || normalizedHost.endsWith('.' + allowed)
|
|
69
|
+
);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Validates and sanitizes a URL for safe external requests
|
|
74
|
+
* @param {string} url - The URL to validate
|
|
75
|
+
* @returns {object} Validation result with isValid boolean and sanitizedUrl
|
|
76
|
+
*/
|
|
77
|
+
const validateImageUrl = (url) => {
|
|
78
|
+
try {
|
|
79
|
+
// Basic URL validation with Joi
|
|
80
|
+
const urlSchema = Joi.string().uri({
|
|
81
|
+
scheme: ['http', 'https'],
|
|
82
|
+
allowRelative: false
|
|
83
|
+
}).required();
|
|
84
|
+
|
|
85
|
+
const validation = urlSchema.validate(url);
|
|
86
|
+
if (validation.error) {
|
|
87
|
+
return {
|
|
88
|
+
isValid: false,
|
|
89
|
+
error: `Invalid URL format: ${validation.error.details[0].message}`
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Parse URL for additional security checks
|
|
94
|
+
const parsedUrl = new URL(url);
|
|
95
|
+
|
|
96
|
+
// Only allow HTTP/HTTPS
|
|
97
|
+
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
|
|
98
|
+
return {
|
|
99
|
+
isValid: false,
|
|
100
|
+
error: 'Only HTTP and HTTPS protocols are allowed'
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Check if host is safe (not internal/private)
|
|
105
|
+
if (!isSafeHost(parsedUrl.hostname)) {
|
|
106
|
+
return {
|
|
107
|
+
isValid: false,
|
|
108
|
+
error: `Requests to ${parsedUrl.hostname} are not allowed for security reasons`
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Prevent requests to non-standard ports (common in SSRF attacks)
|
|
113
|
+
const port = parsedUrl.port;
|
|
114
|
+
if (port && !['80', '443', '8080', '8443'].includes(port)) {
|
|
115
|
+
return {
|
|
116
|
+
isValid: false,
|
|
117
|
+
error: `Requests to non-standard port ${port} are not allowed`
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Additional checks for suspicious patterns
|
|
122
|
+
if (parsedUrl.hostname.includes('localhost') ||
|
|
123
|
+
parsedUrl.hostname === '0.0.0.0' ||
|
|
124
|
+
parsedUrl.hostname.startsWith('192.168.') ||
|
|
125
|
+
parsedUrl.hostname.startsWith('10.') ||
|
|
126
|
+
parsedUrl.hostname.includes('.local')) {
|
|
127
|
+
return {
|
|
128
|
+
isValid: false,
|
|
129
|
+
error: 'Requests to local/private addresses are not allowed'
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
isValid: true,
|
|
135
|
+
sanitizedUrl: parsedUrl.href
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
} catch (error) {
|
|
139
|
+
return {
|
|
140
|
+
isValid: false,
|
|
141
|
+
error: `URL parsing failed: ${error.message}`
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Configures axios with security settings for external requests
|
|
148
|
+
* @param {string} url - The validated URL to request
|
|
149
|
+
* @returns {object} Axios configuration with security settings
|
|
150
|
+
*/
|
|
151
|
+
const createSecureAxiosConfig = (url) => {
|
|
152
|
+
return {
|
|
153
|
+
url,
|
|
154
|
+
responseType: 'stream',
|
|
155
|
+
timeout: 10000, // 10 second timeout
|
|
156
|
+
maxRedirects: 3, // Limit redirects
|
|
157
|
+
maxContentLength: 50 * 1024 * 1024, // 50MB max response
|
|
158
|
+
validateStatus: (status) => status >= 200 && status < 300, // Only accept 2xx
|
|
159
|
+
headers: {
|
|
160
|
+
'User-Agent': 'Ghost-MCP-Server/1.0'
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
export {
|
|
166
|
+
validateImageUrl,
|
|
167
|
+
createSecureAxiosConfig,
|
|
168
|
+
ALLOWED_DOMAINS
|
|
169
|
+
};
|