Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place
at redirects 4.9 kB view raw
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 }); 80 81 const contentLength = response.headers.get('content-length'); 82 if (contentLength && parseInt(contentLength, 10) > maxSize) { 83 throw new Error(`Response too large: ${contentLength} bytes`); 84 } 85 86 return response; 87 } catch (err) { 88 if (err instanceof Error && err.name === 'AbortError') { 89 throw new Error(`Request timeout after ${timeoutMs}ms`); 90 } 91 throw err; 92 } finally { 93 clearTimeout(timeoutId); 94 } 95} 96 97export async function safeFetchJson<T = any>( 98 url: string, 99 options?: RequestInit & { maxSize?: number; timeout?: number } 100): Promise<T> { 101 const maxJsonSize = options?.maxSize ?? MAX_JSON_SIZE; 102 const response = await safeFetch(url, { ...options, maxSize: maxJsonSize }); 103 104 if (!response.ok) { 105 throw new Error(`HTTP ${response.status}: ${response.statusText}`); 106 } 107 108 const reader = response.body?.getReader(); 109 if (!reader) { 110 throw new Error('No response body'); 111 } 112 113 const chunks: Uint8Array[] = []; 114 let totalSize = 0; 115 116 try { 117 while (true) { 118 const { done, value } = await reader.read(); 119 if (done) break; 120 121 totalSize += value.length; 122 if (totalSize > maxJsonSize) { 123 throw new Error(`Response exceeds max size: ${maxJsonSize} bytes`); 124 } 125 126 chunks.push(value); 127 } 128 } finally { 129 reader.releaseLock(); 130 } 131 132 const combined = new Uint8Array(totalSize); 133 let offset = 0; 134 for (const chunk of chunks) { 135 combined.set(chunk, offset); 136 offset += chunk.length; 137 } 138 139 const text = new TextDecoder().decode(combined); 140 return JSON.parse(text); 141} 142 143export async function safeFetchBlob( 144 url: string, 145 options?: RequestInit & { maxSize?: number; timeout?: number } 146): Promise<Uint8Array> { 147 const maxBlobSize = options?.maxSize ?? MAX_BLOB_SIZE; 148 const timeoutMs = options?.timeout ?? FETCH_TIMEOUT_BLOB; 149 const response = await safeFetch(url, { ...options, maxSize: maxBlobSize, timeout: timeoutMs }); 150 151 if (!response.ok) { 152 throw new Error(`HTTP ${response.status}: ${response.statusText}`); 153 } 154 155 const reader = response.body?.getReader(); 156 if (!reader) { 157 throw new Error('No response body'); 158 } 159 160 const chunks: Uint8Array[] = []; 161 let totalSize = 0; 162 163 try { 164 while (true) { 165 const { done, value } = await reader.read(); 166 if (done) break; 167 168 totalSize += value.length; 169 if (totalSize > maxBlobSize) { 170 throw new Error(`Blob exceeds max size: ${maxBlobSize} bytes`); 171 } 172 173 chunks.push(value); 174 } 175 } finally { 176 reader.releaseLock(); 177 } 178 179 const combined = new Uint8Array(totalSize); 180 let offset = 0; 181 for (const chunk of chunks) { 182 combined.set(chunk, offset); 183 offset += chunk.length; 184 } 185 186 return combined; 187}