@lmcc-dev/mult-fetch-mcp-server 1.3.2 → 1.3.3
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/README.md +2 -0
- package/dist/src/client.js +0 -0
- package/dist/src/index.js +0 -0
- package/dist/src/lib/fetchers/browser/BrowserFetcher.js +2 -0
- package/dist/src/lib/fetchers/browser/BrowserInstance.js +27 -24
- package/dist/src/lib/fetchers/node/HttpClient.js +6 -0
- package/dist/src/lib/utils/UrlValidator.js +205 -0
- package/dist/src/mcp-server.js +0 -0
- package/dist/tests/client.test.js +0 -0
- package/dist/tests/fetch.test.js +0 -0
- package/dist/tests/fetchers/node/HttpClient.test.js +50 -0
- package/dist/tests/i18n-missing-keys.js +0 -0
- package/dist/tests/i18n-unused-keys.js +0 -0
- package/dist/tests/i18n.test.js +0 -0
- package/dist/tests/logger.test.js +0 -0
- package/dist/tests/mcp-server.test.js +0 -0
- package/dist/tests/setup.js +0 -0
- package/dist/tests/test-direct-client.js +0 -0
- package/dist/tests/test-extract-single.js +0 -0
- package/dist/tests/test-i18n.js +0 -0
- package/dist/tests/test-mcp-methods.js +0 -0
- package/dist/tests/test-mcp.js +0 -0
- package/dist/tests/test-mini4k.js +0 -0
- package/dist/tests/types.test.js +0 -0
- package/dist/tests/utils/UrlValidator.test.js +58 -0
- package/package.json +26 -28
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
[](https://mseep.ai/app/lmcc-dev-mult-fetch-mcp-server)
|
|
2
|
+
|
|
1
3
|
# mult-fetch-mcp-server
|
|
2
4
|
|
|
3
5
|
[](https://www.npmjs.com/package/@lmcc-dev/mult-fetch-mcp-server)
|
package/dist/src/client.js
CHANGED
|
File without changes
|
package/dist/src/index.js
CHANGED
|
File without changes
|
|
@@ -11,6 +11,7 @@ import { getRandomUserAgent, getSystemProxy } from '../common/utils.js';
|
|
|
11
11
|
import { ContentSizeManager } from '../../utils/ContentSizeManager.js';
|
|
12
12
|
import { BaseFetcher } from '../common/BaseFetcher.js';
|
|
13
13
|
import { ContentProcessor } from '../../utils/ContentProcessor.js';
|
|
14
|
+
import { validateFetchUrl } from '../../utils/UrlValidator.js';
|
|
14
15
|
/**
|
|
15
16
|
* 浏览器模式获取器类 (Browser mode fetcher class)
|
|
16
17
|
* 使用Puppeteer实现浏览器模式的网页获取 (Implements webpage fetching in browser mode using Puppeteer)
|
|
@@ -75,6 +76,7 @@ export class BrowserFetcher extends BaseFetcher {
|
|
|
75
76
|
if (effectiveProxy) {
|
|
76
77
|
log('browser.usingProxy', debug, { proxy: effectiveProxy }, COMPONENTS.BROWSER_FETCH);
|
|
77
78
|
}
|
|
79
|
+
await validateFetchUrl(url);
|
|
78
80
|
// 导航到URL
|
|
79
81
|
log('browser.navigating', debug, { url }, COMPONENTS.BROWSER_FETCH);
|
|
80
82
|
await page.goto(url, {
|
|
@@ -94,32 +94,35 @@ export class BrowserInstance {
|
|
|
94
94
|
try {
|
|
95
95
|
log('browser.startingBrowser', debug, {}, COMPONENTS.BROWSER_FETCH);
|
|
96
96
|
// 准备浏览器启动参数 (Prepare browser startup parameters)
|
|
97
|
+
const browserArgs = [
|
|
98
|
+
'--no-sandbox',
|
|
99
|
+
'--disable-setuid-sandbox',
|
|
100
|
+
'--disable-dev-shm-usage',
|
|
101
|
+
'--disable-accelerated-2d-canvas',
|
|
102
|
+
'--no-first-run',
|
|
103
|
+
'--no-zygote',
|
|
104
|
+
'--disable-gpu',
|
|
105
|
+
'--disable-infobars',
|
|
106
|
+
'--window-position=0,0',
|
|
107
|
+
'--ignore-certificate-errors',
|
|
108
|
+
'--ignore-certificate-errors-spki-list',
|
|
109
|
+
'--disable-extensions',
|
|
110
|
+
'--disable-default-apps',
|
|
111
|
+
'--enable-features=NetworkService',
|
|
112
|
+
'--disable-features=IsolateOrigins,site-per-process',
|
|
113
|
+
'--disable-web-security',
|
|
114
|
+
'--disable-site-isolation-trials',
|
|
115
|
+
'--disable-features=IsolateOrigins',
|
|
116
|
+
'--disable-features=site-per-process',
|
|
117
|
+
'--disable-blink-features=AutomationControlled',
|
|
118
|
+
'--user-agent=' + getRandomUserAgent()
|
|
119
|
+
];
|
|
120
|
+
if (process.env.ALLOW_PRIVATE_NETWORK_REQUESTS === 'true') {
|
|
121
|
+
browserArgs.push('--disable-features=BlockInsecurePrivateNetworkRequests');
|
|
122
|
+
}
|
|
97
123
|
const launchOptions = {
|
|
98
124
|
headless: 'new',
|
|
99
|
-
args:
|
|
100
|
-
'--no-sandbox',
|
|
101
|
-
'--disable-setuid-sandbox',
|
|
102
|
-
'--disable-dev-shm-usage',
|
|
103
|
-
'--disable-accelerated-2d-canvas',
|
|
104
|
-
'--no-first-run',
|
|
105
|
-
'--no-zygote',
|
|
106
|
-
'--disable-gpu',
|
|
107
|
-
'--disable-infobars',
|
|
108
|
-
'--window-position=0,0',
|
|
109
|
-
'--ignore-certificate-errors',
|
|
110
|
-
'--ignore-certificate-errors-spki-list',
|
|
111
|
-
'--disable-extensions',
|
|
112
|
-
'--disable-default-apps',
|
|
113
|
-
'--enable-features=NetworkService',
|
|
114
|
-
'--disable-features=IsolateOrigins,site-per-process',
|
|
115
|
-
'--disable-web-security',
|
|
116
|
-
'--disable-site-isolation-trials',
|
|
117
|
-
'--disable-features=BlockInsecurePrivateNetworkRequests',
|
|
118
|
-
'--disable-features=IsolateOrigins',
|
|
119
|
-
'--disable-features=site-per-process',
|
|
120
|
-
'--disable-blink-features=AutomationControlled',
|
|
121
|
-
'--user-agent=' + getRandomUserAgent()
|
|
122
|
-
],
|
|
125
|
+
args: browserArgs,
|
|
123
126
|
ignoreHTTPSErrors: true,
|
|
124
127
|
defaultViewport: {
|
|
125
128
|
width: 1920,
|
|
@@ -8,6 +8,7 @@ import { HttpProxyAgent } from "http-proxy-agent";
|
|
|
8
8
|
import { HttpsProxyAgent } from "https-proxy-agent";
|
|
9
9
|
import { log, COMPONENTS } from '../../logger.js';
|
|
10
10
|
import { getRandomUserAgent, randomDelay, getSystemProxy } from '../common/utils.js';
|
|
11
|
+
import { validateFetchUrl, UrlValidationError } from '../../utils/UrlValidator.js';
|
|
11
12
|
/**
|
|
12
13
|
* HTTP客户端类 (HTTP client class)
|
|
13
14
|
* 处理HTTP请求、重定向和错误 (Handle HTTP requests, redirects and errors)
|
|
@@ -164,6 +165,7 @@ export class HttpClient {
|
|
|
164
165
|
// 初始化重定向计数器 (Initialize redirect counter)
|
|
165
166
|
let redirectCount = 0;
|
|
166
167
|
let currentUrl = url;
|
|
168
|
+
await validateFetchUrl(currentUrl);
|
|
167
169
|
// 记录请求开始时间 (Record request start time)
|
|
168
170
|
const fetchStart = Date.now();
|
|
169
171
|
// 创建AbortController用于超时控制 (Create AbortController for timeout control)
|
|
@@ -214,6 +216,7 @@ export class HttpClient {
|
|
|
214
216
|
// 获取重定向URL (Get redirect URL)
|
|
215
217
|
const location = response.headers.get('location');
|
|
216
218
|
currentUrl = this.buildRedirectUrl(location, currentUrl, debug);
|
|
219
|
+
await validateFetchUrl(currentUrl);
|
|
217
220
|
// 增加重定向计数 (Increase redirect count)
|
|
218
221
|
redirectCount++;
|
|
219
222
|
// 记录重定向 (Log redirect)
|
|
@@ -236,6 +239,9 @@ export class HttpClient {
|
|
|
236
239
|
}
|
|
237
240
|
catch (error) {
|
|
238
241
|
clearTimeout(timeoutId);
|
|
242
|
+
if (error instanceof UrlValidationError) {
|
|
243
|
+
throw error;
|
|
244
|
+
}
|
|
239
245
|
return this.handleRequestError(error, fetchStart, timeout, redirectCount, maxRedirects, debug);
|
|
240
246
|
}
|
|
241
247
|
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Author: Martin <lmccc.dev@gmail.com>
|
|
3
|
+
* Co-Author: AI Assistant (Claude)
|
|
4
|
+
* Description: SSRF protection for outbound fetch requests.
|
|
5
|
+
*/
|
|
6
|
+
import { isIP } from 'net';
|
|
7
|
+
import { lookup } from 'dns/promises';
|
|
8
|
+
const ALLOWED_SCHEMES = new Set(['http:', 'https:']);
|
|
9
|
+
const BLOCKED_HOSTNAMES = new Set([
|
|
10
|
+
'localhost',
|
|
11
|
+
'metadata',
|
|
12
|
+
'metadata.google.internal',
|
|
13
|
+
'metadata.goog',
|
|
14
|
+
]);
|
|
15
|
+
export class UrlValidationError extends Error {
|
|
16
|
+
code = 'EBLOCKEDURL';
|
|
17
|
+
constructor(message) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = 'UrlValidationError';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function ipv4ToInt(ip) {
|
|
23
|
+
const parts = ip.split('.');
|
|
24
|
+
if (parts.length !== 4) {
|
|
25
|
+
return -1;
|
|
26
|
+
}
|
|
27
|
+
let value = 0;
|
|
28
|
+
for (const part of parts) {
|
|
29
|
+
const octet = Number(part);
|
|
30
|
+
if (!Number.isInteger(octet) || octet < 0 || octet > 255) {
|
|
31
|
+
return -1;
|
|
32
|
+
}
|
|
33
|
+
value = (value << 8) + octet;
|
|
34
|
+
}
|
|
35
|
+
return value >>> 0;
|
|
36
|
+
}
|
|
37
|
+
function isBlockedIPv4(ip) {
|
|
38
|
+
const value = ipv4ToInt(ip);
|
|
39
|
+
if (value < 0) {
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
const firstOctet = value >>> 24;
|
|
43
|
+
const secondOctet = (value >>> 16) & 0xff;
|
|
44
|
+
if (firstOctet === 0)
|
|
45
|
+
return true; // 0.0.0.0/8
|
|
46
|
+
if (firstOctet === 10)
|
|
47
|
+
return true; // 10.0.0.0/8
|
|
48
|
+
if (firstOctet === 127)
|
|
49
|
+
return true; // 127.0.0.0/8
|
|
50
|
+
if (firstOctet === 100 && (value & 0xc0000000) === 0x64400000)
|
|
51
|
+
return true; // 100.64.0.0/10
|
|
52
|
+
if (firstOctet === 169 && secondOctet === 254)
|
|
53
|
+
return true; // 169.254.0.0/16
|
|
54
|
+
if (firstOctet === 172 && secondOctet >= 16 && secondOctet <= 31)
|
|
55
|
+
return true; // 172.16.0.0/12
|
|
56
|
+
if (firstOctet === 192 && secondOctet === 168)
|
|
57
|
+
return true; // 192.168.0.0/16
|
|
58
|
+
if (firstOctet === 192 && secondOctet === 0)
|
|
59
|
+
return true; // 192.0.0.0/24
|
|
60
|
+
if (firstOctet === 198 && (secondOctet === 18 || secondOctet === 19))
|
|
61
|
+
return true; // 198.18.0.0/15
|
|
62
|
+
if (firstOctet >= 224)
|
|
63
|
+
return true; // multicast and reserved ranges
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
function expandIPv6(ip) {
|
|
67
|
+
const lower = ip.toLowerCase();
|
|
68
|
+
const [head = '', tail = ''] = lower.split('::');
|
|
69
|
+
const headParts = head ? head.split(':') : [];
|
|
70
|
+
const tailParts = tail ? tail.split(':') : [];
|
|
71
|
+
if (headParts.length + tailParts.length > 7) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
const missing = 8 - headParts.length - tailParts.length;
|
|
75
|
+
const parts = [
|
|
76
|
+
...headParts,
|
|
77
|
+
...Array.from({ length: missing }, () => '0'),
|
|
78
|
+
...tailParts,
|
|
79
|
+
];
|
|
80
|
+
if (parts.length !== 8) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
const groups = [];
|
|
84
|
+
for (const part of parts) {
|
|
85
|
+
if (!part) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
const value = Number.parseInt(part, 16);
|
|
89
|
+
if (!Number.isFinite(value) || value < 0 || value > 0xffff) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
groups.push(value);
|
|
93
|
+
}
|
|
94
|
+
return groups;
|
|
95
|
+
}
|
|
96
|
+
function isBlockedIPv6(ip) {
|
|
97
|
+
const normalized = ip.toLowerCase();
|
|
98
|
+
if (normalized === '::' || normalized === '::1') {
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
const ipv4MappedMatch = normalized.match(/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/);
|
|
102
|
+
if (ipv4MappedMatch) {
|
|
103
|
+
return isBlockedIPv4(ipv4MappedMatch[1]);
|
|
104
|
+
}
|
|
105
|
+
const groups = expandIPv6(normalized);
|
|
106
|
+
if (!groups) {
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
if (groups[0] === 0 &&
|
|
110
|
+
groups[1] === 0 &&
|
|
111
|
+
groups[2] === 0 &&
|
|
112
|
+
groups[3] === 0 &&
|
|
113
|
+
groups[4] === 0 &&
|
|
114
|
+
groups[5] === 0xffff) {
|
|
115
|
+
const mappedValue = (groups[6] << 16) | groups[7];
|
|
116
|
+
const mappedIpv4 = [
|
|
117
|
+
(mappedValue >>> 24) & 0xff,
|
|
118
|
+
(mappedValue >>> 16) & 0xff,
|
|
119
|
+
(mappedValue >>> 8) & 0xff,
|
|
120
|
+
mappedValue & 0xff,
|
|
121
|
+
].join('.');
|
|
122
|
+
return isBlockedIPv4(mappedIpv4);
|
|
123
|
+
}
|
|
124
|
+
const first = groups[0];
|
|
125
|
+
if ((first & 0xffc0) === 0xfe80)
|
|
126
|
+
return true; // fe80::/10
|
|
127
|
+
if ((first & 0xfe00) === 0xfc00)
|
|
128
|
+
return true; // fc00::/7
|
|
129
|
+
if (first === 0x2001 && groups[1] === 0xdb8)
|
|
130
|
+
return true; // documentation range 2001:db8::/32
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
function isBlockedIpAddress(ip) {
|
|
134
|
+
const version = isIP(ip);
|
|
135
|
+
if (version === 4) {
|
|
136
|
+
return isBlockedIPv4(ip);
|
|
137
|
+
}
|
|
138
|
+
if (version === 6) {
|
|
139
|
+
return isBlockedIPv6(ip);
|
|
140
|
+
}
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
function isBlockedHostname(hostname) {
|
|
144
|
+
const normalized = hostname.toLowerCase().replace(/\.$/, '');
|
|
145
|
+
if (BLOCKED_HOSTNAMES.has(normalized)) {
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
return normalized.endsWith('.localhost');
|
|
149
|
+
}
|
|
150
|
+
function parseFetchUrl(urlString) {
|
|
151
|
+
try {
|
|
152
|
+
return new URL(urlString);
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
throw new UrlValidationError(`Invalid URL: ${urlString}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
function validateScheme(url) {
|
|
159
|
+
if (!ALLOWED_SCHEMES.has(url.protocol)) {
|
|
160
|
+
throw new UrlValidationError(`Blocked URL scheme: ${url.protocol}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
function normalizeHostname(hostname) {
|
|
164
|
+
if (hostname.startsWith('[') && hostname.endsWith(']')) {
|
|
165
|
+
return hostname.slice(1, -1);
|
|
166
|
+
}
|
|
167
|
+
return hostname;
|
|
168
|
+
}
|
|
169
|
+
function validateHostnameOrIp(hostname) {
|
|
170
|
+
const normalizedHostname = normalizeHostname(hostname);
|
|
171
|
+
const ipVersion = isIP(normalizedHostname);
|
|
172
|
+
if (ipVersion) {
|
|
173
|
+
if (isBlockedIpAddress(normalizedHostname)) {
|
|
174
|
+
throw new UrlValidationError(`Blocked IP address: ${normalizedHostname}`);
|
|
175
|
+
}
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (isBlockedHostname(normalizedHostname)) {
|
|
179
|
+
throw new UrlValidationError(`Blocked hostname: ${normalizedHostname}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
async function validateResolvedAddresses(hostname) {
|
|
183
|
+
const normalizedHostname = normalizeHostname(hostname);
|
|
184
|
+
const addresses = await lookup(normalizedHostname, { all: true, verbatim: true });
|
|
185
|
+
if (addresses.length === 0) {
|
|
186
|
+
throw new UrlValidationError(`Unable to resolve hostname: ${normalizedHostname}`);
|
|
187
|
+
}
|
|
188
|
+
for (const address of addresses) {
|
|
189
|
+
if (isBlockedIpAddress(address.address)) {
|
|
190
|
+
throw new UrlValidationError(`Blocked IP address resolved for ${normalizedHostname}: ${address.address}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Validate a URL before making an outbound fetch request.
|
|
196
|
+
* Blocks private, loopback, link-local, and metadata targets.
|
|
197
|
+
*/
|
|
198
|
+
export async function validateFetchUrl(urlString) {
|
|
199
|
+
const url = parseFetchUrl(urlString);
|
|
200
|
+
validateScheme(url);
|
|
201
|
+
validateHostnameOrIp(url.hostname);
|
|
202
|
+
if (!isIP(normalizeHostname(url.hostname))) {
|
|
203
|
+
await validateResolvedAddresses(url.hostname);
|
|
204
|
+
}
|
|
205
|
+
}
|
package/dist/src/mcp-server.js
CHANGED
|
File without changes
|
|
File without changes
|
package/dist/tests/fetch.test.js
CHANGED
|
File without changes
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Author: Martin <lmccc.dev@gmail.com>
|
|
3
|
+
* Co-Author: AI Assistant (Claude)
|
|
4
|
+
* Description: Tests for HttpClient SSRF protections.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, test, vi, beforeEach } from 'vitest';
|
|
7
|
+
import fetch from 'node-fetch';
|
|
8
|
+
import { lookup } from 'dns/promises';
|
|
9
|
+
import { HttpClient } from '../../../src/lib/fetchers/node/HttpClient.js';
|
|
10
|
+
import { UrlValidationError } from '../../../src/lib/utils/UrlValidator.js';
|
|
11
|
+
vi.mock('node-fetch', () => ({
|
|
12
|
+
default: vi.fn(),
|
|
13
|
+
}));
|
|
14
|
+
vi.mock('dns/promises', () => ({
|
|
15
|
+
lookup: vi.fn(),
|
|
16
|
+
}));
|
|
17
|
+
vi.mock('../../../src/lib/fetchers/common/utils.js', () => ({
|
|
18
|
+
getRandomUserAgent: vi.fn(() => 'test-agent'),
|
|
19
|
+
randomDelay: vi.fn(async () => undefined),
|
|
20
|
+
getSystemProxy: vi.fn(() => undefined),
|
|
21
|
+
}));
|
|
22
|
+
describe('HttpClient SSRF protection', () => {
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
vi.clearAllMocks();
|
|
25
|
+
vi.mocked(lookup).mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
|
|
26
|
+
});
|
|
27
|
+
test('blocks direct loopback requests before fetch is called', async () => {
|
|
28
|
+
await expect(HttpClient.fetchWithRedirects({
|
|
29
|
+
url: 'http://127.0.0.1:8080/latest/meta-data/',
|
|
30
|
+
useSystemProxy: false,
|
|
31
|
+
})).rejects.toThrow(UrlValidationError);
|
|
32
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
33
|
+
});
|
|
34
|
+
test('blocks redirect targets that point to private addresses', async () => {
|
|
35
|
+
vi.mocked(fetch).mockResolvedValueOnce({
|
|
36
|
+
status: 302,
|
|
37
|
+
statusText: 'Found',
|
|
38
|
+
headers: {
|
|
39
|
+
has: (name) => name.toLowerCase() === 'location',
|
|
40
|
+
get: () => 'http://127.0.0.1:8080/internal',
|
|
41
|
+
},
|
|
42
|
+
text: async () => '',
|
|
43
|
+
});
|
|
44
|
+
await expect(HttpClient.fetchWithRedirects({
|
|
45
|
+
url: 'https://example.com/redirect',
|
|
46
|
+
useSystemProxy: false,
|
|
47
|
+
})).rejects.toThrow(/Blocked IP address/);
|
|
48
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
File without changes
|
|
File without changes
|
package/dist/tests/i18n.test.js
CHANGED
|
File without changes
|
|
File without changes
|
|
File without changes
|
package/dist/tests/setup.js
CHANGED
|
File without changes
|
|
File without changes
|
|
File without changes
|
package/dist/tests/test-i18n.js
CHANGED
|
File without changes
|
|
File without changes
|
package/dist/tests/test-mcp.js
CHANGED
|
File without changes
|
|
File without changes
|
package/dist/tests/types.test.js
CHANGED
|
File without changes
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Author: Martin <lmccc.dev@gmail.com>
|
|
3
|
+
* Co-Author: AI Assistant (Claude)
|
|
4
|
+
* Description: Tests for SSRF URL validation.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, test, vi, beforeEach } from 'vitest';
|
|
7
|
+
import { lookup } from 'dns/promises';
|
|
8
|
+
import { validateFetchUrl, UrlValidationError } from '../../src/lib/utils/UrlValidator.js';
|
|
9
|
+
vi.mock('dns/promises', () => ({
|
|
10
|
+
lookup: vi.fn(),
|
|
11
|
+
}));
|
|
12
|
+
describe('UrlValidator', () => {
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
vi.clearAllMocks();
|
|
15
|
+
vi.mocked(lookup).mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
|
|
16
|
+
});
|
|
17
|
+
test('allows public https URLs after DNS resolution', async () => {
|
|
18
|
+
await expect(validateFetchUrl('https://example.com/path')).resolves.toBeUndefined();
|
|
19
|
+
expect(lookup).toHaveBeenCalledWith('example.com', { all: true, verbatim: true });
|
|
20
|
+
});
|
|
21
|
+
test('allows public http URLs', async () => {
|
|
22
|
+
await expect(validateFetchUrl('http://example.org')).resolves.toBeUndefined();
|
|
23
|
+
});
|
|
24
|
+
test('blocks non-http schemes', async () => {
|
|
25
|
+
await expect(validateFetchUrl('file:///etc/passwd')).rejects.toThrow(UrlValidationError);
|
|
26
|
+
await expect(validateFetchUrl('ftp://example.com')).rejects.toThrow(UrlValidationError);
|
|
27
|
+
});
|
|
28
|
+
test('blocks loopback IPv4 addresses', async () => {
|
|
29
|
+
await expect(validateFetchUrl('http://127.0.0.1/')).rejects.toThrow(/Blocked IP address/);
|
|
30
|
+
await expect(validateFetchUrl('http://127.0.0.1:8080/latest/meta-data/')).rejects.toThrow(UrlValidationError);
|
|
31
|
+
});
|
|
32
|
+
test('blocks cloud metadata IP addresses', async () => {
|
|
33
|
+
await expect(validateFetchUrl('http://169.254.169.254/latest/meta-data/')).rejects.toThrow(/Blocked IP address/);
|
|
34
|
+
});
|
|
35
|
+
test('blocks private IPv4 ranges', async () => {
|
|
36
|
+
await expect(validateFetchUrl('http://10.0.0.1/')).rejects.toThrow(UrlValidationError);
|
|
37
|
+
await expect(validateFetchUrl('http://192.168.1.1/')).rejects.toThrow(UrlValidationError);
|
|
38
|
+
await expect(validateFetchUrl('http://172.16.0.1/')).rejects.toThrow(UrlValidationError);
|
|
39
|
+
});
|
|
40
|
+
test('blocks localhost hostnames without DNS lookup', async () => {
|
|
41
|
+
await expect(validateFetchUrl('http://localhost/')).rejects.toThrow(/Blocked hostname/);
|
|
42
|
+
expect(lookup).not.toHaveBeenCalled();
|
|
43
|
+
});
|
|
44
|
+
test('blocks hostnames that resolve to private addresses', async () => {
|
|
45
|
+
vi.mocked(lookup).mockResolvedValue([{ address: '127.0.0.1', family: 4 }]);
|
|
46
|
+
await expect(validateFetchUrl('http://rebind.example/')).rejects.toThrow(/Blocked IP address resolved/);
|
|
47
|
+
});
|
|
48
|
+
test('blocks IPv6 loopback and link-local addresses', async () => {
|
|
49
|
+
await expect(validateFetchUrl('http://[::1]/')).rejects.toThrow(UrlValidationError);
|
|
50
|
+
await expect(validateFetchUrl('http://[fe80::1]/')).rejects.toThrow(UrlValidationError);
|
|
51
|
+
});
|
|
52
|
+
test('blocks IPv4-mapped loopback addresses', async () => {
|
|
53
|
+
await expect(validateFetchUrl('http://[::ffff:127.0.0.1]/')).rejects.toThrow(UrlValidationError);
|
|
54
|
+
});
|
|
55
|
+
test('rejects invalid URLs', async () => {
|
|
56
|
+
await expect(validateFetchUrl('not-a-url')).rejects.toThrow(/Invalid URL/);
|
|
57
|
+
});
|
|
58
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lmcc-dev/mult-fetch-mcp-server",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.3",
|
|
4
4
|
"description": "An MCP protocol-based web content fetching tool that supports multiple modes and formats, can be integrated with AI assistants like Claude",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -20,32 +20,6 @@
|
|
|
20
20
|
"engines": {
|
|
21
21
|
"node": ">=18"
|
|
22
22
|
},
|
|
23
|
-
"scripts": {
|
|
24
|
-
"build": "tsc && shx chmod +x dist/src/*.js dist/tests/*.js || true && shx chmod +x dist/*.js || true",
|
|
25
|
-
"prepare": "pnpm run build",
|
|
26
|
-
"dev": "tsc --watch",
|
|
27
|
-
"start": "node dist/index.js",
|
|
28
|
-
"test:mcp": "node dist/tests/test-mcp.js",
|
|
29
|
-
"test:mini4k": "node dist/tests/test-mini4k.js",
|
|
30
|
-
"test:direct": "node dist/tests/test-direct-client.js",
|
|
31
|
-
"test:methods": "node dist/tests/test-mcp-methods.js",
|
|
32
|
-
"test:i18n": "pnpm run build && node dist/tests/test-i18n.js",
|
|
33
|
-
"test:i18n:unused": "pnpm run build && node dist/tests/i18n-unused-keys.js",
|
|
34
|
-
"test:i18n:missing": "pnpm run build && node --loader ts-node/esm tests/i18n-missing-keys.ts",
|
|
35
|
-
"test": "vitest run",
|
|
36
|
-
"test:watch": "vitest",
|
|
37
|
-
"test:coverage": "vitest run --coverage",
|
|
38
|
-
"client": "node dist/src/client.js",
|
|
39
|
-
"server": "node dist/index.js",
|
|
40
|
-
"prepublishOnly": "pnpm run build",
|
|
41
|
-
"release": "pnpm publish --access public",
|
|
42
|
-
"codecov": "vitest run --coverage && codecov",
|
|
43
|
-
"test:ci": "vitest run --coverage && codecov",
|
|
44
|
-
"lint": "eslint . --ext .ts",
|
|
45
|
-
"lint:fix": "eslint . --ext .ts --fix --max-warnings=0",
|
|
46
|
-
"lint:src": "eslint src --ext .ts",
|
|
47
|
-
"lint:src:fix": "eslint src --ext .ts --fix"
|
|
48
|
-
},
|
|
49
23
|
"keywords": [
|
|
50
24
|
"mcp",
|
|
51
25
|
"model-context-protocol",
|
|
@@ -104,5 +78,29 @@
|
|
|
104
78
|
"README.zh.md",
|
|
105
79
|
"LICENSE",
|
|
106
80
|
"images"
|
|
107
|
-
]
|
|
81
|
+
],
|
|
82
|
+
"scripts": {
|
|
83
|
+
"build": "tsc && shx chmod +x dist/src/*.js dist/tests/*.js || true && shx chmod +x dist/*.js || true",
|
|
84
|
+
"dev": "tsc --watch",
|
|
85
|
+
"start": "node dist/index.js",
|
|
86
|
+
"test:mcp": "node dist/tests/test-mcp.js",
|
|
87
|
+
"test:mini4k": "node dist/tests/test-mini4k.js",
|
|
88
|
+
"test:direct": "node dist/tests/test-direct-client.js",
|
|
89
|
+
"test:methods": "node dist/tests/test-mcp-methods.js",
|
|
90
|
+
"test:i18n": "pnpm run build && node dist/tests/test-i18n.js",
|
|
91
|
+
"test:i18n:unused": "pnpm run build && node dist/tests/i18n-unused-keys.js",
|
|
92
|
+
"test:i18n:missing": "pnpm run build && node --loader ts-node/esm tests/i18n-missing-keys.ts",
|
|
93
|
+
"test": "vitest run",
|
|
94
|
+
"test:watch": "vitest",
|
|
95
|
+
"test:coverage": "vitest run --coverage",
|
|
96
|
+
"client": "node dist/src/client.js",
|
|
97
|
+
"server": "node dist/index.js",
|
|
98
|
+
"release": "pnpm publish --access public",
|
|
99
|
+
"codecov": "vitest run --coverage && codecov",
|
|
100
|
+
"test:ci": "vitest run --coverage && codecov",
|
|
101
|
+
"lint": "eslint . --ext .ts",
|
|
102
|
+
"lint:fix": "eslint . --ext .ts --fix --max-warnings=0",
|
|
103
|
+
"lint:src": "eslint src --ext .ts",
|
|
104
|
+
"lint:src:fix": "eslint src --ext .ts --fix"
|
|
105
|
+
}
|
|
108
106
|
}
|