image-fetch.mjs
113 lines 3.2 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Secure image fetcher for MCP image resources (Phase 18A).
3 * Fetches an image URL and returns base64 blob with MIME type.
4 * Defences: HTTPS-only, SSRF blocklist, size cap, timeout, Content-Type validation.
5 */
6
7 import dns from 'node:dns/promises';
8
9 const DEFAULT_MAX_BYTES = 10 * 1024 * 1024; // 10 MB
10 const DEFAULT_TIMEOUT_MS = 8000;
11
12 const PRIVATE_RANGES = [
13 /^127\./,
14 /^10\./,
15 /^172\.(1[6-9]|2\d|3[01])\./,
16 /^192\.168\./,
17 /^169\.254\./,
18 /^0\./,
19 /^100\.(6[4-9]|[7-9]\d|1[0-2]\d)\./,
20 /^::1$/,
21 /^fe80:/i,
22 /^fc00:/i,
23 /^fd/i,
24 ];
25
26 function isPrivateIp(ip) {
27 if (!ip) return true;
28 return PRIVATE_RANGES.some((r) => r.test(ip));
29 }
30
31 /**
32 * @param {string} url
33 * @param {{ maxBytes?: number, timeoutMs?: number }} [opts]
34 * @returns {Promise<{ blob: string, mimeType: string, byteLength: number }>}
35 */
36 export async function fetchImageAsBase64(url, opts = {}) {
37 const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
38 const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
39
40 if (typeof url !== 'string' || !url.startsWith('https://')) {
41 throw new Error('Only https:// image URLs are allowed');
42 }
43
44 let hostname;
45 try {
46 hostname = new URL(url).hostname;
47 } catch {
48 throw new Error('Invalid URL');
49 }
50
51 if (/^localhost$/i.test(hostname) || hostname === '[::1]') {
52 throw new Error('Requests to localhost are blocked (SSRF protection)');
53 }
54
55 try {
56 const { address } = await dns.lookup(hostname);
57 if (isPrivateIp(address)) {
58 throw new Error(`Requests to private IP ranges are blocked (resolved ${hostname} -> ${address})`);
59 }
60 } catch (e) {
61 if (e.message && e.message.includes('blocked')) throw e;
62 throw new Error(`DNS resolution failed for ${hostname}: ${e.message || e}`);
63 }
64
65 const controller = new AbortController();
66 const timer = setTimeout(() => controller.abort(), timeoutMs);
67
68 let response;
69 try {
70 response = await fetch(url, {
71 signal: controller.signal,
72 headers: {
73 'User-Agent': 'Knowtation-MCP/1.0',
74 Accept: 'image/*',
75 },
76 redirect: 'follow',
77 });
78 } catch (e) {
79 clearTimeout(timer);
80 if (e.name === 'AbortError') throw new Error(`Image fetch timed out after ${timeoutMs}ms`);
81 throw new Error(`Image fetch failed: ${e.message || e}`);
82 }
83
84 clearTimeout(timer);
85
86 if (!response.ok) {
87 throw new Error(`Image fetch returned HTTP ${response.status}`);
88 }
89
90 const contentType = response.headers.get('content-type') || '';
91 if (!contentType.startsWith('image/')) {
92 throw new Error(`Expected image/* Content-Type, got: ${contentType}`);
93 }
94
95 const contentLength = response.headers.get('content-length');
96 if (contentLength && parseInt(contentLength, 10) > maxBytes) {
97 throw new Error(`Image exceeds size limit (${contentLength} bytes > ${maxBytes} bytes)`);
98 }
99
100 const arrayBuf = await response.arrayBuffer();
101 if (arrayBuf.byteLength > maxBytes) {
102 throw new Error(`Image exceeds size limit (${arrayBuf.byteLength} bytes > ${maxBytes} bytes)`);
103 }
104
105 const buffer = Buffer.from(arrayBuf);
106 const blob = buffer.toString('base64');
107
108 const mimeType = contentType.split(';')[0].trim();
109
110 return { blob, mimeType, byteLength: buffer.byteLength };
111 }
112
113 export { DEFAULT_MAX_BYTES, DEFAULT_TIMEOUT_MS };
File History 2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor 1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6 docs: accept Calendar Events v0 spec with Phase 0 security … Human 1 day ago