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}