@jgardner04/ghost-mcp-server 1.1.5 → 1.1.7
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
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Creates a mock Ghost Admin API instance with configurable behavior.
|
|
5
|
+
*
|
|
6
|
+
* @param {Object} options - Configuration options for the mock
|
|
7
|
+
* @param {Object} options.posts - Mock implementations for posts methods
|
|
8
|
+
* @param {Object} options.tags - Mock implementations for tags methods
|
|
9
|
+
* @param {Object} options.site - Mock implementations for site methods
|
|
10
|
+
* @param {Object} options.images - Mock implementations for images methods
|
|
11
|
+
* @returns {Object} Mock Ghost Admin API instance
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* import { createMockGhostApi } from '../helpers/mockGhostApi.js';
|
|
15
|
+
*
|
|
16
|
+
* const api = createMockGhostApi({
|
|
17
|
+
* posts: {
|
|
18
|
+
* add: vi.fn().mockResolvedValue({ id: '1', title: 'Test Post' }),
|
|
19
|
+
* },
|
|
20
|
+
* tags: {
|
|
21
|
+
* browse: vi.fn().mockResolvedValue([{ id: '1', name: 'Test Tag' }]),
|
|
22
|
+
* },
|
|
23
|
+
* });
|
|
24
|
+
*/
|
|
25
|
+
export function createMockGhostApi(options = {}) {
|
|
26
|
+
return {
|
|
27
|
+
posts: {
|
|
28
|
+
add: vi.fn(),
|
|
29
|
+
browse: vi.fn(),
|
|
30
|
+
read: vi.fn(),
|
|
31
|
+
edit: vi.fn(),
|
|
32
|
+
delete: vi.fn(),
|
|
33
|
+
...options.posts,
|
|
34
|
+
},
|
|
35
|
+
tags: {
|
|
36
|
+
add: vi.fn(),
|
|
37
|
+
browse: vi.fn(),
|
|
38
|
+
read: vi.fn(),
|
|
39
|
+
edit: vi.fn(),
|
|
40
|
+
delete: vi.fn(),
|
|
41
|
+
...options.tags,
|
|
42
|
+
},
|
|
43
|
+
site: {
|
|
44
|
+
read: vi.fn(),
|
|
45
|
+
...options.site,
|
|
46
|
+
},
|
|
47
|
+
images: {
|
|
48
|
+
upload: vi.fn(),
|
|
49
|
+
...options.images,
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Creates a mock Ghost Admin API constructor for use with vi.mock().
|
|
56
|
+
*
|
|
57
|
+
* @param {Object} defaultOptions - Default configuration for all instances
|
|
58
|
+
* @returns {Function} Mock constructor function
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* import { createMockGhostApiConstructor } from '../helpers/mockGhostApi.js';
|
|
62
|
+
*
|
|
63
|
+
* vi.mock('@tryghost/admin-api', () => ({
|
|
64
|
+
* default: createMockGhostApiConstructor({
|
|
65
|
+
* posts: {
|
|
66
|
+
* add: vi.fn().mockResolvedValue({ id: '1', title: 'Test Post' }),
|
|
67
|
+
* },
|
|
68
|
+
* }),
|
|
69
|
+
* }));
|
|
70
|
+
*/
|
|
71
|
+
export function createMockGhostApiConstructor(defaultOptions = {}) {
|
|
72
|
+
return vi.fn(function () {
|
|
73
|
+
return createMockGhostApi(defaultOptions);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Creates a default mock Ghost Admin API module for vi.mock().
|
|
79
|
+
*
|
|
80
|
+
* @param {Object} options - Configuration options for the mock API
|
|
81
|
+
* @returns {Object} Mock module with default export
|
|
82
|
+
*
|
|
83
|
+
* @example
|
|
84
|
+
* import { mockGhostApiModule } from '../helpers/mockGhostApi.js';
|
|
85
|
+
*
|
|
86
|
+
* vi.mock('@tryghost/admin-api', () => mockGhostApiModule());
|
|
87
|
+
*/
|
|
88
|
+
export function mockGhostApiModule(options = {}) {
|
|
89
|
+
return {
|
|
90
|
+
default: createMockGhostApiConstructor(options),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Creates a mock logger instance with spy functions for testing.
|
|
5
|
+
*
|
|
6
|
+
* @returns {Object} Mock logger with common logging methods
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* import { createMockLogger } from '../helpers/mockLogger.js';
|
|
10
|
+
*
|
|
11
|
+
* const logger = createMockLogger();
|
|
12
|
+
* logger.info('test message');
|
|
13
|
+
* expect(logger.info).toHaveBeenCalledWith('test message');
|
|
14
|
+
*/
|
|
15
|
+
export function createMockLogger() {
|
|
16
|
+
return {
|
|
17
|
+
apiRequest: vi.fn(),
|
|
18
|
+
apiResponse: vi.fn(),
|
|
19
|
+
apiError: vi.fn(),
|
|
20
|
+
warn: vi.fn(),
|
|
21
|
+
error: vi.fn(),
|
|
22
|
+
info: vi.fn(),
|
|
23
|
+
debug: vi.fn(),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Creates a mock context logger factory that returns a mock logger.
|
|
29
|
+
*
|
|
30
|
+
* @returns {Function} Mock createContextLogger function
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* import { createMockContextLogger } from '../helpers/mockLogger.js';
|
|
34
|
+
*
|
|
35
|
+
* vi.mock('../../utils/logger.js', () => ({
|
|
36
|
+
* createContextLogger: createMockContextLogger(),
|
|
37
|
+
* }));
|
|
38
|
+
*/
|
|
39
|
+
export function createMockContextLogger() {
|
|
40
|
+
return vi.fn(() => createMockLogger());
|
|
41
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Creates a mock environment variable configuration.
|
|
5
|
+
*
|
|
6
|
+
* @param {Object} env - Environment variables to set
|
|
7
|
+
* @returns {Object} Mock dotenv module
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* import { mockEnv } from '../helpers/testUtils.js';
|
|
11
|
+
*
|
|
12
|
+
* vi.mock('dotenv', () => mockEnv({
|
|
13
|
+
* GHOST_ADMIN_API_URL: 'https://test.ghost.io',
|
|
14
|
+
* GHOST_ADMIN_API_KEY: 'test-key',
|
|
15
|
+
* }));
|
|
16
|
+
*/
|
|
17
|
+
export function mockEnv(env = {}) {
|
|
18
|
+
// Set environment variables
|
|
19
|
+
Object.entries(env).forEach(([key, value]) => {
|
|
20
|
+
process.env[key] = value;
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
default: {
|
|
25
|
+
config: vi.fn(),
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Cleans up environment variables after tests.
|
|
32
|
+
*
|
|
33
|
+
* @param {string[]} keys - Array of environment variable keys to remove
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* import { cleanupEnv } from '../helpers/testUtils.js';
|
|
37
|
+
*
|
|
38
|
+
* afterEach(() => {
|
|
39
|
+
* cleanupEnv(['GHOST_ADMIN_API_URL', 'GHOST_ADMIN_API_KEY']);
|
|
40
|
+
* });
|
|
41
|
+
*/
|
|
42
|
+
export function cleanupEnv(keys) {
|
|
43
|
+
keys.forEach((key) => {
|
|
44
|
+
delete process.env[key];
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Creates a mock dotenv module for use with vi.mock().
|
|
50
|
+
*
|
|
51
|
+
* @returns {Object} Mock dotenv module
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* import { mockDotenv } from '../helpers/testUtils.js';
|
|
55
|
+
*
|
|
56
|
+
* vi.mock('dotenv', () => mockDotenv());
|
|
57
|
+
*/
|
|
58
|
+
export function mockDotenv() {
|
|
59
|
+
return {
|
|
60
|
+
default: {
|
|
61
|
+
config: vi.fn(),
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Waits for a condition to be true or times out.
|
|
68
|
+
*
|
|
69
|
+
* @param {Function} condition - Function that returns true when condition is met
|
|
70
|
+
* @param {number} timeout - Maximum time to wait in milliseconds
|
|
71
|
+
* @param {number} interval - Check interval in milliseconds
|
|
72
|
+
* @returns {Promise<boolean>} True if condition met, false if timeout
|
|
73
|
+
*
|
|
74
|
+
* @example
|
|
75
|
+
* import { waitFor } from '../helpers/testUtils.js';
|
|
76
|
+
*
|
|
77
|
+
* await waitFor(() => mockFn.mock.calls.length > 0, 1000, 100);
|
|
78
|
+
*/
|
|
79
|
+
export async function waitFor(condition, timeout = 5000, interval = 100) {
|
|
80
|
+
const startTime = Date.now();
|
|
81
|
+
|
|
82
|
+
while (Date.now() - startTime < timeout) {
|
|
83
|
+
if (condition()) {
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
await new Promise((resolve) => setTimeout(resolve, interval));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Creates a promise that resolves after a specified delay.
|
|
94
|
+
*
|
|
95
|
+
* @param {number} ms - Milliseconds to delay
|
|
96
|
+
* @returns {Promise<void>}
|
|
97
|
+
*
|
|
98
|
+
* @example
|
|
99
|
+
* import { delay } from '../helpers/testUtils.js';
|
|
100
|
+
*
|
|
101
|
+
* await delay(1000); // Wait 1 second
|
|
102
|
+
*/
|
|
103
|
+
export function delay(ms) {
|
|
104
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
105
|
+
}
|
|
@@ -1,46 +1,17 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { mockGhostApiModule } from '../../__tests__/helpers/mockGhostApi.js';
|
|
3
|
+
import { createMockContextLogger } from '../../__tests__/helpers/mockLogger.js';
|
|
4
|
+
import { mockDotenv } from '../../__tests__/helpers/testUtils.js';
|
|
2
5
|
|
|
3
6
|
// Mock the Ghost Admin API
|
|
4
|
-
vi.mock('@tryghost/admin-api', () =>
|
|
5
|
-
const GhostAdminAPI = vi.fn(function () {
|
|
6
|
-
return {
|
|
7
|
-
posts: {
|
|
8
|
-
add: vi.fn(),
|
|
9
|
-
},
|
|
10
|
-
tags: {
|
|
11
|
-
add: vi.fn(),
|
|
12
|
-
browse: vi.fn(),
|
|
13
|
-
},
|
|
14
|
-
site: {
|
|
15
|
-
read: vi.fn(),
|
|
16
|
-
},
|
|
17
|
-
images: {
|
|
18
|
-
upload: vi.fn(),
|
|
19
|
-
},
|
|
20
|
-
};
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
return {
|
|
24
|
-
default: GhostAdminAPI,
|
|
25
|
-
};
|
|
26
|
-
});
|
|
7
|
+
vi.mock('@tryghost/admin-api', () => mockGhostApiModule());
|
|
27
8
|
|
|
28
9
|
// Mock dotenv
|
|
29
|
-
vi.mock('dotenv', () => (
|
|
30
|
-
default: {
|
|
31
|
-
config: vi.fn(),
|
|
32
|
-
},
|
|
33
|
-
}));
|
|
10
|
+
vi.mock('dotenv', () => mockDotenv());
|
|
34
11
|
|
|
35
12
|
// Mock logger
|
|
36
13
|
vi.mock('../../utils/logger.js', () => ({
|
|
37
|
-
createContextLogger:
|
|
38
|
-
apiRequest: vi.fn(),
|
|
39
|
-
apiResponse: vi.fn(),
|
|
40
|
-
apiError: vi.fn(),
|
|
41
|
-
warn: vi.fn(),
|
|
42
|
-
error: vi.fn(),
|
|
43
|
-
})),
|
|
14
|
+
createContextLogger: createMockContextLogger(),
|
|
44
15
|
}));
|
|
45
16
|
|
|
46
17
|
// Import after setting up mocks and environment
|
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { validateImageUrl, createSecureAxiosConfig, ALLOWED_DOMAINS } from '../urlValidator.js';
|
|
3
|
+
|
|
4
|
+
describe('urlValidator', () => {
|
|
5
|
+
describe('ALLOWED_DOMAINS', () => {
|
|
6
|
+
it('should export an array of allowed domains', () => {
|
|
7
|
+
expect(ALLOWED_DOMAINS).toBeInstanceOf(Array);
|
|
8
|
+
expect(ALLOWED_DOMAINS.length).toBeGreaterThan(0);
|
|
9
|
+
expect(ALLOWED_DOMAINS).toContain('imgur.com');
|
|
10
|
+
expect(ALLOWED_DOMAINS).toContain('github.com');
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe('isSafeHost (tested via validateImageUrl)', () => {
|
|
15
|
+
describe('allowed domains', () => {
|
|
16
|
+
it('should allow imgur.com', () => {
|
|
17
|
+
const result = validateImageUrl('https://imgur.com/image.jpg');
|
|
18
|
+
expect(result.isValid).toBe(true);
|
|
19
|
+
expect(result.sanitizedUrl).toBe('https://imgur.com/image.jpg');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should allow i.imgur.com subdomain', () => {
|
|
23
|
+
const result = validateImageUrl('https://i.imgur.com/abc123.jpg');
|
|
24
|
+
expect(result.isValid).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should allow github.com', () => {
|
|
28
|
+
const result = validateImageUrl('https://github.com/user/repo/image.png');
|
|
29
|
+
expect(result.isValid).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should allow githubusercontent.com', () => {
|
|
33
|
+
const result = validateImageUrl('https://githubusercontent.com/image.jpg');
|
|
34
|
+
expect(result.isValid).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should allow unsplash.com', () => {
|
|
38
|
+
const result = validateImageUrl('https://unsplash.com/photo.jpg');
|
|
39
|
+
expect(result.isValid).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should allow images.unsplash.com subdomain', () => {
|
|
43
|
+
const result = validateImageUrl('https://images.unsplash.com/photo-123');
|
|
44
|
+
expect(result.isValid).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should allow cloudinary.com', () => {
|
|
48
|
+
const result = validateImageUrl('https://cloudinary.com/image.jpg');
|
|
49
|
+
expect(result.isValid).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should allow res.cloudinary.com subdomain', () => {
|
|
53
|
+
const result = validateImageUrl('https://res.cloudinary.com/demo/image.jpg');
|
|
54
|
+
expect(result.isValid).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should allow amazonaws.com', () => {
|
|
58
|
+
const result = validateImageUrl('https://amazonaws.com/bucket/image.jpg');
|
|
59
|
+
expect(result.isValid).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should allow s3.amazonaws.com subdomain', () => {
|
|
63
|
+
const result = validateImageUrl('https://s3.amazonaws.com/bucket/image.jpg');
|
|
64
|
+
expect(result.isValid).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should allow deep subdomains of allowed domains', () => {
|
|
68
|
+
const result = validateImageUrl('https://cdn.images.unsplash.com/photo.jpg');
|
|
69
|
+
expect(result.isValid).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('blocked IP patterns - IPv4 localhost and private ranges', () => {
|
|
74
|
+
it('should block 127.0.0.1 (localhost)', () => {
|
|
75
|
+
const result = validateImageUrl('https://127.0.0.1/image.jpg');
|
|
76
|
+
expect(result.isValid).toBe(false);
|
|
77
|
+
expect(result.error).toContain('not allowed for security reasons');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('should block 127.0.0.2 (localhost range)', () => {
|
|
81
|
+
const result = validateImageUrl('https://127.0.0.2/image.jpg');
|
|
82
|
+
expect(result.isValid).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should block 127.255.255.255 (localhost range edge)', () => {
|
|
86
|
+
const result = validateImageUrl('https://127.255.255.255/image.jpg');
|
|
87
|
+
expect(result.isValid).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should block 10.0.0.1 (private network)', () => {
|
|
91
|
+
const result = validateImageUrl('https://10.0.0.1/image.jpg');
|
|
92
|
+
expect(result.isValid).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should block 10.255.255.255 (private network edge)', () => {
|
|
96
|
+
const result = validateImageUrl('https://10.255.255.255/image.jpg');
|
|
97
|
+
expect(result.isValid).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('should block 192.168.0.1 (private network)', () => {
|
|
101
|
+
const result = validateImageUrl('https://192.168.0.1/image.jpg');
|
|
102
|
+
expect(result.isValid).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('should block 192.168.255.255 (private network edge)', () => {
|
|
106
|
+
const result = validateImageUrl('https://192.168.255.255/image.jpg');
|
|
107
|
+
expect(result.isValid).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('should block 172.16.0.1 (private network)', () => {
|
|
111
|
+
const result = validateImageUrl('https://172.16.0.1/image.jpg');
|
|
112
|
+
expect(result.isValid).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('should block 172.31.255.255 (private network edge)', () => {
|
|
116
|
+
const result = validateImageUrl('https://172.31.255.255/image.jpg');
|
|
117
|
+
expect(result.isValid).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('should block 169.254.0.1 (link local)', () => {
|
|
121
|
+
const result = validateImageUrl('https://169.254.0.1/image.jpg');
|
|
122
|
+
expect(result.isValid).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('should block 0.0.0.0', () => {
|
|
126
|
+
const result = validateImageUrl('https://0.0.0.0/image.jpg');
|
|
127
|
+
expect(result.isValid).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('should block 224.0.0.1 (multicast)', () => {
|
|
131
|
+
const result = validateImageUrl('https://224.0.0.1/image.jpg');
|
|
132
|
+
expect(result.isValid).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('should block 240.0.0.1 (reserved)', () => {
|
|
136
|
+
const result = validateImageUrl('https://240.0.0.1/image.jpg');
|
|
137
|
+
expect(result.isValid).toBe(false);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('should block 255.255.255.255 (broadcast)', () => {
|
|
141
|
+
const result = validateImageUrl('https://255.255.255.255/image.jpg');
|
|
142
|
+
expect(result.isValid).toBe(false);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe('blocked IP patterns - IPv6', () => {
|
|
147
|
+
it('should block ::1 (IPv6 localhost)', () => {
|
|
148
|
+
const result = validateImageUrl('https://[::1]/image.jpg');
|
|
149
|
+
expect(result.isValid).toBe(false);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('should block :: (IPv6 unspecified)', () => {
|
|
153
|
+
const result = validateImageUrl('https://[::]/image.jpg');
|
|
154
|
+
expect(result.isValid).toBe(false);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('should block fc00:: (IPv6 unique local)', () => {
|
|
158
|
+
const result = validateImageUrl('https://[fc00::1]/image.jpg');
|
|
159
|
+
expect(result.isValid).toBe(false);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('should block fe80:: (IPv6 link local)', () => {
|
|
163
|
+
const result = validateImageUrl('https://[fe80::1]/image.jpg');
|
|
164
|
+
expect(result.isValid).toBe(false);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('should block ff00:: (IPv6 multicast)', () => {
|
|
168
|
+
const result = validateImageUrl('https://[ff00::1]/image.jpg');
|
|
169
|
+
expect(result.isValid).toBe(false);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe('subdomain matching', () => {
|
|
174
|
+
it('should allow exact domain match', () => {
|
|
175
|
+
const result = validateImageUrl('https://imgur.com/image.jpg');
|
|
176
|
+
expect(result.isValid).toBe(true);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('should allow subdomain with dot prefix', () => {
|
|
180
|
+
const result = validateImageUrl('https://i.imgur.com/image.jpg');
|
|
181
|
+
expect(result.isValid).toBe(true);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('should allow multi-level subdomains', () => {
|
|
185
|
+
const result = validateImageUrl('https://cdn.images.imgur.com/image.jpg');
|
|
186
|
+
expect(result.isValid).toBe(true);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('should reject domain that partially matches but is not a subdomain', () => {
|
|
190
|
+
const result = validateImageUrl('https://fakeimgur.com/image.jpg');
|
|
191
|
+
expect(result.isValid).toBe(false);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('should reject domain with allowed domain as substring', () => {
|
|
195
|
+
const result = validateImageUrl('https://notimgur.com/image.jpg');
|
|
196
|
+
expect(result.isValid).toBe(false);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe('hostname string checks for localhost/internal', () => {
|
|
201
|
+
it('should block localhost hostname', () => {
|
|
202
|
+
const result = validateImageUrl('https://localhost/image.jpg');
|
|
203
|
+
expect(result.isValid).toBe(false);
|
|
204
|
+
expect(result.error).toContain('not allowed for security reasons');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('should block localhost with port', () => {
|
|
208
|
+
const result = validateImageUrl('https://localhost:3000/image.jpg');
|
|
209
|
+
expect(result.isValid).toBe(false);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('should block 0.0.0.0 hostname', () => {
|
|
213
|
+
const result = validateImageUrl('https://0.0.0.0/image.jpg');
|
|
214
|
+
expect(result.isValid).toBe(false);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('should block 192.168.x.x hostname pattern', () => {
|
|
218
|
+
const result = validateImageUrl('https://192.168.1.1/image.jpg');
|
|
219
|
+
expect(result.isValid).toBe(false);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('should block 10.x.x.x hostname pattern', () => {
|
|
223
|
+
const result = validateImageUrl('https://10.1.1.1/image.jpg');
|
|
224
|
+
expect(result.isValid).toBe(false);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('should block .local domains', () => {
|
|
228
|
+
const result = validateImageUrl('https://myserver.local/image.jpg');
|
|
229
|
+
expect(result.isValid).toBe(false);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
describe('validateImageUrl', () => {
|
|
235
|
+
describe('valid HTTPS URLs', () => {
|
|
236
|
+
it('should accept valid HTTPS URL from allowed domain', () => {
|
|
237
|
+
const result = validateImageUrl('https://imgur.com/abc123.jpg');
|
|
238
|
+
expect(result.isValid).toBe(true);
|
|
239
|
+
expect(result.sanitizedUrl).toBe('https://imgur.com/abc123.jpg');
|
|
240
|
+
expect(result.error).toBeUndefined();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('should accept HTTPS URL with query parameters', () => {
|
|
244
|
+
const result = validateImageUrl('https://imgur.com/image.jpg?size=large');
|
|
245
|
+
expect(result.isValid).toBe(true);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('should accept HTTPS URL with fragment', () => {
|
|
249
|
+
const result = validateImageUrl('https://imgur.com/image.jpg#section');
|
|
250
|
+
expect(result.isValid).toBe(true);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('should accept HTTPS URL with path segments', () => {
|
|
254
|
+
const result = validateImageUrl('https://github.com/user/repo/blob/main/image.png');
|
|
255
|
+
expect(result.isValid).toBe(true);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('should accept HTTP URL from allowed domain', () => {
|
|
259
|
+
const result = validateImageUrl('http://imgur.com/image.jpg');
|
|
260
|
+
expect(result.isValid).toBe(true);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
describe('invalid protocols', () => {
|
|
265
|
+
it('should reject file:// protocol', () => {
|
|
266
|
+
const result = validateImageUrl('file:///etc/passwd');
|
|
267
|
+
expect(result.isValid).toBe(false);
|
|
268
|
+
expect(result.error).toContain('Invalid URL format');
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('should reject ftp:// protocol', () => {
|
|
272
|
+
const result = validateImageUrl('ftp://example.com/image.jpg');
|
|
273
|
+
expect(result.isValid).toBe(false);
|
|
274
|
+
expect(result.error).toContain('Invalid URL format');
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('should reject data: protocol', () => {
|
|
278
|
+
const result = validateImageUrl('data:image/png;base64,iVBORw0KGgo');
|
|
279
|
+
expect(result.isValid).toBe(false);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('should reject javascript: protocol', () => {
|
|
283
|
+
const result = validateImageUrl('javascript:alert("xss")');
|
|
284
|
+
expect(result.isValid).toBe(false);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('should reject gopher:// protocol', () => {
|
|
288
|
+
const result = validateImageUrl('gopher://example.com/image');
|
|
289
|
+
expect(result.isValid).toBe(false);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
describe('non-standard ports', () => {
|
|
294
|
+
it('should allow port 80', () => {
|
|
295
|
+
const result = validateImageUrl('http://imgur.com:80/image.jpg');
|
|
296
|
+
expect(result.isValid).toBe(true);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('should allow port 443', () => {
|
|
300
|
+
const result = validateImageUrl('https://imgur.com:443/image.jpg');
|
|
301
|
+
expect(result.isValid).toBe(true);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('should allow port 8080', () => {
|
|
305
|
+
const result = validateImageUrl('https://imgur.com:8080/image.jpg');
|
|
306
|
+
expect(result.isValid).toBe(true);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('should allow port 8443', () => {
|
|
310
|
+
const result = validateImageUrl('https://imgur.com:8443/image.jpg');
|
|
311
|
+
expect(result.isValid).toBe(true);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('should reject port 22 (SSH)', () => {
|
|
315
|
+
const result = validateImageUrl('https://imgur.com:22/image.jpg');
|
|
316
|
+
expect(result.isValid).toBe(false);
|
|
317
|
+
expect(result.error).toContain('non-standard port');
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('should reject port 3000', () => {
|
|
321
|
+
const result = validateImageUrl('https://imgur.com:3000/image.jpg');
|
|
322
|
+
expect(result.isValid).toBe(false);
|
|
323
|
+
expect(result.error).toContain('non-standard port');
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('should reject port 5432 (PostgreSQL)', () => {
|
|
327
|
+
const result = validateImageUrl('https://imgur.com:5432/image.jpg');
|
|
328
|
+
expect(result.isValid).toBe(false);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('should reject port 6379 (Redis)', () => {
|
|
332
|
+
const result = validateImageUrl('https://imgur.com:6379/image.jpg');
|
|
333
|
+
expect(result.isValid).toBe(false);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('should reject port 9200 (Elasticsearch)', () => {
|
|
337
|
+
const result = validateImageUrl('https://imgur.com:9200/image.jpg');
|
|
338
|
+
expect(result.isValid).toBe(false);
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
describe('edge cases and error handling', () => {
|
|
343
|
+
it('should reject empty string', () => {
|
|
344
|
+
const result = validateImageUrl('');
|
|
345
|
+
expect(result.isValid).toBe(false);
|
|
346
|
+
expect(result.error).toContain('Invalid URL format');
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('should reject malformed URL', () => {
|
|
350
|
+
const result = validateImageUrl('not-a-url');
|
|
351
|
+
expect(result.isValid).toBe(false);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('should reject relative URLs', () => {
|
|
355
|
+
const result = validateImageUrl('/path/to/image.jpg');
|
|
356
|
+
expect(result.isValid).toBe(false);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('should reject URL without protocol', () => {
|
|
360
|
+
const result = validateImageUrl('imgur.com/image.jpg');
|
|
361
|
+
expect(result.isValid).toBe(false);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it('should handle URLs with special characters in path', () => {
|
|
365
|
+
const result = validateImageUrl('https://imgur.com/image%20with%20spaces.jpg');
|
|
366
|
+
expect(result.isValid).toBe(true);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it('should handle URLs with authentication (though domain must be allowed)', () => {
|
|
370
|
+
const result = validateImageUrl('https://user:pass@imgur.com/image.jpg');
|
|
371
|
+
expect(result.isValid).toBe(true);
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
describe('createSecureAxiosConfig', () => {
|
|
377
|
+
it('should return config object with all required fields', () => {
|
|
378
|
+
const url = 'https://imgur.com/image.jpg';
|
|
379
|
+
const config = createSecureAxiosConfig(url);
|
|
380
|
+
|
|
381
|
+
expect(config).toHaveProperty('url', url);
|
|
382
|
+
expect(config).toHaveProperty('responseType', 'stream');
|
|
383
|
+
expect(config).toHaveProperty('timeout', 10000);
|
|
384
|
+
expect(config).toHaveProperty('maxRedirects', 3);
|
|
385
|
+
expect(config).toHaveProperty('maxContentLength', 50 * 1024 * 1024);
|
|
386
|
+
expect(config).toHaveProperty('validateStatus');
|
|
387
|
+
expect(config).toHaveProperty('headers');
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it('should set correct URL', () => {
|
|
391
|
+
const url = 'https://github.com/user/repo/image.png';
|
|
392
|
+
const config = createSecureAxiosConfig(url);
|
|
393
|
+
|
|
394
|
+
expect(config.url).toBe(url);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it('should set response type to stream', () => {
|
|
398
|
+
const config = createSecureAxiosConfig('https://imgur.com/image.jpg');
|
|
399
|
+
expect(config.responseType).toBe('stream');
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it('should set 10 second timeout', () => {
|
|
403
|
+
const config = createSecureAxiosConfig('https://imgur.com/image.jpg');
|
|
404
|
+
expect(config.timeout).toBe(10000);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it('should limit redirects to 3', () => {
|
|
408
|
+
const config = createSecureAxiosConfig('https://imgur.com/image.jpg');
|
|
409
|
+
expect(config.maxRedirects).toBe(3);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it('should set max content length to 50MB', () => {
|
|
413
|
+
const config = createSecureAxiosConfig('https://imgur.com/image.jpg');
|
|
414
|
+
expect(config.maxContentLength).toBe(50 * 1024 * 1024);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it('should include User-Agent header', () => {
|
|
418
|
+
const config = createSecureAxiosConfig('https://imgur.com/image.jpg');
|
|
419
|
+
expect(config.headers).toHaveProperty('User-Agent', 'Ghost-MCP-Server/1.0');
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it('should have validateStatus function that accepts 2xx status codes', () => {
|
|
423
|
+
const config = createSecureAxiosConfig('https://imgur.com/image.jpg');
|
|
424
|
+
expect(typeof config.validateStatus).toBe('function');
|
|
425
|
+
expect(config.validateStatus(200)).toBe(true);
|
|
426
|
+
expect(config.validateStatus(204)).toBe(true);
|
|
427
|
+
expect(config.validateStatus(299)).toBe(true);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it('should have validateStatus function that rejects non-2xx status codes', () => {
|
|
431
|
+
const config = createSecureAxiosConfig('https://imgur.com/image.jpg');
|
|
432
|
+
expect(config.validateStatus(199)).toBe(false);
|
|
433
|
+
expect(config.validateStatus(300)).toBe(false);
|
|
434
|
+
expect(config.validateStatus(404)).toBe(false);
|
|
435
|
+
expect(config.validateStatus(500)).toBe(false);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it('should handle different URLs independently', () => {
|
|
439
|
+
const url1 = 'https://imgur.com/image1.jpg';
|
|
440
|
+
const url2 = 'https://github.com/image2.png';
|
|
441
|
+
|
|
442
|
+
const config1 = createSecureAxiosConfig(url1);
|
|
443
|
+
const config2 = createSecureAxiosConfig(url2);
|
|
444
|
+
|
|
445
|
+
expect(config1.url).toBe(url1);
|
|
446
|
+
expect(config2.url).toBe(url2);
|
|
447
|
+
expect(config1.url).not.toBe(config2.url);
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
});
|