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 31// Retry configuration 32const MAX_RETRIES = 3; 33const INITIAL_RETRY_DELAY = 1000; // 1 second 34const MAX_RETRY_DELAY = 10000; // 10 seconds 35 36function isBlockedHost(hostname: string): boolean { 37 const lowerHost = hostname.toLowerCase(); 38 39 if (BLOCKED_HOSTS.includes(lowerHost)) { 40 return true; 41 } 42 43 for (const pattern of BLOCKED_IP_RANGES) { 44 if (pattern.test(lowerHost)) { 45 return true; 46 } 47 } 48 49 return false; 50} 51 52/** 53 * Check if an error is retryable (network/SSL errors, not HTTP errors) 54 */ 55function isRetryableError(err: unknown): boolean { 56 if (!(err instanceof Error)) return false; 57 58 // Network errors (ECONNRESET, ENOTFOUND, etc.) 59 const errorCode = (err as any).code; 60 if (errorCode) { 61 const retryableCodes = [ 62 'ECONNRESET', 63 'ECONNREFUSED', 64 'ETIMEDOUT', 65 'ENOTFOUND', 66 'ENETUNREACH', 67 'EAI_AGAIN', 68 'EPIPE', 69 'ERR_SSL_TLSV1_ALERT_INTERNAL_ERROR', // SSL/TLS handshake failures 70 'ERR_SSL_WRONG_VERSION_NUMBER', 71 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', 72 ]; 73 if (retryableCodes.includes(errorCode)) { 74 return true; 75 } 76 } 77 78 // Timeout errors 79 if (err.name === 'AbortError' || err.message.includes('timeout')) { 80 return true; 81 } 82 83 // Fetch failures (generic network errors) 84 if (err.message.includes('fetch failed')) { 85 return true; 86 } 87 88 return false; 89} 90 91/** 92 * Sleep for a given number of milliseconds 93 */ 94function sleep(ms: number): Promise<void> { 95 return new Promise(resolve => setTimeout(resolve, ms)); 96} 97 98/** 99 * Retry a function with exponential backoff 100 */ 101async function withRetry<T>( 102 fn: () => Promise<T>, 103 options: { maxRetries?: number; initialDelay?: number; maxDelay?: number; context?: string } = {} 104): Promise<T> { 105 const maxRetries = options.maxRetries ?? MAX_RETRIES; 106 const initialDelay = options.initialDelay ?? INITIAL_RETRY_DELAY; 107 const maxDelay = options.maxDelay ?? MAX_RETRY_DELAY; 108 const context = options.context ?? 'Request'; 109 110 let lastError: unknown; 111 112 for (let attempt = 0; attempt <= maxRetries; attempt++) { 113 try { 114 return await fn(); 115 } catch (err) { 116 lastError = err; 117 118 // Don't retry if this is the last attempt or error is not retryable 119 if (attempt === maxRetries || !isRetryableError(err)) { 120 throw err; 121 } 122 123 // Calculate delay with exponential backoff 124 const delay = Math.min(initialDelay * Math.pow(2, attempt), maxDelay); 125 126 const errorCode = (err as any)?.code; 127 const errorMsg = err instanceof Error ? err.message : String(err); 128 console.warn( 129 `${context} failed (attempt ${attempt + 1}/${maxRetries + 1}): ${errorMsg}${errorCode ? ` [${errorCode}]` : ''} - retrying in ${delay}ms` 130 ); 131 132 await sleep(delay); 133 } 134 } 135 136 throw lastError; 137} 138 139export async function safeFetch( 140 url: string, 141 options?: RequestInit & { maxSize?: number; timeout?: number; retry?: boolean } 142): Promise<Response> { 143 const shouldRetry = options?.retry !== false; // Default to true 144 const timeoutMs = options?.timeout ?? FETCH_TIMEOUT; 145 const maxSize = options?.maxSize ?? MAX_RESPONSE_SIZE; 146 147 // Parse and validate URL (done once, outside retry loop) 148 let parsedUrl: URL; 149 try { 150 parsedUrl = new URL(url); 151 } catch (err) { 152 throw new Error(`Invalid URL: ${url}`); 153 } 154 155 if (!['http:', 'https:'].includes(parsedUrl.protocol)) { 156 throw new Error(`Blocked protocol: ${parsedUrl.protocol}`); 157 } 158 159 const hostname = parsedUrl.hostname; 160 if (isBlockedHost(hostname)) { 161 throw new Error(`Blocked host: ${hostname}`); 162 } 163 164 const fetchFn = async () => { 165 const controller = new AbortController(); 166 const timeoutId = setTimeout(() => controller.abort(), timeoutMs); 167 168 try { 169 const response = await fetch(url, { 170 ...options, 171 signal: controller.signal, 172 redirect: 'follow', 173 headers: { 174 'User-Agent': 'wisp-place hosting-service', 175 ...(options?.headers || {}), 176 }, 177 }); 178 179 const contentLength = response.headers.get('content-length'); 180 if (contentLength && parseInt(contentLength, 10) > maxSize) { 181 throw new Error(`Response too large: ${contentLength} bytes`); 182 } 183 184 return response; 185 } catch (err) { 186 if (err instanceof Error && err.name === 'AbortError') { 187 throw new Error(`Request timeout after ${timeoutMs}ms`); 188 } 189 throw err; 190 } finally { 191 clearTimeout(timeoutId); 192 } 193 }; 194 195 if (shouldRetry) { 196 return withRetry(fetchFn, { context: `Fetch ${parsedUrl.hostname}` }); 197 } else { 198 return fetchFn(); 199 } 200} 201 202export async function safeFetchJson<T = any>( 203 url: string, 204 options?: RequestInit & { maxSize?: number; timeout?: number; retry?: boolean } 205): Promise<T> { 206 const maxJsonSize = options?.maxSize ?? MAX_JSON_SIZE; 207 const response = await safeFetch(url, { ...options, maxSize: maxJsonSize }); 208 209 if (!response.ok) { 210 throw new Error(`HTTP ${response.status}: ${response.statusText}`); 211 } 212 213 const reader = response.body?.getReader(); 214 if (!reader) { 215 throw new Error('No response body'); 216 } 217 218 const chunks: Uint8Array[] = []; 219 let totalSize = 0; 220 221 try { 222 while (true) { 223 const { done, value } = await reader.read(); 224 if (done) break; 225 226 totalSize += value.length; 227 if (totalSize > maxJsonSize) { 228 throw new Error(`Response exceeds max size: ${maxJsonSize} bytes`); 229 } 230 231 chunks.push(value); 232 } 233 } finally { 234 reader.releaseLock(); 235 } 236 237 const combined = new Uint8Array(totalSize); 238 let offset = 0; 239 for (const chunk of chunks) { 240 combined.set(chunk, offset); 241 offset += chunk.length; 242 } 243 244 const text = new TextDecoder().decode(combined); 245 return JSON.parse(text); 246} 247 248export async function safeFetchBlob( 249 url: string, 250 options?: RequestInit & { maxSize?: number; timeout?: number; retry?: boolean } 251): Promise<Uint8Array> { 252 const maxBlobSize = options?.maxSize ?? MAX_BLOB_SIZE; 253 const timeoutMs = options?.timeout ?? FETCH_TIMEOUT_BLOB; 254 const response = await safeFetch(url, { ...options, maxSize: maxBlobSize, timeout: timeoutMs }); 255 256 if (!response.ok) { 257 throw new Error(`HTTP ${response.status}: ${response.statusText}`); 258 } 259 260 const reader = response.body?.getReader(); 261 if (!reader) { 262 throw new Error('No response body'); 263 } 264 265 const chunks: Uint8Array[] = []; 266 let totalSize = 0; 267 268 try { 269 while (true) { 270 const { done, value } = await reader.read(); 271 if (done) break; 272 273 totalSize += value.length; 274 if (totalSize > maxBlobSize) { 275 throw new Error(`Blob exceeds max size: ${maxBlobSize} bytes`); 276 } 277 278 chunks.push(value); 279 } 280 } finally { 281 reader.releaseLock(); 282 } 283 284 const combined = new Uint8Array(totalSize); 285 let offset = 0; 286 for (const chunk of chunks) { 287 combined.set(chunk, offset); 288 offset += chunk.length; 289 } 290 291 return combined; 292}