fix: improve CORS proxy handling and update documentation for certificate fetching
This commit is contained in:
40
README.md
40
README.md
@@ -824,7 +824,7 @@ For detailed security configuration, see [SECURITY.md](SECURITY.md).
|
|||||||
|
|
||||||
### Digital Signature CORS Proxy (Required)
|
### Digital Signature CORS Proxy (Required)
|
||||||
|
|
||||||
The **Digital Signature** tool uses a signing library that may need to fetch certificate chain data from certificate authority provider. Since many certificate servers don't include CORS headers, a proxy is required for this feature to work in the browser.
|
The **Digital Signature** tool uses a signing library that may need to fetch certificate chain data from certificate authority providers. Since many certificate servers don't include CORS headers (and often serve over HTTP, which is blocked by browsers on HTTPS sites), a proxy is required for this feature to work.
|
||||||
|
|
||||||
**When is the proxy needed?**
|
**When is the proxy needed?**
|
||||||
|
|
||||||
@@ -846,30 +846,48 @@ The **Digital Signature** tool uses a signing library that may need to fetch cer
|
|||||||
npx wrangler login
|
npx wrangler login
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Deploy the worker:**
|
3. **Update allowed origins** — open `cors-proxy-worker.js` and change `ALLOWED_ORIGINS` to your domain:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const ALLOWED_ORIGINS = [
|
||||||
|
'https://your-domain.com',
|
||||||
|
'https://www.your-domain.com',
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> Without this step, the proxy will reject all requests from your site with a 403 error. The default only allows `bentopdf.com`.
|
||||||
|
|
||||||
|
4. **Deploy the worker:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx wrangler deploy
|
npx wrangler deploy
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Note your worker URL** (e.g., `https://bentopdf-cors-proxy.your-subdomain.workers.dev`)
|
5. **Note your worker URL** (e.g., `https://bentopdf-cors-proxy.your-subdomain.workers.dev`)
|
||||||
|
|
||||||
5. **Set the environment variable when building:**
|
6. **Set the environment variable when building:**
|
||||||
```bash
|
```bash
|
||||||
VITE_CORS_PROXY_URL=https://your-worker-url.workers.dev npm run build
|
VITE_CORS_PROXY_URL=https://your-worker-url.workers.dev npm run build
|
||||||
```
|
```
|
||||||
|
Or with Docker:
|
||||||
|
```bash
|
||||||
|
docker build --build-arg VITE_CORS_PROXY_URL="https://your-worker-url.workers.dev" -t your-bentopdf .
|
||||||
|
```
|
||||||
|
|
||||||
#### Production Security Features
|
#### Production Security Features
|
||||||
|
|
||||||
The CORS proxy includes several security measures:
|
The CORS proxy includes several security measures:
|
||||||
|
|
||||||
| Feature | Description |
|
| Feature | Description |
|
||||||
| ----------------------- | ------------------------------------------------------------------------- |
|
| ----------------------- | -------------------------------------------------------------------------------------- |
|
||||||
| **URL Restrictions** | Only allows certificate URLs (`.crt`, `.cer`, `.pem`, `/certs/`, `/ocsp`) |
|
| **Origin Validation** | Only allows requests from domains listed in `ALLOWED_ORIGINS` |
|
||||||
| **Private IP Blocking** | Blocks requests to localhost, 10.x, 192.168.x, 172.16-31.x |
|
| **URL Restrictions** | Only allows certificate URLs (`.crt`, `.cer`, `.pem`, `/certs/`, `/ocsp`, `/crl`) |
|
||||||
| **File Size Limit** | Rejects files larger than 10MB |
|
| **Private IP Blocking** | Blocks IPv4/IPv6 private ranges, link-local, loopback, decimal IPs, and cloud metadata |
|
||||||
| **Rate Limiting** | 60 requests per IP per minute (requires KV) |
|
| **Content-Type Safety** | Only returns safe certificate MIME types, blocks upstream content-type injection |
|
||||||
| **HMAC Signatures** | Optional client-side signing (limited protection) |
|
| **File Size Limit** | Streams response with 10MB limit, aborts mid-download if exceeded |
|
||||||
|
| **Rate Limiting** | 60 requests per IP per minute (requires KV) |
|
||||||
|
| **HMAC Signatures** | Optional client-side signing (deters casual abuse) |
|
||||||
|
|
||||||
#### Enabling Rate Limiting (Recommended)
|
#### Enabling Rate Limiting (Recommended)
|
||||||
|
|
||||||
|
|||||||
@@ -1,39 +1,38 @@
|
|||||||
/**
|
/**
|
||||||
* BentoPDF CORS Proxy Worker
|
* BentoPDF CORS Proxy Worker
|
||||||
*
|
*
|
||||||
* This Cloudflare Worker proxies certificate requests for the digital signing tool.
|
* This Cloudflare Worker proxies certificate requests for the digital signing tool.
|
||||||
* It fetches certificates from external CAs that don't have CORS headers enabled
|
* It fetches certificates from external CAs that don't have CORS headers enabled
|
||||||
* and returns them with proper CORS headers.
|
* and returns them with proper CORS headers.
|
||||||
*
|
*
|
||||||
*
|
*
|
||||||
* Deploy: npx wrangler deploy
|
* Deploy: npx wrangler deploy
|
||||||
*
|
*
|
||||||
* Required Environment Variables (set in wrangler.toml or Cloudflare dashboard):
|
* Required Environment Variables (set in wrangler.toml or Cloudflare dashboard):
|
||||||
* - PROXY_SECRET: Shared secret for HMAC signature verification
|
* - PROXY_SECRET: Shared secret for HMAC signature verification
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const ALLOWED_PATTERNS = [
|
const ALLOWED_PATH_PATTERNS = [
|
||||||
/\.crt$/i,
|
/\.crt$/i,
|
||||||
/\.cer$/i,
|
/\.cer$/i,
|
||||||
/\.pem$/i,
|
/\.pem$/i,
|
||||||
/\/certs\//i,
|
/\/certs\//i,
|
||||||
/\/ocsp/i,
|
/\/ocsp/i,
|
||||||
/\/crl/i,
|
/\/crl/i,
|
||||||
/caIssuers/i,
|
/caIssuers/i,
|
||||||
];
|
];
|
||||||
|
|
||||||
const ALLOWED_ORIGINS = [
|
const ALLOWED_ORIGINS = ['https://www.bentopdf.com', 'https://bentopdf.com'];
|
||||||
'https://www.bentopdf.com',
|
|
||||||
'https://bentopdf.com',
|
|
||||||
];
|
|
||||||
|
|
||||||
const BLOCKED_DOMAINS = [
|
const SAFE_CONTENT_TYPES = [
|
||||||
'localhost',
|
'application/x-x509-ca-cert',
|
||||||
'127.0.0.1',
|
'application/pkix-cert',
|
||||||
'0.0.0.0',
|
'application/x-pem-file',
|
||||||
|
'application/pkcs7-mime',
|
||||||
|
'application/octet-stream',
|
||||||
|
'text/plain',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
const MAX_TIMESTAMP_AGE_MS = 5 * 60 * 1000;
|
const MAX_TIMESTAMP_AGE_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
const RATE_LIMIT_MAX_REQUESTS = 60;
|
const RATE_LIMIT_MAX_REQUESTS = 60;
|
||||||
@@ -42,310 +41,415 @@ const RATE_LIMIT_WINDOW_MS = 60 * 1000;
|
|||||||
const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024;
|
const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024;
|
||||||
|
|
||||||
async function verifySignature(message, signature, secret) {
|
async function verifySignature(message, signature, secret) {
|
||||||
try {
|
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 encoder = new TextEncoder();
|
||||||
const key = await crypto.subtle.importKey(
|
const key = await crypto.subtle.importKey(
|
||||||
'raw',
|
'raw',
|
||||||
encoder.encode(secret),
|
encoder.encode(secret),
|
||||||
{ name: 'HMAC', hash: 'SHA-256' },
|
{ name: 'HMAC', hash: 'SHA-256' },
|
||||||
false,
|
false,
|
||||||
['sign']
|
['verify']
|
||||||
);
|
);
|
||||||
|
|
||||||
const signature = await crypto.subtle.sign(
|
const signatureBytes = new Uint8Array(
|
||||||
'HMAC',
|
signature.match(/.{1,2}/g).map((byte) => parseInt(byte, 16))
|
||||||
key,
|
|
||||||
encoder.encode(message)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return Array.from(new Uint8Array(signature))
|
return await crypto.subtle.verify(
|
||||||
.map(b => b.toString(16).padStart(2, '0'))
|
'HMAC',
|
||||||
.join('');
|
key,
|
||||||
|
signatureBytes,
|
||||||
|
encoder.encode(message)
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Signature verification error:', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAllowedOrigin(origin) {
|
function isAllowedOrigin(origin) {
|
||||||
if (!origin) return false;
|
if (!origin) return false;
|
||||||
return ALLOWED_ORIGINS.some(allowed => origin.startsWith(allowed.replace(/\/$/, '')));
|
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) {
|
function isValidCertificateUrl(urlString) {
|
||||||
try {
|
try {
|
||||||
const url = new URL(urlString);
|
const url = new URL(urlString);
|
||||||
|
|
||||||
if (!['http:', 'https:'].includes(url.protocol)) {
|
if (!['http:', 'https:'].includes(url.protocol)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
|
||||||
|
|
||||||
if (BLOCKED_DOMAINS.some(domain => url.hostname.includes(domain))) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hostname = url.hostname;
|
|
||||||
if (/^10\./.test(hostname) ||
|
|
||||||
/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(hostname) ||
|
|
||||||
/^192\.168\./.test(hostname)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ALLOWED_PATTERNS.some(pattern => pattern.test(urlString));
|
|
||||||
} catch {
|
|
||||||
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) {
|
function corsHeaders(origin) {
|
||||||
return {
|
return {
|
||||||
'Access-Control-Allow-Origin': origin || '*',
|
'Access-Control-Allow-Origin': origin,
|
||||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||||
'Access-Control-Allow-Headers': 'Content-Type',
|
'Access-Control-Allow-Headers': 'Content-Type',
|
||||||
'Access-Control-Max-Age': '86400',
|
'Access-Control-Max-Age': '86400',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleOptions(request) {
|
function handleOptions(request) {
|
||||||
const origin = request.headers.get('Origin');
|
const origin = request.headers.get('Origin');
|
||||||
return new Response(null, {
|
if (!isAllowedOrigin(origin)) {
|
||||||
status: 204,
|
return new Response(null, { status: 403 });
|
||||||
headers: corsHeaders(origin),
|
}
|
||||||
});
|
return new Response(null, {
|
||||||
|
status: 204,
|
||||||
|
headers: corsHeaders(origin),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
async fetch(request, env, ctx) {
|
async fetch(request, env, ctx) {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const origin = request.headers.get('Origin');
|
const origin = request.headers.get('Origin');
|
||||||
|
|
||||||
if (request.method === 'OPTIONS') {
|
if (request.method === 'OPTIONS') {
|
||||||
return handleOptions(request);
|
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',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 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 (request.method !== 'GET') {
|
||||||
if (!isAllowedOrigin(origin)) {
|
return new Response('Method not allowed', {
|
||||||
return new Response(JSON.stringify({
|
status: 405,
|
||||||
error: 'Forbidden',
|
headers: corsHeaders(origin),
|
||||||
message: 'This proxy only accepts requests from bentopdf.com',
|
});
|
||||||
}), {
|
}
|
||||||
status: 403,
|
|
||||||
headers: {
|
const targetUrl = url.searchParams.get('url');
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
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 (request.method !== 'GET') {
|
if (!isValidCertificateUrl(targetUrl)) {
|
||||||
return new Response('Method not allowed', {
|
return new Response(
|
||||||
status: 405,
|
JSON.stringify({
|
||||||
headers: corsHeaders(origin),
|
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',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const targetUrl = url.searchParams.get('url');
|
if (env.PROXY_SECRET) {
|
||||||
const timestamp = url.searchParams.get('t');
|
const timestamp = url.searchParams.get('t');
|
||||||
const signature = url.searchParams.get('sig');
|
const signature = url.searchParams.get('sig');
|
||||||
|
|
||||||
if (env.PROXY_SECRET) {
|
if (!timestamp || !signature) {
|
||||||
if (!timestamp || !signature) {
|
return new Response(
|
||||||
return new Response(JSON.stringify({
|
JSON.stringify({
|
||||||
error: 'Missing authentication parameters',
|
error: 'Missing authentication parameters',
|
||||||
message: 'Request must include timestamp (t) and signature (sig) parameters',
|
message:
|
||||||
}), {
|
'Request must include timestamp (t) and signature (sig) parameters',
|
||||||
status: 401,
|
}),
|
||||||
headers: {
|
{
|
||||||
...corsHeaders(origin),
|
status: 401,
|
||||||
'Content-Type': 'application/json',
|
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({
|
const requestTime = parseInt(timestamp, 10);
|
||||||
error: 'Request expired or invalid timestamp',
|
const now = Date.now();
|
||||||
message: 'Timestamp must be within 5 minutes of current time',
|
if (
|
||||||
}), {
|
isNaN(requestTime) ||
|
||||||
status: 401,
|
Math.abs(now - requestTime) > MAX_TIMESTAMP_AGE_MS
|
||||||
headers: {
|
) {
|
||||||
...corsHeaders(origin),
|
return new Response(
|
||||||
'Content-Type': 'application/json',
|
JSON.stringify({
|
||||||
},
|
error: 'Request expired or invalid timestamp',
|
||||||
});
|
message: 'Timestamp must be within 5 minutes of current time',
|
||||||
}
|
}),
|
||||||
|
{
|
||||||
const message = `${targetUrl}${timestamp}`;
|
status: 401,
|
||||||
const isValid = await verifySignature(message, signature, env.PROXY_SECRET);
|
headers: {
|
||||||
|
...corsHeaders(origin),
|
||||||
if (!isValid) {
|
'Content-Type': 'application/json',
|
||||||
return new Response(JSON.stringify({
|
},
|
||||||
error: 'Invalid signature',
|
}
|
||||||
message: 'Request signature verification failed',
|
);
|
||||||
}), {
|
}
|
||||||
status: 401,
|
|
||||||
headers: {
|
const message = `${targetUrl}${timestamp}`;
|
||||||
...corsHeaders(origin),
|
const isValid = await verifySignature(
|
||||||
'Content-Type': 'application/json',
|
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',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!targetUrl) {
|
chunks.push(value);
|
||||||
return new Response(JSON.stringify({
|
}
|
||||||
error: 'Missing url parameter',
|
|
||||||
usage: 'GET /?url=<certificate_url>',
|
const certData = new Uint8Array(totalSize);
|
||||||
}), {
|
let offset = 0;
|
||||||
status: 400,
|
for (const chunk of chunks) {
|
||||||
headers: {
|
certData.set(chunk, offset);
|
||||||
...corsHeaders(origin),
|
offset += chunk.byteLength;
|
||||||
'Content-Type': 'application/json',
|
}
|
||||||
},
|
|
||||||
});
|
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',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
);
|
||||||
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',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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: {
|
|
||||||
'User-Agent': 'BentoPDF-CertProxy/1.0',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
return new Response(JSON.stringify({
|
|
||||||
error: 'Failed to fetch certificate',
|
|
||||||
status: response.status,
|
|
||||||
statusText: response.statusText,
|
|
||||||
}), {
|
|
||||||
status: response.status,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders(origin),
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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: {
|
|
||||||
...corsHeaders(origin),
|
|
||||||
'Content-Type': response.headers.get('Content-Type') || 'application/x-x509-ca-cert',
|
|
||||||
'Content-Length': certData.byteLength.toString(),
|
|
||||||
'Cache-Control': 'public, max-age=86400',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
return new Response(JSON.stringify({
|
|
||||||
error: 'Proxy error',
|
|
||||||
message: error.message,
|
|
||||||
}), {
|
|
||||||
status: 500,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders(origin),
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
The digital signature tool uses a CORS proxy to fetch issuer certificates from external Certificate Authorities (CAs). This is necessary because many CA servers don't include CORS headers in their responses, which prevents direct browser-based fetching.
|
The digital signature tool uses a CORS proxy to fetch issuer certificates from external Certificate Authorities (CAs). This is necessary because many CA servers don't include CORS headers in their responses, which prevents direct browser-based fetching.
|
||||||
|
|
||||||
|
Additionally, many CA servers serve certificates over plain HTTP. When your BentoPDF instance is hosted over HTTPS, browsers block these HTTP requests (mixed content policy). The CORS proxy resolves both issues by routing requests through an HTTPS endpoint with proper headers.
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
When signing a PDF with a certificate:
|
When signing a PDF with a certificate:
|
||||||
@@ -13,35 +15,64 @@ When signing a PDF with a certificate:
|
|||||||
|
|
||||||
## Self-Hosting the CORS Proxy
|
## Self-Hosting the CORS Proxy
|
||||||
|
|
||||||
If you're self-hosting BentoPDF, you'll need to deploy your own CORS proxy.
|
If you're self-hosting BentoPDF, you'll need to deploy your own CORS proxy for digital signatures to work with certificates that require chain fetching.
|
||||||
|
|
||||||
### Option 1: Cloudflare Workers (Recommended)
|
### Option 1: Cloudflare Workers (Recommended)
|
||||||
|
|
||||||
1. **Install Wrangler CLI**:
|
1. **Install Wrangler CLI**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install -g wrangler
|
npm install -g wrangler
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Login to Cloudflare**:
|
2. **Login to Cloudflare**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
wrangler login
|
wrangler login
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Deploy the proxy**:
|
3. **Clone BentoPDF and update allowed origins**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/alam00000/bentopdf.git
|
||||||
|
cd bentopdf/cloudflare
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `cors-proxy-worker.js` and change the `ALLOWED_ORIGINS` array to your domain:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const ALLOWED_ORIGINS = [
|
||||||
|
'https://your-domain.com',
|
||||||
|
'https://www.your-domain.com',
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
::: warning Important
|
||||||
|
Without this change, the proxy will reject all requests from your site with a **403 Forbidden** error. The default only allows requests from `bentopdf.com`.
|
||||||
|
:::
|
||||||
|
|
||||||
|
4. **Deploy the proxy**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd cloudflare
|
|
||||||
wrangler deploy
|
wrangler deploy
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Update your environment**:
|
Note your worker URL (e.g., `https://bentopdf-cors-proxy.your-subdomain.workers.dev`).
|
||||||
Create a `.env` or set in your hosting platform:
|
|
||||||
```
|
5. **Rebuild BentoPDF with the proxy URL**:
|
||||||
VITE_CORS_PROXY_URL=https://your-worker-name.your-subdomain.workers.dev
|
|
||||||
|
If using Docker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build \
|
||||||
|
--build-arg VITE_CORS_PROXY_URL="https://your-worker.workers.dev" \
|
||||||
|
-t your-bentopdf .
|
||||||
```
|
```
|
||||||
|
|
||||||
5. **Rebuild BentoPDF**:
|
If building from source:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build
|
VITE_CORS_PROXY_URL=https://your-worker.workers.dev npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
### Option 2: Custom Backend Proxy
|
### Option 2: Custom Backend Proxy
|
||||||
@@ -51,27 +82,28 @@ You can also create your own proxy endpoint. The requirements are:
|
|||||||
1. Accept GET requests with a `url` query parameter
|
1. Accept GET requests with a `url` query parameter
|
||||||
2. Fetch the URL from your server (no CORS restrictions server-side)
|
2. Fetch the URL from your server (no CORS restrictions server-side)
|
||||||
3. Return the response with these headers:
|
3. Return the response with these headers:
|
||||||
- `Access-Control-Allow-Origin: *` (or your specific origin)
|
- `Access-Control-Allow-Origin: https://your-domain.com`
|
||||||
- `Access-Control-Allow-Methods: GET, OPTIONS`
|
- `Access-Control-Allow-Methods: GET, OPTIONS`
|
||||||
- `Content-Type: application/x-x509-ca-cert`
|
- `X-Content-Type-Options: nosniff`
|
||||||
|
|
||||||
Example Express.js implementation:
|
Example Express.js implementation:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
app.get('/api/cert-proxy', async (req, res) => {
|
app.get('/api/cert-proxy', async (req, res) => {
|
||||||
const targetUrl = req.query.url;
|
const targetUrl = req.query.url;
|
||||||
|
|
||||||
// Validate it's a certificate URL
|
// Validate it's a certificate URL
|
||||||
if (!isValidCertUrl(targetUrl)) {
|
if (!isValidCertUrl(targetUrl)) {
|
||||||
return res.status(400).json({ error: 'Invalid URL' });
|
return res.status(400).json({ error: 'Invalid URL' });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(targetUrl);
|
const response = await fetch(targetUrl);
|
||||||
const data = await response.arrayBuffer();
|
const data = await response.arrayBuffer();
|
||||||
|
|
||||||
res.set('Access-Control-Allow-Origin', '*');
|
res.set('Access-Control-Allow-Origin', 'https://your-domain.com');
|
||||||
res.set('Content-Type', 'application/x-x509-ca-cert');
|
res.set('Content-Type', 'application/octet-stream');
|
||||||
|
res.set('X-Content-Type-Options', 'nosniff');
|
||||||
res.send(Buffer.from(data));
|
res.send(Buffer.from(data));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: 'Proxy error' });
|
res.status(500).json({ error: 'Proxy error' });
|
||||||
@@ -83,9 +115,15 @@ app.get('/api/cert-proxy', async (req, res) => {
|
|||||||
|
|
||||||
The included Cloudflare Worker has several security measures:
|
The included Cloudflare Worker has several security measures:
|
||||||
|
|
||||||
- **URL Validation**: Only allows certificate-related URLs (`.crt`, `.cer`, `.pem`, `/certs/`, `/ocsp`, `/crl`)
|
| Feature | Description |
|
||||||
- **Blocked Domains**: Prevents access to localhost and private IP ranges
|
| ----------------------- | -------------------------------------------------------------------------------------- |
|
||||||
- **HTTP Methods**: Only allows GET requests
|
| **Origin Validation** | Only allows requests from domains listed in `ALLOWED_ORIGINS` |
|
||||||
|
| **URL Restrictions** | Only allows certificate URLs (`.crt`, `.cer`, `.pem`, `/certs/`, `/ocsp`, `/crl`) |
|
||||||
|
| **Private IP Blocking** | Blocks IPv4/IPv6 private ranges, link-local, loopback, decimal IPs, and cloud metadata |
|
||||||
|
| **Content-Type Safety** | Only returns safe certificate MIME types, blocks upstream content-type injection |
|
||||||
|
| **File Size Limit** | Streams response with 10MB limit, aborts mid-download if exceeded |
|
||||||
|
| **Rate Limiting** | 60 requests per IP per minute (requires KV) |
|
||||||
|
| **HMAC Signatures** | Optional client-side signing (deters casual abuse) |
|
||||||
|
|
||||||
## Disabling the Proxy
|
## Disabling the Proxy
|
||||||
|
|
||||||
@@ -95,22 +133,39 @@ If you don't want to use a CORS proxy, set the environment variable to an empty
|
|||||||
VITE_CORS_PROXY_URL=
|
VITE_CORS_PROXY_URL=
|
||||||
```
|
```
|
||||||
|
|
||||||
**Note**: Without the proxy, signing with certificates that require external chain fetching (like FNMT or some corporate CAs) will fail.
|
**Note**: Without the proxy, signing with certificates that require external chain fetching (like FNMT or some corporate CAs) will fail with a "Failed to fetch" error.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### "Failed to fetch certificate chain" Error
|
### "Signing error: TypeError: Failed to fetch"
|
||||||
|
|
||||||
1. Check that your CORS proxy is deployed and accessible
|
This usually means either:
|
||||||
2. Verify the `VITE_CORS_PROXY_URL` is correctly set
|
|
||||||
3. Test the proxy directly:
|
1. **No CORS proxy configured** — Set `VITE_CORS_PROXY_URL` and rebuild
|
||||||
```bash
|
2. **Mixed content blocked** — Your site is HTTPS but the certificate's issuer URL is HTTP. The CORS proxy resolves this.
|
||||||
curl "https://your-proxy.workers.dev?url=https://www.cert.fnmt.es/certs/ACUSU.crt"
|
3. **CORS proxy rejecting your origin** — Check that your domain is in the `ALLOWED_ORIGINS` array in `cors-proxy-worker.js`
|
||||||
```
|
|
||||||
|
### "403 Forbidden" from the proxy
|
||||||
|
|
||||||
|
Your domain is not in the `ALLOWED_ORIGINS` list. Edit `cors-proxy-worker.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const ALLOWED_ORIGINS = ['https://your-domain.com'];
|
||||||
|
```
|
||||||
|
|
||||||
|
Then redeploy: `npx wrangler deploy`
|
||||||
|
|
||||||
|
### Testing the proxy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H "Origin: https://your-domain.com" \
|
||||||
|
"https://your-proxy.workers.dev?url=http://www.cert.fnmt.es/certs/ACUSU.crt"
|
||||||
|
```
|
||||||
|
|
||||||
### Certificates That Work Without Proxy
|
### Certificates That Work Without Proxy
|
||||||
|
|
||||||
Some certificates include the full chain in the P12/PFX file and don't require external fetching:
|
Some certificates include the full chain in the P12/PFX file and don't require external fetching:
|
||||||
|
|
||||||
- Self-signed certificates
|
- Self-signed certificates
|
||||||
- Some commercial CAs that bundle intermediate certificates
|
- Some commercial CAs that bundle intermediate certificates
|
||||||
- Certificates you've manually assembled with the full chain
|
- Certificates you've manually assembled with the full chain
|
||||||
|
|||||||
@@ -2,82 +2,94 @@ import { PdfSigner, type SignOption } from 'zgapdfsigner';
|
|||||||
import forge from 'node-forge';
|
import forge from 'node-forge';
|
||||||
import { CertificateData, SignPdfOptions } from '@/types';
|
import { CertificateData, SignPdfOptions } from '@/types';
|
||||||
|
|
||||||
export function parsePfxFile(pfxBytes: ArrayBuffer, password: string): CertificateData {
|
export function parsePfxFile(
|
||||||
const pfxAsn1 = forge.asn1.fromDer(forge.util.createBuffer(new Uint8Array(pfxBytes)));
|
pfxBytes: ArrayBuffer,
|
||||||
const pfx = forge.pkcs12.pkcs12FromAsn1(pfxAsn1, password);
|
password: string
|
||||||
|
): CertificateData {
|
||||||
|
const pfxAsn1 = forge.asn1.fromDer(
|
||||||
|
forge.util.createBuffer(new Uint8Array(pfxBytes))
|
||||||
|
);
|
||||||
|
const pfx = forge.pkcs12.pkcs12FromAsn1(pfxAsn1, password);
|
||||||
|
|
||||||
const certBags = pfx.getBags({ bagType: forge.pki.oids.certBag });
|
const certBags = pfx.getBags({ bagType: forge.pki.oids.certBag });
|
||||||
const keyBags = pfx.getBags({ bagType: forge.pki.oids.pkcs8ShroudedKeyBag });
|
const keyBags = pfx.getBags({ bagType: forge.pki.oids.pkcs8ShroudedKeyBag });
|
||||||
|
|
||||||
const certBagArray = certBags[forge.pki.oids.certBag];
|
const certBagArray = certBags[forge.pki.oids.certBag];
|
||||||
const keyBagArray = keyBags[forge.pki.oids.pkcs8ShroudedKeyBag];
|
const keyBagArray = keyBags[forge.pki.oids.pkcs8ShroudedKeyBag];
|
||||||
|
|
||||||
if (!certBagArray || certBagArray.length === 0) {
|
if (!certBagArray || certBagArray.length === 0) {
|
||||||
throw new Error('No certificate found in PFX file');
|
throw new Error('No certificate found in PFX file');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!keyBagArray || keyBagArray.length === 0) {
|
if (!keyBagArray || keyBagArray.length === 0) {
|
||||||
throw new Error('No private key found in PFX file');
|
throw new Error('No private key found in PFX file');
|
||||||
}
|
}
|
||||||
|
|
||||||
const certificate = certBagArray[0].cert;
|
const certificate = certBagArray[0].cert;
|
||||||
|
|
||||||
if (!certificate) {
|
if (!certificate) {
|
||||||
throw new Error('Failed to extract certificate from PFX file');
|
throw new Error('Failed to extract certificate from PFX file');
|
||||||
}
|
}
|
||||||
|
|
||||||
return { p12Buffer: pfxBytes, password, certificate };
|
return { p12Buffer: pfxBytes, password, certificate };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parsePemFiles(
|
export function parsePemFiles(
|
||||||
certPem: string,
|
certPem: string,
|
||||||
keyPem: string,
|
keyPem: string,
|
||||||
keyPassword?: string
|
keyPassword?: string
|
||||||
): CertificateData {
|
): CertificateData {
|
||||||
const certificate = forge.pki.certificateFromPem(certPem);
|
const certificate = forge.pki.certificateFromPem(certPem);
|
||||||
|
|
||||||
let privateKey: forge.pki.PrivateKey;
|
let privateKey: forge.pki.PrivateKey;
|
||||||
if (keyPem.includes('ENCRYPTED')) {
|
if (keyPem.includes('ENCRYPTED')) {
|
||||||
if (!keyPassword) {
|
if (!keyPassword) {
|
||||||
throw new Error('Password required for encrypted private key');
|
throw new Error('Password required for encrypted private key');
|
||||||
}
|
|
||||||
privateKey = forge.pki.decryptRsaPrivateKey(keyPem, keyPassword);
|
|
||||||
if (!privateKey) {
|
|
||||||
throw new Error('Failed to decrypt private key');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
privateKey = forge.pki.privateKeyFromPem(keyPem);
|
|
||||||
}
|
}
|
||||||
|
privateKey = forge.pki.decryptRsaPrivateKey(keyPem, keyPassword);
|
||||||
const p12Password = keyPassword || 'temp-password';
|
if (!privateKey) {
|
||||||
const p12Asn1 = forge.pkcs12.toPkcs12Asn1(
|
throw new Error('Failed to decrypt private key');
|
||||||
privateKey,
|
|
||||||
[certificate],
|
|
||||||
p12Password,
|
|
||||||
{ algorithm: '3des' }
|
|
||||||
);
|
|
||||||
const p12Der = forge.asn1.toDer(p12Asn1).getBytes();
|
|
||||||
const p12Buffer = new Uint8Array(p12Der.length);
|
|
||||||
for (let i = 0; i < p12Der.length; i++) {
|
|
||||||
p12Buffer[i] = p12Der.charCodeAt(i);
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
privateKey = forge.pki.privateKeyFromPem(keyPem);
|
||||||
|
}
|
||||||
|
|
||||||
return { p12Buffer: p12Buffer.buffer, password: p12Password, certificate };
|
const p12Password = keyPassword || crypto.randomUUID();
|
||||||
|
const p12Asn1 = forge.pkcs12.toPkcs12Asn1(
|
||||||
|
privateKey,
|
||||||
|
[certificate],
|
||||||
|
p12Password,
|
||||||
|
{ algorithm: '3des' }
|
||||||
|
);
|
||||||
|
const p12Der = forge.asn1.toDer(p12Asn1).getBytes();
|
||||||
|
const p12Buffer = new Uint8Array(p12Der.length);
|
||||||
|
for (let i = 0; i < p12Der.length; i++) {
|
||||||
|
p12Buffer[i] = p12Der.charCodeAt(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { p12Buffer: p12Buffer.buffer, password: p12Password, certificate };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseCombinedPem(pemContent: string, password?: string): CertificateData {
|
export function parseCombinedPem(
|
||||||
const certMatch = pemContent.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/);
|
pemContent: string,
|
||||||
const keyMatch = pemContent.match(/-----BEGIN (RSA |EC |ENCRYPTED )?PRIVATE KEY-----[\s\S]*?-----END (RSA |EC |ENCRYPTED )?PRIVATE KEY-----/);
|
password?: string
|
||||||
|
): CertificateData {
|
||||||
|
const certMatch = pemContent.match(
|
||||||
|
/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/
|
||||||
|
);
|
||||||
|
const keyMatch = pemContent.match(
|
||||||
|
/-----BEGIN (RSA |EC |ENCRYPTED )?PRIVATE KEY-----[\s\S]*?-----END (RSA |EC |ENCRYPTED )?PRIVATE KEY-----/
|
||||||
|
);
|
||||||
|
|
||||||
if (!certMatch) {
|
if (!certMatch) {
|
||||||
throw new Error('No certificate found in PEM file');
|
throw new Error('No certificate found in PEM file');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!keyMatch) {
|
if (!keyMatch) {
|
||||||
throw new Error('No private key found in PEM file');
|
throw new Error('No private key found in PEM file');
|
||||||
}
|
}
|
||||||
|
|
||||||
return parsePemFiles(certMatch[0], keyMatch[0], password);
|
return parsePemFiles(certMatch[0], keyMatch[0], password);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -85,30 +97,30 @@ export function parseCombinedPem(pemContent: string, password?: string): Certifi
|
|||||||
* The zgapdfsigner library tries to fetch issuer certificates from external URLs,
|
* The zgapdfsigner library tries to fetch issuer certificates from external URLs,
|
||||||
* but those servers often don't have CORS headers. This proxy adds the necessary
|
* but those servers often don't have CORS headers. This proxy adds the necessary
|
||||||
* CORS headers to allow the requests from the browser.
|
* CORS headers to allow the requests from the browser.
|
||||||
*
|
*
|
||||||
* If you are self-hosting, you MUST deploy your own proxy using cloudflare/cors-proxy-worker.js or any other way of your choice
|
* If you are self-hosting, you MUST deploy your own proxy using cloudflare/cors-proxy-worker.js or any other way of your choice
|
||||||
* and set VITE_CORS_PROXY_URL environment variable.
|
* and set VITE_CORS_PROXY_URL environment variable.
|
||||||
*
|
*
|
||||||
* If not set, certificates requiring external chain fetching will fail.
|
* If not set, certificates requiring external chain fetching will fail.
|
||||||
*/
|
*/
|
||||||
const CORS_PROXY_URL = import.meta.env.VITE_CORS_PROXY_URL || '';
|
const CORS_PROXY_URL = import.meta.env.VITE_CORS_PROXY_URL || '';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared secret for signing proxy requests (HMAC-SHA256).
|
* Shared secret for signing proxy requests (HMAC-SHA256).
|
||||||
*
|
*
|
||||||
* SECURITY NOTE FOR PRODUCTION:
|
* SECURITY NOTE FOR PRODUCTION:
|
||||||
* Client-side secrets are NEVER truly hidden and they can be extracted from
|
* Client-side secrets are NEVER truly hidden and they can be extracted from
|
||||||
* bundled JavaScript.
|
* bundled JavaScript.
|
||||||
*
|
*
|
||||||
* For production deployments with sensitive requirements, you should:
|
* For production deployments with sensitive requirements, you should:
|
||||||
* 1. Use your own backend server to proxy certificate requests
|
* 1. Use your own backend server to proxy certificate requests
|
||||||
* 2. Keep the HMAC secret on your server ONLY (never in frontend code)
|
* 2. Keep the HMAC secret on your server ONLY (never in frontend code)
|
||||||
* 3. Have your frontend call your server, which then calls the CORS proxy
|
* 3. Have your frontend call your server, which then calls the CORS proxy
|
||||||
*
|
*
|
||||||
* This client-side HMAC provides limited protection (deters casual abuse)
|
* This client-side HMAC provides limited protection (deters casual abuse)
|
||||||
* but should NOT be considered secure against determined attackers. BentoPDF
|
* but should NOT be considered secure against determined attackers. BentoPDF
|
||||||
* accepts this tradeoff because of it's client side architecture.
|
* accepts this tradeoff because of it's client side architecture.
|
||||||
*
|
*
|
||||||
* To enable (optional):
|
* To enable (optional):
|
||||||
* 1. Generate a secret: openssl rand -hex 32
|
* 1. Generate a secret: openssl rand -hex 32
|
||||||
* 2. Set PROXY_SECRET on your Cloudflare Worker: npx wrangler secret put PROXY_SECRET
|
* 2. Set PROXY_SECRET on your Cloudflare Worker: npx wrangler secret put PROXY_SECRET
|
||||||
@@ -116,26 +128,29 @@ const CORS_PROXY_URL = import.meta.env.VITE_CORS_PROXY_URL || '';
|
|||||||
*/
|
*/
|
||||||
const CORS_PROXY_SECRET = import.meta.env.VITE_CORS_PROXY_SECRET || '';
|
const CORS_PROXY_SECRET = import.meta.env.VITE_CORS_PROXY_SECRET || '';
|
||||||
|
|
||||||
async function generateProxySignature(url: string, timestamp: number): Promise<string> {
|
async function generateProxySignature(
|
||||||
const encoder = new TextEncoder();
|
url: string,
|
||||||
const key = await crypto.subtle.importKey(
|
timestamp: number
|
||||||
'raw',
|
): Promise<string> {
|
||||||
encoder.encode(CORS_PROXY_SECRET),
|
const encoder = new TextEncoder();
|
||||||
{ name: 'HMAC', hash: 'SHA-256' },
|
const key = await crypto.subtle.importKey(
|
||||||
false,
|
'raw',
|
||||||
['sign']
|
encoder.encode(CORS_PROXY_SECRET),
|
||||||
);
|
{ name: 'HMAC', hash: 'SHA-256' },
|
||||||
|
false,
|
||||||
|
['sign']
|
||||||
|
);
|
||||||
|
|
||||||
const message = `${url}${timestamp}`;
|
const message = `${url}${timestamp}`;
|
||||||
const signature = await crypto.subtle.sign(
|
const signature = await crypto.subtle.sign(
|
||||||
'HMAC',
|
'HMAC',
|
||||||
key,
|
key,
|
||||||
encoder.encode(message)
|
encoder.encode(message)
|
||||||
);
|
);
|
||||||
|
|
||||||
return Array.from(new Uint8Array(signature))
|
return Array.from(new Uint8Array(signature))
|
||||||
.map(b => b.toString(16).padStart(2, '0'))
|
.map((b) => b.toString(16).padStart(2, '0'))
|
||||||
.join('');
|
.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -143,141 +158,165 @@ async function generateProxySignature(url: string, timestamp: number): Promise<s
|
|||||||
* The zgapdfsigner library tries to fetch issuer certificates from URLs embedded in the
|
* The zgapdfsigner library tries to fetch issuer certificates from URLs embedded in the
|
||||||
* certificate's AIA extension. When those servers don't have CORS enabled (like www.cert.fnmt.es),
|
* certificate's AIA extension. When those servers don't have CORS enabled (like www.cert.fnmt.es),
|
||||||
* the fetch fails. This wrapper routes such requests through our CORS proxy.
|
* the fetch fails. This wrapper routes such requests through our CORS proxy.
|
||||||
*
|
*
|
||||||
* If VITE_CORS_PROXY_SECRET is configured, requests include HMAC signatures for anti-spoofing.
|
* If VITE_CORS_PROXY_SECRET is configured, requests include HMAC signatures for anti-spoofing.
|
||||||
|
*
|
||||||
*/
|
*/
|
||||||
|
let fetchWrapRefCount = 0;
|
||||||
|
let savedOriginalFetch: typeof fetch | null = null;
|
||||||
|
|
||||||
function createCorsAwareFetch(): {
|
function createCorsAwareFetch(): {
|
||||||
wrappedFetch: typeof fetch;
|
wrappedFetch: typeof fetch;
|
||||||
restore: () => void;
|
restore: () => void;
|
||||||
} {
|
} {
|
||||||
const originalFetch = window.fetch.bind(window);
|
if (fetchWrapRefCount === 0) {
|
||||||
|
savedOriginalFetch = window.fetch.bind(window);
|
||||||
|
|
||||||
const wrappedFetch: typeof fetch = async (input, init) => {
|
const originalFetch = savedOriginalFetch;
|
||||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
|
||||||
|
|
||||||
const isExternalCertificateUrl = (
|
window.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
url.includes('.crt') ||
|
const url =
|
||||||
url.includes('.cer') ||
|
typeof input === 'string'
|
||||||
url.includes('.pem') ||
|
? input
|
||||||
url.includes('/certs/') ||
|
: input instanceof URL
|
||||||
url.includes('/ocsp') ||
|
? input.toString()
|
||||||
url.includes('/crl') ||
|
: input.url;
|
||||||
url.includes('caIssuers')
|
|
||||||
) && !url.startsWith(window.location.origin);
|
|
||||||
|
|
||||||
if (isExternalCertificateUrl && CORS_PROXY_URL) {
|
const isExternalCertificateUrl =
|
||||||
let proxyUrl = `${CORS_PROXY_URL}?url=${encodeURIComponent(url)}`;
|
(url.includes('.crt') ||
|
||||||
|
url.includes('.cer') ||
|
||||||
|
url.includes('.pem') ||
|
||||||
|
url.includes('/certs/') ||
|
||||||
|
url.includes('/ocsp') ||
|
||||||
|
url.includes('/crl') ||
|
||||||
|
url.includes('caIssuers')) &&
|
||||||
|
!url.startsWith(window.location.origin);
|
||||||
|
|
||||||
if (CORS_PROXY_SECRET) {
|
if (isExternalCertificateUrl && CORS_PROXY_URL) {
|
||||||
const timestamp = Date.now();
|
let proxyUrl = `${CORS_PROXY_URL}?url=${encodeURIComponent(url)}`;
|
||||||
const signature = await generateProxySignature(url, timestamp);
|
|
||||||
proxyUrl += `&t=${timestamp}&sig=${signature}`;
|
|
||||||
console.log(`[CORS Proxy] Routing signed certificate request through proxy: ${url}`);
|
|
||||||
} else {
|
|
||||||
console.log(`[CORS Proxy] Routing certificate request through proxy: ${url}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return originalFetch(proxyUrl, init);
|
if (CORS_PROXY_SECRET) {
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const signature = await generateProxySignature(url, timestamp);
|
||||||
|
proxyUrl += `&t=${timestamp}&sig=${signature}`;
|
||||||
|
console.log(
|
||||||
|
`[CORS Proxy] Routing signed certificate request through proxy: ${url}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
`[CORS Proxy] Routing certificate request through proxy: ${url}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return originalFetch(input, init);
|
return originalFetch(proxyUrl, init);
|
||||||
};
|
}
|
||||||
|
|
||||||
window.fetch = wrappedFetch;
|
return originalFetch(input, init);
|
||||||
|
}) as typeof fetch;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
fetchWrapRefCount++;
|
||||||
wrappedFetch,
|
|
||||||
restore: () => {
|
return {
|
||||||
window.fetch = originalFetch;
|
wrappedFetch: window.fetch,
|
||||||
}
|
restore: () => {
|
||||||
};
|
fetchWrapRefCount--;
|
||||||
|
if (fetchWrapRefCount === 0 && savedOriginalFetch) {
|
||||||
|
window.fetch = savedOriginalFetch;
|
||||||
|
savedOriginalFetch = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export async function signPdf(
|
export async function signPdf(
|
||||||
pdfBytes: Uint8Array,
|
pdfBytes: Uint8Array,
|
||||||
certificateData: CertificateData,
|
certificateData: CertificateData,
|
||||||
options: SignPdfOptions = {}
|
options: SignPdfOptions = {}
|
||||||
): Promise<Uint8Array> {
|
): Promise<Uint8Array> {
|
||||||
const signatureInfo = options.signatureInfo ?? {};
|
const signatureInfo = options.signatureInfo ?? {};
|
||||||
|
|
||||||
const signOptions: SignOption = {
|
const signOptions: SignOption = {
|
||||||
p12cert: certificateData.p12Buffer,
|
p12cert: certificateData.p12Buffer,
|
||||||
pwd: certificateData.password,
|
pwd: certificateData.password,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (signatureInfo.reason) {
|
||||||
|
signOptions.reason = signatureInfo.reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signatureInfo.location) {
|
||||||
|
signOptions.location = signatureInfo.location;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signatureInfo.contactInfo) {
|
||||||
|
signOptions.contact = signatureInfo.contactInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.visibleSignature?.enabled) {
|
||||||
|
const vs = options.visibleSignature;
|
||||||
|
|
||||||
|
const drawinf = {
|
||||||
|
area: {
|
||||||
|
x: vs.x,
|
||||||
|
y: vs.y,
|
||||||
|
w: vs.width,
|
||||||
|
h: vs.height,
|
||||||
|
},
|
||||||
|
pageidx: vs.page,
|
||||||
|
imgInfo: undefined as
|
||||||
|
| { imgData: ArrayBuffer; imgType: string }
|
||||||
|
| undefined,
|
||||||
|
textInfo: undefined as
|
||||||
|
| { text: string; size: number; color: string }
|
||||||
|
| undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (signatureInfo.reason) {
|
if (vs.imageData && vs.imageType) {
|
||||||
signOptions.reason = signatureInfo.reason;
|
drawinf.imgInfo = {
|
||||||
|
imgData: vs.imageData,
|
||||||
|
imgType: vs.imageType,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (signatureInfo.location) {
|
if (vs.text) {
|
||||||
signOptions.location = signatureInfo.location;
|
drawinf.textInfo = {
|
||||||
|
text: vs.text,
|
||||||
|
size: vs.textSize ?? 12,
|
||||||
|
color: vs.textColor ?? '#000000',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (signatureInfo.contactInfo) {
|
signOptions.drawinf = drawinf as SignOption['drawinf'];
|
||||||
signOptions.contact = signatureInfo.contactInfo;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (options.visibleSignature?.enabled) {
|
const signer = new PdfSigner(signOptions);
|
||||||
const vs = options.visibleSignature;
|
|
||||||
|
|
||||||
const drawinf = {
|
const { restore } = createCorsAwareFetch();
|
||||||
area: {
|
|
||||||
x: vs.x,
|
|
||||||
y: vs.y,
|
|
||||||
w: vs.width,
|
|
||||||
h: vs.height,
|
|
||||||
},
|
|
||||||
pageidx: vs.page,
|
|
||||||
imgInfo: undefined as { imgData: ArrayBuffer; imgType: string } | undefined,
|
|
||||||
textInfo: undefined as { text: string; size: number; color: string } | undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (vs.imageData && vs.imageType) {
|
try {
|
||||||
drawinf.imgInfo = {
|
const signedPdfBytes = await signer.sign(pdfBytes);
|
||||||
imgData: vs.imageData,
|
return new Uint8Array(signedPdfBytes);
|
||||||
imgType: vs.imageType,
|
} finally {
|
||||||
};
|
restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (vs.text) {
|
|
||||||
drawinf.textInfo = {
|
|
||||||
text: vs.text,
|
|
||||||
size: vs.textSize ?? 12,
|
|
||||||
color: vs.textColor ?? '#000000',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
signOptions.drawinf = drawinf as SignOption['drawinf'];
|
|
||||||
}
|
|
||||||
|
|
||||||
const signer = new PdfSigner(signOptions);
|
|
||||||
|
|
||||||
const { restore } = createCorsAwareFetch();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const signedPdfBytes = await signer.sign(pdfBytes);
|
|
||||||
return new Uint8Array(signedPdfBytes);
|
|
||||||
} finally {
|
|
||||||
restore();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCertificateInfo(certificate: forge.pki.Certificate): {
|
export function getCertificateInfo(certificate: forge.pki.Certificate): {
|
||||||
subject: string;
|
subject: string;
|
||||||
issuer: string;
|
issuer: string;
|
||||||
validFrom: Date;
|
validFrom: Date;
|
||||||
validTo: Date;
|
validTo: Date;
|
||||||
serialNumber: string;
|
serialNumber: string;
|
||||||
} {
|
} {
|
||||||
const subjectCN = certificate.subject.getField('CN');
|
const subjectCN = certificate.subject.getField('CN');
|
||||||
const issuerCN = certificate.issuer.getField('CN');
|
const issuerCN = certificate.issuer.getField('CN');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
subject: subjectCN?.value as string ?? 'Unknown',
|
subject: (subjectCN?.value as string) ?? 'Unknown',
|
||||||
issuer: issuerCN?.value as string ?? 'Unknown',
|
issuer: (issuerCN?.value as string) ?? 'Unknown',
|
||||||
validFrom: certificate.validity.notBefore,
|
validFrom: certificate.validity.notBefore,
|
||||||
validTo: certificate.validity.notAfter,
|
validTo: certificate.validity.notAfter,
|
||||||
serialNumber: certificate.serialNumber,
|
serialNumber: certificate.serialNumber,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user