Files
bentopdf/cloudflare/cors-proxy-worker.js

456 lines
11 KiB
JavaScript

/**
* BentoPDF CORS Proxy Worker
*
* This Cloudflare Worker proxies certificate requests for the digital signing tool.
* It fetches certificates from external CAs that don't have CORS headers enabled
* and returns them with proper CORS headers.
*
*
* Deploy: npx wrangler deploy
*
* Required Environment Variables (set in wrangler.toml or Cloudflare dashboard):
* - PROXY_SECRET: Shared secret for HMAC signature verification
*/
const ALLOWED_PATH_PATTERNS = [
/\.crt$/i,
/\.cer$/i,
/\.pem$/i,
/\/certs\//i,
/\/ocsp/i,
/\/crl/i,
/caIssuers/i,
];
const ALLOWED_ORIGINS = ['https://www.bentopdf.com', 'https://bentopdf.com'];
const SAFE_CONTENT_TYPES = [
'application/x-x509-ca-cert',
'application/pkix-cert',
'application/x-pem-file',
'application/pkcs7-mime',
'application/octet-stream',
'text/plain',
];
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;
}
}
function isAllowedOrigin(origin) {
if (!origin) return false;
return ALLOWED_ORIGINS.includes(origin);
}
function isPrivateOrReservedHost(hostname) {
if (
/^10\./.test(hostname) ||
/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(hostname) ||
/^192\.168\./.test(hostname) ||
/^169\.254\./.test(hostname) || // link-local (cloud metadata)
/^100\.(6[4-9]|[7-9]\d|1[0-1]\d|12[0-7])\./.test(hostname) || // CGNAT
/^127\./.test(hostname) ||
/^0\./.test(hostname)
) {
return true;
}
if (/^\d+$/.test(hostname)) {
return true;
}
const lower = hostname.toLowerCase().replace(/^\[|\]$/g, '');
if (
lower === '::1' ||
lower.startsWith('::ffff:') ||
lower.startsWith('fe80') ||
lower.startsWith('fc') ||
lower.startsWith('fd') ||
lower.startsWith('ff')
) {
return true;
}
const blockedNames = [
'localhost',
'localhost.localdomain',
'0.0.0.0',
'[::1]',
];
if (blockedNames.includes(hostname.toLowerCase())) {
return true;
}
return false;
}
function isValidCertificateUrl(urlString) {
try {
const url = new URL(urlString);
if (!['http:', 'https:'].includes(url.protocol)) {
return false;
}
if (isPrivateOrReservedHost(url.hostname)) {
return false;
}
return ALLOWED_PATH_PATTERNS.some((pattern) => pattern.test(url.pathname));
} catch {
return false;
}
}
function getSafeContentType(upstreamContentType) {
if (!upstreamContentType) return 'application/octet-stream';
const match = SAFE_CONTENT_TYPES.find((ct) =>
upstreamContentType.startsWith(ct)
);
return match || 'application/octet-stream';
}
function corsHeaders(origin) {
return {
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Max-Age': '86400',
};
}
function handleOptions(request) {
const origin = request.headers.get('Origin');
if (!isAllowedOrigin(origin)) {
return new Response(null, { status: 403 });
}
return new Response(null, {
status: 204,
headers: corsHeaders(origin),
});
}
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
const origin = request.headers.get('Origin');
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({
error: 'Forbidden',
message: 'This proxy only accepts requests from allowed origins',
}),
{
status: 403,
headers: {
'Content-Type': 'application/json',
},
}
);
}
if (request.method !== 'GET') {
return new Response('Method not allowed', {
status: 405,
headers: corsHeaders(origin),
});
}
const targetUrl = url.searchParams.get('url');
if (!targetUrl) {
return new Response(
JSON.stringify({
error: 'Missing url parameter',
usage: 'GET /?url=<certificate_url>',
}),
{
status: 400,
headers: {
...corsHeaders(origin),
'Content-Type': 'application/json',
},
}
);
}
if (!isValidCertificateUrl(targetUrl)) {
return new Response(
JSON.stringify({
error: 'Invalid or disallowed URL',
message:
'Only certificate-related URLs are allowed (*.crt, *.cer, *.pem, /certs/, /ocsp, /crl)',
}),
{
status: 403,
headers: {
...corsHeaders(origin),
'Content-Type': 'application/json',
},
}
);
}
if (env.PROXY_SECRET) {
const timestamp = url.searchParams.get('t');
const signature = url.searchParams.get('sig');
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',
},
}
);
}
}
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,
}
);
} else {
console.warn(
'[CORS Proxy] RATE_LIMIT_KV not configured — rate limiting is disabled'
);
}
try {
const response = await fetch(targetUrl, {
headers: {
'User-Agent': 'BentoPDF-CertProxy/1.0',
},
});
if (!response.ok) {
return new Response(
JSON.stringify({
error: 'Failed to fetch certificate',
status: response.status,
}),
{
status: response.status,
headers: {
...corsHeaders(origin),
'Content-Type': 'application/json',
},
}
);
}
// Check Content-Length header first (fast reject for known-large responses)
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`,
}),
{
status: 413,
headers: {
...corsHeaders(origin),
'Content-Type': 'application/json',
},
}
);
}
const reader = response.body.getReader();
const chunks = [];
let totalSize = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
totalSize += value.byteLength;
if (totalSize > MAX_FILE_SIZE_BYTES) {
reader.cancel();
return new Response(
JSON.stringify({
error: 'File too large',
message: `Certificate file exceeds maximum size of ${MAX_FILE_SIZE_BYTES / 1024}KB`,
}),
{
status: 413,
headers: {
...corsHeaders(origin),
'Content-Type': 'application/json',
},
}
);
}
chunks.push(value);
}
const certData = new Uint8Array(totalSize);
let offset = 0;
for (const chunk of chunks) {
certData.set(chunk, offset);
offset += chunk.byteLength;
}
return new Response(certData, {
status: 200,
headers: {
...corsHeaders(origin),
'Content-Type': getSafeContentType(
response.headers.get('Content-Type')
),
'Content-Length': totalSize.toString(),
'Cache-Control': 'public, max-age=86400',
'X-Content-Type-Options': 'nosniff',
},
});
} catch (error) {
console.error('Proxy fetch error:', error);
return new Response(
JSON.stringify({
error: 'Proxy error',
message: 'Failed to fetch the requested certificate',
}),
{
status: 500,
headers: {
...corsHeaders(origin),
'Content-Type': 'application/json',
},
}
);
}
},
};