Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place
1/** 2 * SSRF-hardened fetch utility 3 * Prevents requests to private networks, localhost, and enforces timeouts/size limits 4 */ 5 6const BLOCKED_IP_RANGES = [ 7 /^127\./, // 127.0.0.0/8 - Loopback 8 /^10\./, // 10.0.0.0/8 - Private 9 /^172\.(1[6-9]|2\d|3[01])\./, // 172.16.0.0/12 - Private 10 /^192\.168\./, // 192.168.0.0/16 - Private 11 /^169\.254\./, // 169.254.0.0/16 - Link-local 12 /^::1$/, // IPv6 loopback 13 /^fe80:/, // IPv6 link-local 14 /^fc00:/, // IPv6 unique local 15 /^fd00:/, // IPv6 unique local 16]; 17 18const BLOCKED_HOSTS = [ 19 'localhost', 20 'metadata.google.internal', 21 '169.254.169.254', 22]; 23 24const FETCH_TIMEOUT = 120000; // 120 seconds 25const FETCH_TIMEOUT_BLOB = 120000; // 2 minutes for blob downloads 26const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB 27const MAX_JSON_SIZE = 1024 * 1024; // 1MB 28const MAX_BLOB_SIZE = 500 * 1024 * 1024; // 500MB 29const MAX_REDIRECTS = 10; 30 31function isBlockedHost(hostname: string): boolean { 32 const lowerHost = hostname.toLowerCase(); 33 34 if (BLOCKED_HOSTS.includes(lowerHost)) { 35 return true; 36 } 37 38 for (const pattern of BLOCKED_IP_RANGES) { 39 if (pattern.test(lowerHost)) { 40 return true; 41 } 42 } 43 44 return false; 45} 46 47export async function safeFetch( 48 url: string, 49 options?: RequestInit & { maxSize?: number; timeout?: number } 50): Promise<Response> { 51 const timeoutMs = options?.timeout ?? FETCH_TIMEOUT; 52 const maxSize = options?.maxSize ?? MAX_RESPONSE_SIZE; 53 54 // Parse and validate URL 55 let parsedUrl: URL; 56 try { 57 parsedUrl = new URL(url); 58 } catch (err) { 59 throw new Error(`Invalid URL: ${url}`); 60 } 61 62 if (!['http:', 'https:'].includes(parsedUrl.protocol)) { 63 throw new Error(`Blocked protocol: ${parsedUrl.protocol}`); 64 } 65 66 const hostname = parsedUrl.hostname; 67 if (isBlockedHost(hostname)) { 68 throw new Error(`Blocked host: ${hostname}`); 69 } 70 71 const controller = new AbortController(); 72 const timeoutId = setTimeout(() => controller.abort(), timeoutMs); 73 74 try { 75 const response = await fetch(url, { 76 ...options, 77 signal: controller.signal, 78 redirect: 'follow', 79 headers: { 80 'User-Agent': 'wisp-place hosting-service', 81 ...(options?.headers || {}), 82 }, 83 }); 84 85 const contentLength = response.headers.get('content-length'); 86 if (contentLength && parseInt(contentLength, 10) > maxSize) { 87 throw new Error(`Response too large: ${contentLength} bytes`); 88 } 89 90 return response; 91 } catch (err) { 92 if (err instanceof Error && err.name === 'AbortError') { 93 throw new Error(`Request timeout after ${timeoutMs}ms`); 94 } 95 throw err; 96 } finally { 97 clearTimeout(timeoutId); 98 } 99} 100 101export async function safeFetchJson<T = any>( 102 url: string, 103 options?: RequestInit & { maxSize?: number; timeout?: number } 104): Promise<T> { 105 const maxJsonSize = options?.maxSize ?? MAX_JSON_SIZE; 106 const response = await safeFetch(url, { ...options, maxSize: maxJsonSize }); 107 108 if (!response.ok) { 109 throw new Error(`HTTP ${response.status}: ${response.statusText}`); 110 } 111 112 const reader = response.body?.getReader(); 113 if (!reader) { 114 throw new Error('No response body'); 115 } 116 117 const chunks: Uint8Array[] = []; 118 let totalSize = 0; 119 120 try { 121 while (true) { 122 const { done, value } = await reader.read(); 123 if (done) break; 124 125 totalSize += value.length; 126 if (totalSize > maxJsonSize) { 127 throw new Error(`Response exceeds max size: ${maxJsonSize} bytes`); 128 } 129 130 chunks.push(value); 131 } 132 } finally { 133 reader.releaseLock(); 134 } 135 136 const combined = new Uint8Array(totalSize); 137 let offset = 0; 138 for (const chunk of chunks) { 139 combined.set(chunk, offset); 140 offset += chunk.length; 141 } 142 143 const text = new TextDecoder().decode(combined); 144 return JSON.parse(text); 145} 146 147export async function safeFetchBlob( 148 url: string, 149 options?: RequestInit & { maxSize?: number; timeout?: number } 150): Promise<Uint8Array> { 151 const maxBlobSize = options?.maxSize ?? MAX_BLOB_SIZE; 152 const timeoutMs = options?.timeout ?? FETCH_TIMEOUT_BLOB; 153 const response = await safeFetch(url, { ...options, maxSize: maxBlobSize, timeout: timeoutMs }); 154 155 if (!response.ok) { 156 throw new Error(`HTTP ${response.status}: ${response.statusText}`); 157 } 158 159 const reader = response.body?.getReader(); 160 if (!reader) { 161 throw new Error('No response body'); 162 } 163 164 const chunks: Uint8Array[] = []; 165 let totalSize = 0; 166 167 try { 168 while (true) { 169 const { done, value } = await reader.read(); 170 if (done) break; 171 172 totalSize += value.length; 173 if (totalSize > maxBlobSize) { 174 throw new Error(`Blob exceeds max size: ${maxBlobSize} bytes`); 175 } 176 177 chunks.push(value); 178 } 179 } finally { 180 reader.releaseLock(); 181 } 182 183 const combined = new Uint8Array(totalSize); 184 let offset = 0; 185 for (const chunk of chunks) { 186 combined.set(chunk, offset); 187 offset += chunk.length; 188 } 189 190 return combined; 191}