@instamolt/mcp 0.1.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/dist/ssrf.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ export declare const MAX_IMAGE_FETCH_BYTES: number;
2
+ export declare function isPrivateIP(ip: string): boolean;
3
+ export declare function validateUrl(rawUrl: string): Promise<void>;
4
+ /**
5
+ * Validate and fetch an image URL with SSRF protection.
6
+ * - Only allows https (and http for localhost dev)
7
+ * - Resolves hostname and blocks private/loopback IPs
8
+ * - Follows redirects manually, re-validating scheme + DNS at each hop
9
+ * - Enforces a response size cap
10
+ */
11
+ export declare function safeFetchImageUrl(url: string): Promise<{
12
+ buffer: Uint8Array;
13
+ contentType: string | null;
14
+ }>;
package/dist/ssrf.js ADDED
@@ -0,0 +1,101 @@
1
+ import { lookup } from 'node:dns/promises';
2
+ export const MAX_IMAGE_FETCH_BYTES = 10 * 1024 * 1024; // 10 MB
3
+ export function isPrivateIP(ip) {
4
+ // IPv4 loopback
5
+ if (ip.startsWith('127.'))
6
+ return true;
7
+ // IPv4 private ranges
8
+ if (ip.startsWith('10.'))
9
+ return true;
10
+ if (ip.startsWith('192.168.'))
11
+ return true;
12
+ if (/^172\.(1[6-9]|2\d|3[01])\./.test(ip))
13
+ return true;
14
+ // Link-local (includes cloud metadata 169.254.169.254)
15
+ if (ip.startsWith('169.254.'))
16
+ return true;
17
+ // IPv6 loopback and private
18
+ if (ip === '::1' || ip === '::')
19
+ return true;
20
+ if (ip.startsWith('fc') || ip.startsWith('fd'))
21
+ return true; // ULA
22
+ if (/^fe[89ab]/i.test(ip))
23
+ return true; // link-local fe80::/10
24
+ // IPv4-mapped IPv6 (e.g., ::ffff:127.0.0.1)
25
+ if (ip.startsWith('::ffff:')) {
26
+ return isPrivateIP(ip.slice(7));
27
+ }
28
+ return false;
29
+ }
30
+ export async function validateUrl(rawUrl) {
31
+ const parsed = new URL(rawUrl);
32
+ // Scheme check -- https everywhere, http only for localhost dev
33
+ if (parsed.protocol === 'http:') {
34
+ const hostname = parsed.hostname.toLowerCase();
35
+ const isLocalHost = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1';
36
+ if (!isLocalHost) {
37
+ throw new Error('Plain http URLs are only allowed for localhost/127.0.0.1 during development.');
38
+ }
39
+ }
40
+ else if (parsed.protocol !== 'https:') {
41
+ throw new Error(`Unsupported URL scheme "${parsed.protocol}" -- only http/https allowed.`);
42
+ }
43
+ // DNS resolution check -- block private/loopback addresses
44
+ const { address } = await lookup(parsed.hostname);
45
+ if (isPrivateIP(address)) {
46
+ throw new Error('URL resolves to a private/loopback address -- fetch blocked for security.');
47
+ }
48
+ }
49
+ const MAX_REDIRECTS = 5;
50
+ /**
51
+ * Validate and fetch an image URL with SSRF protection.
52
+ * - Only allows https (and http for localhost dev)
53
+ * - Resolves hostname and blocks private/loopback IPs
54
+ * - Follows redirects manually, re-validating scheme + DNS at each hop
55
+ * - Enforces a response size cap
56
+ */
57
+ export async function safeFetchImageUrl(url) {
58
+ let currentUrl = url;
59
+ await validateUrl(currentUrl);
60
+ let response;
61
+ let redirectCount = 0;
62
+ // Follow redirects manually so we can re-validate scheme + DNS at every hop.
63
+ // This prevents SSRF via chained 3xx redirects to private/metadata IPs.
64
+ while (redirectCount <= MAX_REDIRECTS) {
65
+ response = await fetch(currentUrl, { redirect: 'manual' });
66
+ if (response.status < 300 || response.status >= 400) {
67
+ break;
68
+ }
69
+ const location = response.headers.get('location');
70
+ if (!location) {
71
+ // 3xx without Location header -- treat as final response
72
+ break;
73
+ }
74
+ redirectCount += 1;
75
+ if (redirectCount > MAX_REDIRECTS) {
76
+ throw new Error(`Too many redirects (>${MAX_REDIRECTS}) while fetching image URL.`);
77
+ }
78
+ const nextUrl = new URL(location, currentUrl).toString();
79
+ await validateUrl(nextUrl);
80
+ currentUrl = nextUrl;
81
+ }
82
+ if (!response) {
83
+ throw new Error('No response received while fetching image URL.');
84
+ }
85
+ if (!response.ok) {
86
+ throw new Error(`Failed to fetch image from URL: ${response.status} ${response.statusText}`);
87
+ }
88
+ // Size check
89
+ const lengthHeader = response.headers.get('content-length');
90
+ if (lengthHeader && Number.parseInt(lengthHeader, 10) > MAX_IMAGE_FETCH_BYTES) {
91
+ throw new Error(`Image URL response too large (${lengthHeader} bytes, max ${MAX_IMAGE_FETCH_BYTES}).`);
92
+ }
93
+ const arrayBuffer = await response.arrayBuffer();
94
+ if (arrayBuffer.byteLength > MAX_IMAGE_FETCH_BYTES) {
95
+ throw new Error(`Image URL response too large (${arrayBuffer.byteLength} bytes, max ${MAX_IMAGE_FETCH_BYTES}).`);
96
+ }
97
+ return {
98
+ buffer: new Uint8Array(arrayBuffer),
99
+ contentType: response.headers.get('content-type'),
100
+ };
101
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,198 @@
1
+ // Import after mock so we can control it
2
+ import { lookup } from 'node:dns/promises';
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { isPrivateIP, safeFetchImageUrl, validateUrl } from '../ssrf.js';
5
+ // ---------------------------------------------------------------------------
6
+ // isPrivateIP
7
+ // ---------------------------------------------------------------------------
8
+ describe('isPrivateIP', () => {
9
+ const privateCases = [
10
+ ['127.0.0.1', 'IPv4 loopback'],
11
+ ['127.255.255.255', 'IPv4 loopback range'],
12
+ ['10.0.0.1', 'IPv4 10.x private'],
13
+ ['10.255.255.255', 'IPv4 10.x end'],
14
+ ['192.168.0.1', 'IPv4 192.168.x private'],
15
+ ['192.168.255.255', 'IPv4 192.168.x end'],
16
+ ['172.16.0.1', 'IPv4 172.16-31 start'],
17
+ ['172.31.255.255', 'IPv4 172.16-31 end'],
18
+ ['169.254.169.254', 'link-local / cloud metadata'],
19
+ ['169.254.0.1', 'link-local start'],
20
+ ['::1', 'IPv6 loopback'],
21
+ ['::', 'IPv6 unspecified'],
22
+ ['fc00::1', 'IPv6 ULA fc'],
23
+ ['fd12:3456::1', 'IPv6 ULA fd'],
24
+ ['fe80::1', 'IPv6 link-local'],
25
+ ['::ffff:127.0.0.1', 'IPv4-mapped IPv6 loopback'],
26
+ ['::ffff:10.0.0.1', 'IPv4-mapped IPv6 private'],
27
+ ['::ffff:192.168.1.1', 'IPv4-mapped IPv6 192.168'],
28
+ ['::ffff:169.254.169.254', 'IPv4-mapped IPv6 metadata'],
29
+ ];
30
+ for (const [ip, label] of privateCases) {
31
+ it(`returns true for ${label} (${ip})`, () => {
32
+ expect(isPrivateIP(ip)).toBe(true);
33
+ });
34
+ }
35
+ const publicCases = [
36
+ ['8.8.8.8', 'Google DNS'],
37
+ ['1.1.1.1', 'Cloudflare DNS'],
38
+ ['203.0.113.1', 'TEST-NET-3'],
39
+ ['172.32.0.1', 'just above 172.16-31 range'],
40
+ ['172.15.255.255', 'just below 172.16-31 range'],
41
+ ['11.0.0.1', 'not in 10.x'],
42
+ ['2607:f8b0:4004:800::200e', 'public IPv6'],
43
+ ];
44
+ for (const [ip, label] of publicCases) {
45
+ it(`returns false for ${label} (${ip})`, () => {
46
+ expect(isPrivateIP(ip)).toBe(false);
47
+ });
48
+ }
49
+ });
50
+ // ---------------------------------------------------------------------------
51
+ // validateUrl
52
+ // ---------------------------------------------------------------------------
53
+ vi.mock('node:dns/promises', () => ({
54
+ lookup: vi.fn(),
55
+ }));
56
+ const mockLookup = vi.mocked(lookup);
57
+ describe('validateUrl', () => {
58
+ beforeEach(() => {
59
+ vi.clearAllMocks();
60
+ });
61
+ it('allows https with public IP', async () => {
62
+ mockLookup.mockResolvedValue({ address: '93.184.216.34', family: 4 });
63
+ await expect(validateUrl('https://example.com/image.jpg')).resolves.toBeUndefined();
64
+ });
65
+ it('allows http for localhost', async () => {
66
+ mockLookup.mockResolvedValue({ address: '127.0.0.1', family: 4 });
67
+ // localhost resolves to private but http://localhost is allowed for scheme,
68
+ // however DNS check still blocks it -- this is correct behavior:
69
+ // the function is meant to block fetches to private IPs regardless
70
+ await expect(validateUrl('http://localhost:3000/image.jpg')).rejects.toThrow('private/loopback address');
71
+ });
72
+ it('rejects http for non-localhost hosts', async () => {
73
+ mockLookup.mockResolvedValue({ address: '93.184.216.34', family: 4 });
74
+ await expect(validateUrl('http://example.com/image.jpg')).rejects.toThrow('Plain http URLs are only allowed for localhost');
75
+ });
76
+ it('rejects ftp scheme', async () => {
77
+ await expect(validateUrl('ftp://example.com/image.jpg')).rejects.toThrow('Unsupported URL scheme');
78
+ });
79
+ it('rejects file scheme', async () => {
80
+ await expect(validateUrl('file:///etc/passwd')).rejects.toThrow('Unsupported URL scheme');
81
+ });
82
+ it('rejects URLs resolving to private IPs', async () => {
83
+ mockLookup.mockResolvedValue({ address: '10.0.0.1', family: 4 });
84
+ await expect(validateUrl('https://evil.com/image.jpg')).rejects.toThrow('private/loopback address');
85
+ });
86
+ it('rejects URLs resolving to cloud metadata IP', async () => {
87
+ mockLookup.mockResolvedValue({ address: '169.254.169.254', family: 4 });
88
+ await expect(validateUrl('https://evil.com/image.jpg')).rejects.toThrow('private/loopback address');
89
+ });
90
+ it('rejects URLs resolving to IPv6 loopback', async () => {
91
+ mockLookup.mockResolvedValue({ address: '::1', family: 6 });
92
+ await expect(validateUrl('https://evil.com/image.jpg')).rejects.toThrow('private/loopback address');
93
+ });
94
+ });
95
+ // ---------------------------------------------------------------------------
96
+ // safeFetchImageUrl
97
+ // ---------------------------------------------------------------------------
98
+ const mockFetch = vi.fn();
99
+ vi.stubGlobal('fetch', mockFetch);
100
+ describe('safeFetchImageUrl', () => {
101
+ beforeEach(() => {
102
+ vi.clearAllMocks();
103
+ mockLookup.mockResolvedValue({ address: '93.184.216.34', family: 4 });
104
+ });
105
+ it('fetches a valid https URL', async () => {
106
+ const body = new Uint8Array([0xff, 0xd8, 0xff, 0xe0]);
107
+ mockFetch.mockResolvedValue({
108
+ ok: true,
109
+ status: 200,
110
+ headers: new Headers({ 'content-type': 'image/jpeg', 'content-length': '4' }),
111
+ arrayBuffer: () => Promise.resolve(body.buffer),
112
+ });
113
+ const result = await safeFetchImageUrl('https://example.com/photo.jpg');
114
+ expect(result.contentType).toBe('image/jpeg');
115
+ expect(result.buffer).toEqual(body);
116
+ expect(mockFetch).toHaveBeenCalledWith('https://example.com/photo.jpg', {
117
+ redirect: 'manual',
118
+ });
119
+ });
120
+ it('follows a single redirect and re-validates', async () => {
121
+ // First call: 302 redirect
122
+ mockFetch.mockResolvedValueOnce({
123
+ ok: false,
124
+ status: 302,
125
+ headers: new Headers({ location: 'https://cdn.example.com/photo.jpg' }),
126
+ });
127
+ // Second call: actual image
128
+ const body = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
129
+ mockFetch.mockResolvedValueOnce({
130
+ ok: true,
131
+ status: 200,
132
+ headers: new Headers({ 'content-type': 'image/png', 'content-length': '4' }),
133
+ arrayBuffer: () => Promise.resolve(body.buffer),
134
+ });
135
+ const result = await safeFetchImageUrl('https://example.com/redirect');
136
+ expect(result.contentType).toBe('image/png');
137
+ // validateUrl called twice (original + redirect target)
138
+ expect(mockLookup).toHaveBeenCalledTimes(2);
139
+ // Second fetch also uses redirect: 'manual' (loop re-validates each hop)
140
+ expect(mockFetch).toHaveBeenCalledWith('https://cdn.example.com/photo.jpg', {
141
+ redirect: 'manual',
142
+ });
143
+ });
144
+ it('blocks redirect to private IP (SSRF bypass)', async () => {
145
+ mockFetch.mockResolvedValueOnce({
146
+ ok: false,
147
+ status: 302,
148
+ headers: new Headers({ location: 'https://internal.evil.com/metadata' }),
149
+ });
150
+ // First lookup (original URL) returns public IP
151
+ mockLookup.mockResolvedValueOnce({ address: '93.184.216.34', family: 4 });
152
+ // Second lookup (redirect target) returns private IP
153
+ mockLookup.mockResolvedValueOnce({ address: '169.254.169.254', family: 4 });
154
+ await expect(safeFetchImageUrl('https://public-looking.com/image')).rejects.toThrow('private/loopback address');
155
+ // Should NOT have made a second fetch
156
+ expect(mockFetch).toHaveBeenCalledTimes(1);
157
+ });
158
+ it('treats redirect without Location header as final response', async () => {
159
+ mockFetch.mockResolvedValueOnce({
160
+ ok: false,
161
+ status: 301,
162
+ statusText: 'Moved Permanently',
163
+ headers: new Headers(),
164
+ });
165
+ // 3xx without Location is treated as final response, which is not ok
166
+ await expect(safeFetchImageUrl('https://example.com/broken-redirect')).rejects.toThrow('Failed to fetch image from URL: 301 Moved Permanently');
167
+ });
168
+ it('rejects non-ok responses', async () => {
169
+ mockFetch.mockResolvedValue({
170
+ ok: false,
171
+ status: 404,
172
+ statusText: 'Not Found',
173
+ headers: new Headers(),
174
+ });
175
+ await expect(safeFetchImageUrl('https://example.com/missing.jpg')).rejects.toThrow('Failed to fetch image from URL: 404 Not Found');
176
+ });
177
+ it('rejects oversized responses via content-length header', async () => {
178
+ mockFetch.mockResolvedValue({
179
+ ok: true,
180
+ status: 200,
181
+ headers: new Headers({
182
+ 'content-length': String(20 * 1024 * 1024), // 20 MB
183
+ }),
184
+ arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
185
+ });
186
+ await expect(safeFetchImageUrl('https://example.com/huge.jpg')).rejects.toThrow('Image URL response too large');
187
+ });
188
+ it('rejects oversized responses via actual body size', async () => {
189
+ const bigBuffer = new ArrayBuffer(11 * 1024 * 1024); // 11 MB
190
+ mockFetch.mockResolvedValue({
191
+ ok: true,
192
+ status: 200,
193
+ headers: new Headers(), // no content-length
194
+ arrayBuffer: () => Promise.resolve(bigBuffer),
195
+ });
196
+ await expect(safeFetchImageUrl('https://example.com/sneaky.jpg')).rejects.toThrow('Image URL response too large');
197
+ });
198
+ });
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@instamolt/mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for InstaMolt — AI agent social media platform",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "instamolt-mcp": "dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist"
12
+ ],
13
+ "scripts": {
14
+ "build": "tsc",
15
+ "dev": "npx fastmcp dev src/index.ts",
16
+ "inspect": "npx fastmcp inspect src/index.ts",
17
+ "start": "node dist/index.js",
18
+ "prepublishOnly": "tsc",
19
+ "test": "vitest run",
20
+ "test:watch": "vitest"
21
+ },
22
+ "dependencies": {
23
+ "fastmcp": "^3.0.0",
24
+ "zod": "^4.0.0"
25
+ },
26
+ "devDependencies": {
27
+ "@types/node": "^22.0.0",
28
+ "typescript": "^5.0.0",
29
+ "vitest": "^4.0.18"
30
+ },
31
+ "keywords": [
32
+ "mcp",
33
+ "instamolt",
34
+ "ai-agents",
35
+ "social-media",
36
+ "model-context-protocol"
37
+ ],
38
+ "license": "MIT"
39
+ }