Skip to content
This repository was archived by the owner on Jan 14, 2026. It is now read-only.

Commit b1b2dd9

Browse files
Security improvements
1 parent ee5e64e commit b1b2dd9

File tree

1 file changed

+81
-9
lines changed

1 file changed

+81
-9
lines changed

server.js

Lines changed: 81 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,22 +37,94 @@ export default {
3737
}
3838

3939
if (url.pathname.startsWith("/api/v1/image-proxy")) {
40-
const url = new URL(request.url)
4140
const originalUrl = url.searchParams.get("url")
4241
if (!originalUrl) {
43-
return new Response("Missing url", { status: 400 })
42+
return new Response("Missing url parameter", { status: 400 })
4443
}
4544

46-
let cache = caches.default
47-
const cacheKey = originalUrl
48-
const cachedResponse = await cache.match(cacheKey)
45+
// Security: Validate URL to prevent SSRF attacks
46+
const ALLOWED_DOMAINS = ['cdn.sanity.io', 'sanity.io'];
47+
let parsedUrl;
48+
try {
49+
parsedUrl = new URL(originalUrl);
50+
// Only allow HTTPS URLs from Sanity domains
51+
if (parsedUrl.protocol !== 'https:') {
52+
return new Response("Only HTTPS URLs are allowed", { status: 403 });
53+
}
54+
const isAllowed = ALLOWED_DOMAINS.some(domain =>
55+
parsedUrl.hostname === domain || parsedUrl.hostname.endsWith(`.${domain}`)
56+
);
57+
if (!isAllowed) {
58+
return new Response("URL not allowed", { status: 403 });
59+
}
60+
} catch (error) {
61+
return new Response("Invalid URL", { status: 400 });
62+
}
63+
64+
// Use cache with proper key
65+
const cache = caches.default;
66+
const cacheKey = new Request(originalUrl, { method: 'GET' });
67+
const cachedResponse = await cache.match(cacheKey);
4968
if (cachedResponse) {
50-
return cachedResponse
69+
// Add cache hit header
70+
const headers = new Headers(cachedResponse.headers);
71+
headers.set('X-Cache', 'HIT');
72+
return new Response(cachedResponse.body, {
73+
status: cachedResponse.status,
74+
headers
75+
});
5176
}
5277

53-
const response = await fetch(originalUrl, request)
54-
cache.put(cacheKey, response.clone())
55-
return response
78+
// Fetch with timeout and error handling
79+
try {
80+
const controller = new AbortController();
81+
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout
82+
83+
const response = await fetch(originalUrl, {
84+
signal: controller.signal,
85+
headers: {
86+
'User-Agent': 'CloudflareWorkers-ImageProxy/1.0'
87+
}
88+
});
89+
90+
clearTimeout(timeoutId);
91+
92+
// Validate response
93+
if (!response.ok) {
94+
return new Response(`Upstream error: ${response.status}`, {
95+
status: response.status
96+
});
97+
}
98+
99+
// Validate content type is an image
100+
const contentType = response.headers.get('content-type');
101+
if (!contentType || !contentType.startsWith('image/')) {
102+
return new Response("Invalid content type - only images are allowed", {
103+
status: 400
104+
});
105+
}
106+
107+
// Add cache headers
108+
const headers = new Headers(response.headers);
109+
headers.set('Cache-Control', 'public, max-age=86400'); // 24 hours
110+
headers.set('X-Cache', 'MISS');
111+
112+
const newResponse = new Response(response.body, {
113+
status: response.status,
114+
headers
115+
});
116+
117+
// Store in cache
118+
ctx.waitUntil(cache.put(cacheKey, newResponse.clone()));
119+
120+
return newResponse;
121+
} catch (error) {
122+
console.error('Image proxy error:', error);
123+
if (error.name === 'AbortError') {
124+
return new Response("Request timeout", { status: 504 });
125+
}
126+
return new Response("Proxy error", { status: 500 });
127+
}
56128
}
57129

58130

0 commit comments

Comments
 (0)