forked from
nekomimi.pet/wisp.place-monorepo
Monorepo for Wisp.place. A static site hosting service built on top of the AT Protocol.
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 = 100 * 1024 * 1024; // 100MB
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}