feat(cors-proxy): add anti-spoofing security measures
Security improvements for the Cloudflare Worker CORS proxy: - Add rate limiting per IP (60 requests/minute) using Cloudflare KV - Add file size limit (10MB max) to prevent abuse - Add HMAC signature verification (optional, for deterrence) - Add timestamp validation to prevent replay attacks - Block private IP ranges (localhost, 10.x, 192.168.x, 172.16-31.x) Client-side changes: - Add signature generation in digital-sign-pdf.ts - Add security warning about client-side secrets Documentation: - Update README with production security features - Update docs/self-hosting/cloudflare.md with CORS proxy section - Document KV setup for rate limiting - Add clear warnings about client-side HMAC limitations Files changed: - cloudflare/cors-proxy-worker.js - cloudflare/wrangler.toml - src/js/logic/digital-sign-pdf.ts - README.md - docs/self-hosting/cloudflare.md
This commit is contained in:
@@ -5,9 +5,11 @@
|
||||
* It fetches certificates from external CAs that don't have CORS headers enabled
|
||||
* and returns them with proper CORS headers.
|
||||
*
|
||||
* Security: Only allows fetching certificate-related URLs
|
||||
*
|
||||
* Deploy: npx wrangler deploy
|
||||
*
|
||||
* Required Environment Variables (set in wrangler.toml or Cloudflare dashboard):
|
||||
* - PROXY_SECRET: Shared secret for HMAC signature verification
|
||||
*/
|
||||
|
||||
const ALLOWED_PATTERNS = [
|
||||
@@ -31,6 +33,62 @@ const BLOCKED_DOMAINS = [
|
||||
'0.0.0.0',
|
||||
];
|
||||
|
||||
|
||||
const MAX_TIMESTAMP_AGE_MS = 5 * 60 * 1000;
|
||||
|
||||
const RATE_LIMIT_MAX_REQUESTS = 60;
|
||||
const RATE_LIMIT_WINDOW_MS = 60 * 1000;
|
||||
|
||||
const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
async function verifySignature(message, signature, secret) {
|
||||
try {
|
||||
const encoder = new TextEncoder();
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['verify']
|
||||
);
|
||||
|
||||
const signatureBytes = new Uint8Array(
|
||||
signature.match(/.{1,2}/g).map(byte => parseInt(byte, 16))
|
||||
);
|
||||
|
||||
return await crypto.subtle.verify(
|
||||
'HMAC',
|
||||
key,
|
||||
signatureBytes,
|
||||
encoder.encode(message)
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('Signature verification error:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function generateSignature(message, secret) {
|
||||
const encoder = new TextEncoder();
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
);
|
||||
|
||||
const signature = await crypto.subtle.sign(
|
||||
'HMAC',
|
||||
key,
|
||||
encoder.encode(message)
|
||||
);
|
||||
|
||||
return Array.from(new Uint8Array(signature))
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
function isAllowedOrigin(origin) {
|
||||
if (!origin) return false;
|
||||
return ALLOWED_ORIGINS.some(allowed => origin.startsWith(allowed.replace(/\/$/, '')));
|
||||
@@ -86,7 +144,7 @@ export default {
|
||||
if (request.method === 'OPTIONS') {
|
||||
return handleOptions(request);
|
||||
}
|
||||
|
||||
|
||||
// NOTE: If you are selfhosting this proxy, you can remove this check, or can set it to only accept requests from your own domain
|
||||
if (!isAllowedOrigin(origin)) {
|
||||
return new Response(JSON.stringify({
|
||||
@@ -108,6 +166,54 @@ export default {
|
||||
}
|
||||
|
||||
const targetUrl = url.searchParams.get('url');
|
||||
const timestamp = url.searchParams.get('t');
|
||||
const signature = url.searchParams.get('sig');
|
||||
|
||||
if (env.PROXY_SECRET) {
|
||||
if (!timestamp || !signature) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Missing authentication parameters',
|
||||
message: 'Request must include timestamp (t) and signature (sig) parameters',
|
||||
}), {
|
||||
status: 401,
|
||||
headers: {
|
||||
...corsHeaders(origin),
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const requestTime = parseInt(timestamp, 10);
|
||||
const now = Date.now();
|
||||
if (isNaN(requestTime) || Math.abs(now - requestTime) > MAX_TIMESTAMP_AGE_MS) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Request expired or invalid timestamp',
|
||||
message: 'Timestamp must be within 5 minutes of current time',
|
||||
}), {
|
||||
status: 401,
|
||||
headers: {
|
||||
...corsHeaders(origin),
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const message = `${targetUrl}${timestamp}`;
|
||||
const isValid = await verifySignature(message, signature, env.PROXY_SECRET);
|
||||
|
||||
if (!isValid) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Invalid signature',
|
||||
message: 'Request signature verification failed',
|
||||
}), {
|
||||
status: 401,
|
||||
headers: {
|
||||
...corsHeaders(origin),
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetUrl) {
|
||||
return new Response(JSON.stringify({
|
||||
@@ -135,6 +241,37 @@ export default {
|
||||
});
|
||||
}
|
||||
|
||||
const clientIP = request.headers.get('CF-Connecting-IP') || 'unknown';
|
||||
const rateLimitKey = `ratelimit:${clientIP}`;
|
||||
const now = Date.now();
|
||||
|
||||
if (env.RATE_LIMIT_KV) {
|
||||
const rateLimitData = await env.RATE_LIMIT_KV.get(rateLimitKey, { type: 'json' });
|
||||
const requests = rateLimitData?.requests || [];
|
||||
|
||||
const recentRequests = requests.filter(t => now - t < RATE_LIMIT_WINDOW_MS);
|
||||
|
||||
if (recentRequests.length >= RATE_LIMIT_MAX_REQUESTS) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Rate limit exceeded',
|
||||
message: `Maximum ${RATE_LIMIT_MAX_REQUESTS} requests per minute. Please try again later.`,
|
||||
retryAfter: Math.ceil((recentRequests[0] + RATE_LIMIT_WINDOW_MS - now) / 1000),
|
||||
}), {
|
||||
status: 429,
|
||||
headers: {
|
||||
...corsHeaders(origin),
|
||||
'Content-Type': 'application/json',
|
||||
'Retry-After': Math.ceil((recentRequests[0] + RATE_LIMIT_WINDOW_MS - now) / 1000).toString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
recentRequests.push(now);
|
||||
await env.RATE_LIMIT_KV.put(rateLimitKey, JSON.stringify({ requests: recentRequests }), {
|
||||
expirationTtl: 120,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(targetUrl, {
|
||||
headers: {
|
||||
@@ -156,8 +293,39 @@ export default {
|
||||
});
|
||||
}
|
||||
|
||||
const contentLength = parseInt(response.headers.get('Content-Length') || '0', 10);
|
||||
if (contentLength > MAX_FILE_SIZE_BYTES) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'File too large',
|
||||
message: `Certificate file exceeds maximum size of ${MAX_FILE_SIZE_BYTES / 1024}KB`,
|
||||
size: contentLength,
|
||||
maxSize: MAX_FILE_SIZE_BYTES,
|
||||
}), {
|
||||
status: 413,
|
||||
headers: {
|
||||
...corsHeaders(origin),
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const certData = await response.arrayBuffer();
|
||||
|
||||
if (certData.byteLength > MAX_FILE_SIZE_BYTES) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'File too large',
|
||||
message: `Certificate file exceeds maximum size of ${MAX_FILE_SIZE_BYTES / 1024}KB`,
|
||||
size: certData.byteLength,
|
||||
maxSize: MAX_FILE_SIZE_BYTES,
|
||||
}), {
|
||||
status: 413,
|
||||
headers: {
|
||||
...corsHeaders(origin),
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(certData, {
|
||||
status: 200,
|
||||
headers: {
|
||||
|
||||
Reference in New Issue
Block a user