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}