diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml index c45724e8..46e58946 100644 --- a/.github/workflows/build-and-publish.yml +++ b/.github/workflows/build-and-publish.yml @@ -45,7 +45,7 @@ jobs: run: npm run package - name: Create GitHub Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 with: files: | dist-${{ steps.version.outputs.version }}.zip @@ -77,10 +77,10 @@ jobs: uses: actions/checkout@v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Login to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -103,7 +103,7 @@ jobs: - name: Build and push amd64 ${{ matrix.mode.name }} (release) if: steps.version.outputs.is_release == 'true' - uses: docker/build-push-action@v6 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: push: true build-args: | @@ -111,12 +111,11 @@ jobs: tags: | ghcr.io/${{ github.repository_owner }}/bentopdf${{ matrix.mode.suffix }}:${{ steps.version.outputs.version }}-amd64 platforms: linux/amd64 - cache-from: type=gha,scope=amd64-${{ matrix.mode.name }} - cache-to: type=gha,mode=max,scope=amd64-${{ matrix.mode.name }} + no-cache: true - name: Build and push amd64 ${{ matrix.mode.name }} (edge) if: steps.version.outputs.is_release == 'false' - uses: docker/build-push-action@v6 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: push: true build-args: | @@ -146,10 +145,10 @@ jobs: uses: actions/checkout@v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Login to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -172,7 +171,7 @@ jobs: - name: Build and push arm64 ${{ matrix.mode.name }} (release) if: steps.version.outputs.is_release == 'true' - uses: docker/build-push-action@v6 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: push: true build-args: | @@ -180,12 +179,11 @@ jobs: tags: | ghcr.io/${{ github.repository_owner }}/bentopdf${{ matrix.mode.suffix }}:${{ steps.version.outputs.version }}-arm64 platforms: linux/arm64 - cache-from: type=gha,scope=arm64-${{ matrix.mode.name }} - cache-to: type=gha,mode=max,scope=arm64-${{ matrix.mode.name }} + no-cache: true - name: Build and push arm64 ${{ matrix.mode.name }} (edge) if: steps.version.outputs.is_release == 'false' - uses: docker/build-push-action@v6 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: push: true build-args: | @@ -211,7 +209,7 @@ jobs: suffix: "-simple" steps: - name: Login to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -275,14 +273,14 @@ jobs: suffix: "-simple" steps: - name: Login to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Login to DockerHub - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_TOKEN }} diff --git a/.github/workflows/sponsors.yml b/.github/workflows/sponsors.yml index 8d261dc5..f3bd1150 100644 --- a/.github/workflows/sponsors.yml +++ b/.github/workflows/sponsors.yml @@ -6,7 +6,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: JamesIves/github-sponsors-readme-action@v1 + - uses: JamesIves/github-sponsors-readme-action@02650b8cd445fc16dfef73195f9c406dce041623 # v1.6.1 with: token: ${{ secrets.SPONSORS_TOKEN }} file: 'README.md' \ No newline at end of file diff --git a/.github/workflows/trivy-scan.yml b/.github/workflows/trivy-scan.yml index b4cc2dd1..1768047b 100644 --- a/.github/workflows/trivy-scan.yml +++ b/.github/workflows/trivy-scan.yml @@ -28,10 +28,10 @@ jobs: uses: actions/checkout@v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Build image for scanning - uses: docker/build-push-action@v6 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: context: . file: ${{ matrix.image.file }} @@ -41,7 +41,7 @@ jobs: no-cache: true - name: Run Trivy vulnerability scanner - uses: aquasecurity/trivy-action@master + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: image-ref: ${{ matrix.image.name }}:scan format: sarif @@ -63,7 +63,7 @@ jobs: uses: actions/checkout@v4 - name: Scan npm dependencies with Trivy - uses: aquasecurity/trivy-action@master + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: scan-type: fs scan-ref: . @@ -86,7 +86,7 @@ jobs: uses: actions/checkout@v4 - name: Scan Dockerfiles for misconfigurations - uses: aquasecurity/trivy-action@master + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: scan-type: config scan-ref: . diff --git a/.htaccess b/.htaccess index da24d341..5c790594 100644 --- a/.htaccess +++ b/.htaccess @@ -12,7 +12,7 @@ Header always set Referrer-Policy "strict-origin-when-cross-origin" Header always set Permissions-Policy "geolocation=(), microphone=(), camera=()" Header always set Cross-Origin-Opener-Policy "same-origin" Header always set Cross-Origin-Embedder-Policy "require-corp" -Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval' blob: https://cdn.jsdelivr.net; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com https://rawcdn.githack.com; connect-src 'self' blob: https://api.github.com https://fonts.gstatic.com https://cdn.jsdelivr.net https://bentopdf-cors-proxy.bentopdf.workers.dev https://rawcdn.githack.com; object-src 'none'; base-uri 'self'; frame-src 'self' blob:; frame-ancestors 'self'; form-action 'self'" +Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval' blob: https://cdn.jsdelivr.net/npm/@bentopdf/pymupdf-wasm@0.11.16/ https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm@0.1.1/assets/ https://cdn.jsdelivr.net/npm/coherentpdf@2.5.5/dist/ https://cdn.jsdelivr.net/npm/tesseract.js@v7.0.0/dist/ https://cdn.jsdelivr.net/npm/tesseract.js-core@v7.0.0/; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com https://rawcdn.githack.com; connect-src 'self' blob: https://api.github.com https://fonts.gstatic.com https://cdn.jsdelivr.net https://bentopdf-cors-proxy.bentopdf.workers.dev https://rawcdn.githack.com; object-src 'none'; base-uri 'self'; frame-src 'self' blob:; frame-ancestors 'self'; form-action 'self'" # Block source maps from being served diff --git a/STATIC-HOSTING.md b/STATIC-HOSTING.md index 434039db..9030b4ca 100644 --- a/STATIC-HOSTING.md +++ b/STATIC-HOSTING.md @@ -62,6 +62,17 @@ When the build completes, you can find the website at `https://[your-github-user If/when you merge changes from the source BentoPDF repository, the build and deploy action will automatically be kicked off and the new version will be automatically deployed to GitHub Pages. +> **Note:** GitHub Pages cannot set custom response headers, so a GitHub Pages deployment is served without BentoPDF's Content-Security-Policy and other security headers. If you need them, use one of the other hosts below or put a proxy or CDN that can add headers in front of your Pages site. + +## Security headers + +BentoPDF ships a Content-Security-Policy and other security headers for each static host: + +- **Netlify and Cloudflare Pages** read `_headers` from the deployed folder. The build copies `public/_headers` into `dist/_headers`, and `scripts/generate-security-headers.mjs` regenerates both during `npm run build`, so any `VITE_WASM_*`, `VITE_TESSERACT_*`, `VITE_CORS_PROXY_URL` or `VITE_OCR_FONT_BASE_URL` values you set at build time are allowed by the policy. The release `dist-{version}.zip` already contains it. +- **Vercel** uses the `headers` block in `vercel.json`. It holds the default policy, so if you point the `VITE_*` URLs at your own servers, update the Content-Security-Policy there to match `public/_headers`. +- **`npx serve`** reads `serve.json` from the folder it serves. To use the repository's `serve.json` with the build output, run `npx serve dist -c ../serve.json`. +- **GitHub Pages** cannot set headers (see the note above). + ## SEO and search engine indexing By default, every self-hosted BentoPDF build emits canonical URLs and an diff --git a/cloudflare/WASM-PROXY.md b/cloudflare/WASM-PROXY.md index fa6963d8..3fdce5e9 100644 --- a/cloudflare/WASM-PROXY.md +++ b/cloudflare/WASM-PROXY.md @@ -83,7 +83,7 @@ npx wrangler deploy -c wasm-wrangler.toml ## Security Features - **Origin validation**: Only allows requests from configured origins -- **Rate limiting**: 100 requests/minute per IP (requires KV namespace) +- **Rate limiting**: 100 requests/minute per IP, per /64 for IPv6 (requires KV namespace; fails open on KV errors) - **File type restrictions**: Only WASM-related files (.js, .wasm, .data, etc.) - **Size limits**: Max 100MB per file - **Caching**: Reduces origin requests and improves performance diff --git a/cloudflare/cors-proxy-worker.js b/cloudflare/cors-proxy-worker.js index 6eb38ca7..fa1681ed 100644 --- a/cloudflare/cors-proxy-worker.js +++ b/cloudflare/cors-proxy-worker.js @@ -81,53 +81,134 @@ function isAllowedOrigin(origin) { return ALLOWED_ORIGINS.includes(origin); } +function parseIPv4(address) { + const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(address); + if (!match) return null; + const octets = match.slice(1).map(Number); + if (octets.some((octet) => octet > 255)) return null; + return octets.reduce((value, octet) => value * 256 + octet, 0); +} + +function parseIPv6(address) { + let text = address.toLowerCase(); + const embedded = /^(.*:)(\d+\.\d+\.\d+\.\d+)$/.exec(text); + if (embedded) { + const ipv4 = parseIPv4(embedded[2]); + if (ipv4 === null) return null; + text = `${embedded[1]}${Math.floor(ipv4 / 65536).toString(16)}:${(ipv4 % 65536).toString(16)}`; + } + const sections = text.split('::'); + if (sections.length > 2) return null; + const head = sections[0] ? sections[0].split(':') : []; + const tail = + sections.length === 2 && sections[1] ? sections[1].split(':') : []; + const missing = 8 - head.length - tail.length; + if (sections.length === 1 ? missing !== 0 : missing < 1) return null; + const groups = [...head, ...Array(missing).fill('0'), ...tail]; + if (!groups.every((group) => /^[0-9a-f]{1,4}$/.test(group))) return null; + return groups.map((group) => parseInt(group, 16)); +} + +const BLOCKED_IPV4_RANGES = [ + '0.0.0.0/8', + '10.0.0.0/8', + '100.64.0.0/10', + '127.0.0.0/8', + '169.254.0.0/16', + '172.16.0.0/12', + '192.0.0.0/24', + '192.0.2.0/24', + '192.168.0.0/16', + '198.18.0.0/15', + '198.51.100.0/24', + '203.0.113.0/24', + '224.0.0.0/4', + '240.0.0.0/4', +].map((cidr) => { + const [address, prefixLength] = cidr.split('/'); + return { base: parseIPv4(address), size: 2 ** (32 - Number(prefixLength)) }; +}); + +const BLOCKED_IPV6_RANGES = [ + '::/128', + '::1/128', + '::/96', + '::ffff:0:0/96', + '64:ff9b::/96', + '64:ff9b:1::/48', + '100::/64', + '2001::/32', + '2001:db8::/32', + '2002::/16', + 'fc00::/7', + 'fe80::/10', + 'fec0::/10', + 'ff00::/8', +].map((cidr) => { + const [address, prefixLength] = cidr.split('/'); + return { groups: parseIPv6(address), prefixLength: Number(prefixLength) }; +}); + +function isBlockedIPv4(value) { + return BLOCKED_IPV4_RANGES.some( + ({ base, size }) => value >= base && value < base + size + ); +} + +function isBlockedIPv6(groups) { + return BLOCKED_IPV6_RANGES.some((range) => + range.groups.every((group, index) => { + const bitsInGroup = Math.min( + 16, + Math.max(0, range.prefixLength - index * 16) + ); + const groupMask = (0xffff << (16 - bitsInGroup)) & 0xffff; + return (groups[index] & groupMask) === (group & groupMask); + }) + ); +} + +function isBlockedIp(address) { + const ipv4 = parseIPv4(address); + if (ipv4 !== null) return isBlockedIPv4(ipv4); + const ipv6Groups = parseIPv6(address); + if (ipv6Groups !== null) return isBlockedIPv6(ipv6Groups); + return true; +} + +function isIpLiteral(host) { + return /^[\d.]+$/.test(host) || host.includes(':'); +} + +function getRateLimitKey(ip) { + const ipv6Groups = ip.includes(':') ? parseIPv6(ip) : null; + if (!ipv6Groups) return ip; + return `${ipv6Groups + .slice(0, 4) + .map((group) => group.toString(16)) + .join(':')}::/64`; +} + 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) - ) { + const host = hostname + .toLowerCase() + .replace(/^\[|\]$/g, '') + .replace(/\.$/, ''); + + if (['localhost', 'localhost.localdomain'].includes(host)) { 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; + return isIpLiteral(host) && isBlockedIp(host); } async function hostnameResolvesToPrivate(hostname) { - const clean = hostname.replace(/^\[|\]$/g, ''); - if (/^\d+\.\d+\.\d+\.\d+$/.test(clean) || clean.includes(':')) { - return isPrivateOrReservedHost(clean); - } + const clean = hostname + .toLowerCase() + .replace(/^\[|\]$/g, '') + .replace(/\.$/, ''); + if (isPrivateOrReservedHost(clean)) return true; + if (isIpLiteral(clean)) return false; const ips = []; for (const type of ['A', 'AAAA']) { try { @@ -138,14 +219,16 @@ async function hostnameResolvesToPrivate(hostname) { if (!res.ok) return true; const data = await res.json(); for (const ans of data.Answer || []) { - if (ans && ans.data) ips.push(String(ans.data).replace(/\.$/, '')); + if (ans && (ans.type === 1 || ans.type === 28) && ans.data) { + ips.push(String(ans.data)); + } } } catch { return true; } } if (ips.length === 0) return true; - return ips.some((ip) => isPrivateOrReservedHost(ip)); + return ips.some((ip) => isBlockedIp(ip)); } function isValidCertificateUrl(urlString) { @@ -372,49 +455,56 @@ export default { } const clientIP = request.headers.get('CF-Connecting-IP') || 'unknown'; - const rateLimitKey = `ratelimit:${clientIP}`; + const rateLimitKey = `ratelimit:${getRateLimitKey(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 || []; + try { + 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 - ); + 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( + 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 - ).toString(), - }, + ), + }), + { + 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, } ); + } catch (error) { + console.error( + '[CORS Proxy] Rate limit KV error, allowing request:', + error + ); } - - 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' diff --git a/cloudflare/wasm-proxy-worker.js b/cloudflare/wasm-proxy-worker.js index a61346e2..fdfe6449 100644 --- a/cloudflare/wasm-proxy-worker.js +++ b/cloudflare/wasm-proxy-worker.js @@ -39,9 +39,38 @@ const ALLOWED_EXTENSIONS = [ '.mem', '.asm.js', '.worker.js', - '.html', + '.whl', ]; +const SAFE_SUBPATH = /^\/[A-Za-z0-9._/-]*$/; + +function isSafeSubpath(subpath) { + return ( + SAFE_SUBPATH.test(subpath) && + !subpath.split('/').some((segment) => segment === '.' || segment === '..') + ); +} + +function getRateLimitKey(ip) { + if (!ip.includes(':') || ip.includes('.')) return ip; + const sections = ip.toLowerCase().split('::'); + if (sections.length > 2) return ip; + const leadingGroups = sections[0] ? sections[0].split(':') : []; + const trailingGroups = sections[1] ? sections[1].split(':') : []; + const missing = 8 - leadingGroups.length - trailingGroups.length; + if (sections.length === 1 ? missing !== 0 : missing < 1) return ip; + const groups = [ + ...leadingGroups, + ...Array(missing).fill('0'), + ...trailingGroups, + ]; + if (!groups.every((group) => /^[0-9a-f]{1,4}$/.test(group))) return ip; + return `${groups + .slice(0, 4) + .map((group) => parseInt(group, 16).toString(16)) + .join(':')}::/64`; +} + function isAllowedOrigin(origin) { if (!origin) return true; // Allow no-origin requests (e.g., direct browser navigation) return ALLOWED_ORIGINS.some((allowed) => @@ -66,6 +95,7 @@ function corsHeaders(origin) { 'Access-Control-Expose-Headers': 'Content-Length, Content-Range, Content-Type', 'Access-Control-Max-Age': '86400', + 'X-Content-Type-Options': 'nosniff', }; } @@ -89,7 +119,6 @@ function getContentType(pathname) { '.so': 'application/octet-stream', '.zip': 'application/zip', '.mem': 'application/octet-stream', - '.html': 'text/html', }; return contentTypes[ext] || 'application/octet-stream'; } @@ -115,7 +144,34 @@ async function proxyRequest(request, env, sourceBaseUrl, subpath, origin) { ? sourceBaseUrl.slice(0, -1) : sourceBaseUrl; const normalizedPath = subpath.startsWith('/') ? subpath : `/${subpath}`; - const targetUrl = `${normalizedBase}${normalizedPath}`; + + let targetUrl = null; + if (isSafeSubpath(normalizedPath)) { + try { + const candidate = new URL(`${normalizedBase}${normalizedPath}`).href; + if (candidate.startsWith(new URL(`${normalizedBase}/`).href)) { + targetUrl = candidate; + } + } catch { + targetUrl = null; + } + } + + if (!targetUrl) { + return new Response( + JSON.stringify({ + error: 'Invalid path', + message: 'The requested path is not allowed.', + }), + { + status: 400, + headers: { + ...corsHeaders(origin), + 'Content-Type': 'application/json', + }, + } + ); + } if (!isAllowedFile(normalizedPath)) { return new Response( @@ -151,8 +207,6 @@ async function proxyRequest(request, env, sourceBaseUrl, subpath, origin) { JSON.stringify({ error: 'Failed to fetch resource', status: response.status, - statusText: response.statusText, - targetUrl: targetUrl, }), { status: response.status, @@ -204,14 +258,14 @@ async function proxyRequest(request, env, sourceBaseUrl, subpath, origin) { 'Content-Type': getContentType(normalizedPath), 'Content-Length': bodyData.byteLength.toString(), 'Cache-Control': `public, max-age=${CACHE_TTL_SECONDS}`, - 'X-Proxied-From': new URL(targetUrl).hostname, }, }); } catch (error) { + console.error('[WASM Proxy] Fetch error:', error); return new Response( JSON.stringify({ error: 'Proxy error', - message: error.message, + message: 'Failed to fetch the requested resource', }), { status: 500, @@ -260,42 +314,49 @@ export default { if (env.RATE_LIMIT_KV) { const clientIP = request.headers.get('CF-Connecting-IP') || 'unknown'; - const rateLimitKey = `wasm-ratelimit:${clientIP}`; + const rateLimitKey = `wasm-ratelimit:${getRateLimitKey(clientIP)}`; const now = Date.now(); - 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 - ); + try { + 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.`, - }), + 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.`, + }), + { + status: 429, + headers: { + ...corsHeaders(origin), + 'Content-Type': 'application/json', + 'Retry-After': '60', + }, + } + ); + } + + recentRequests.push(now); + await env.RATE_LIMIT_KV.put( + rateLimitKey, + JSON.stringify({ requests: recentRequests }), { - status: 429, - headers: { - ...corsHeaders(origin), - 'Content-Type': 'application/json', - 'Retry-After': '60', - }, + expirationTtl: 120, } ); + } catch (error) { + console.error( + '[WASM Proxy] Rate limit KV error, allowing request:', + error + ); } - - recentRequests.push(now); - await env.RATE_LIMIT_KV.put( - rateLimitKey, - JSON.stringify({ requests: recentRequests }), - { - expirationTtl: 120, - } - ); } if (pathname.startsWith('/pymupdf/')) { diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index c4591fa8..b1ff8b2d 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -7,6 +7,7 @@ export default defineConfig({ description: "Documentation for BentoPDF - The free, open-source, privacy-first PDF toolkit", base: '/docs/', cleanUrls: true, + metaChunk: true, transformPageData(pageData) { const relPath = pageData.relativePath.replace(/\.md$/, '') diff --git a/docs/self-hosting/cors-proxy.md b/docs/self-hosting/cors-proxy.md index 14ebc042..27e6db2a 100644 --- a/docs/self-hosting/cors-proxy.md +++ b/docs/self-hosting/cors-proxy.md @@ -123,7 +123,7 @@ The included Cloudflare Worker has several security measures: | **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) | +| **Rate Limiting** | 60 requests per minute per IP or IPv6 /64 (requires KV, fails open on KV errors) | | **HMAC Signatures** | Optional client-side signing (deters casual abuse) | ### DNS rebinding (self-hosting off Cloudflare) diff --git a/docs/self-hosting/docker.md b/docs/self-hosting/docker.md index 54f7297b..3e19208a 100644 --- a/docs/self-hosting/docker.md +++ b/docs/self-hosting/docker.md @@ -126,10 +126,11 @@ The nginx image ships with an enforcing `Content-Security-Policy` header. The CS As a result: - If you override `VITE_CORS_PROXY_URL` or `VITE_WASM_*_URL` at build time, the CSP permits those origins automatically — no extra config needed. -- If you configure custom WASM URLs at _runtime_ via the in-app Advanced Settings page, those origins are **not** in the CSP and the browser will block fetches to them. Runtime configuration is intended for experimentation; for permanent custom URLs set the matching `VITE_*` build arg. -- Air-gapped deployments that override all three `VITE_WASM_*_URL` values also get the public `cdn.jsdelivr.net` removed from CSP (each default is replaced, not appended). Similarly, setting `VITE_CORS_PROXY_URL` replaces the public `bentopdf-cors-proxy.bentopdf.workers.dev` default. +- For URLs on `cdn.jsdelivr.net`, `script-src` allows only the exact versioned package folder, for example `https://cdn.jsdelivr.net/npm/@bentopdf/pymupdf-wasm@0.11.16/`, not the whole CDN. jsDelivr serves any public npm package or GitHub repository, so allowing the whole host would let any of them run on your instance. URLs on any other host are allowed by origin. +- If you configure custom WASM URLs at _runtime_ via the in-app Advanced Settings page, those origins are **not** in the CSP and the browser will block fetches to them. This also applies to a different version of the same package on `cdn.jsdelivr.net`, because only the version set at build time is allowed. Runtime configuration is intended for experimentation; for permanent custom URLs set the matching `VITE_*` build arg. +- Air-gapped deployments that override all three `VITE_WASM_*_URL` values also get those `cdn.jsdelivr.net` package folders removed from CSP (each default is replaced, not appended). The Tesseract OCR defaults on `cdn.jsdelivr.net` stay in the policy until you also set the three `VITE_TESSERACT_*` values. Similarly, setting `VITE_CORS_PROXY_URL` replaces the public `bentopdf-cors-proxy.bentopdf.workers.dev` default. -The CSP includes `'unsafe-eval'` in `script-src` because the LibreOffice WASM runtime (used by Word/Excel/PowerPoint conversion tools) compiles internal dispatch code via `new Function()`. Removing it would break all LibreOffice-backed tools. If you build in `SIMPLE_MODE` (without LibreOffice), you can manually edit the generated `security-headers.conf` to drop `'unsafe-eval'` for a stricter policy. +The CSP includes `'unsafe-eval'` in `script-src` because the LibreOffice WASM runtime (used by Word/Excel/PowerPoint conversion tools) and the wasm-vips image library (used by PDF to TIFF) compile internal dispatch code via `new Function()`. Removing it would break those tools. If you build in `SIMPLE_MODE` (without LibreOffice) and do not need PDF to TIFF, you can manually edit the generated `security-headers.conf` to drop `'unsafe-eval'` for a stricter policy. For OCR, leave the `VITE_TESSERACT_*` variables empty to use the default online assets, or set all three together for self-hosted/offline OCR. Partial OCR overrides are rejected because the worker, core runtime, and traineddata directory must match. For fully offline searchable PDF output, also set `VITE_OCR_FONT_BASE_URL` so the OCR text-layer fonts are loaded from your internal server instead of the public Noto font URLs. diff --git a/docs/tools/edit-pdf.md b/docs/tools/edit-pdf.md index 26dc8144..055e81de 100644 --- a/docs/tools/edit-pdf.md +++ b/docs/tools/edit-pdf.md @@ -18,7 +18,7 @@ BentoPDF's PDF Editor lets you annotate, highlight, redact, comment, add shapes - Full annotation toolbar with highlighting, underlining, strikethrough, and freehand drawing - Text insertion and commenting -- Redaction tools for permanently removing sensitive content +- Redaction tools that permanently remove marked content once you apply the redaction. If any marks haven't been applied when you click **Download Edited PDF**, the editor offers to apply them first. - Shape tools for rectangles, circles, lines, and arrows - Image insertion onto PDF pages - Built-in text search across the document @@ -37,6 +37,7 @@ BentoPDF's PDF Editor lets you annotate, highlight, redact, comment, add shapes - You can open multiple PDFs at once and switch between them using the tab bar at the top of the editor. - The editor preserves the original PDF structure, so fonts, layouts, and form fields remain intact. +- Marking text or an area for redaction doesn't remove anything until the redaction is applied. When you redact selected text, copies of it in the file's properties, bookmarks, comments, and attachments are also removed from the download, unless the file is encrypted. - For bulk watermarking or page numbering, use the dedicated tools instead for more control over formatting. ## Related Tools diff --git a/pdf-security.html b/pdf-security.html index 2f92c51e..15f96472 100644 --- a/pdf-security.html +++ b/pdf-security.html @@ -313,11 +313,17 @@ Use the redaction tool in the PDF Editor, which permanently removes the marked content from the page - instead of just covering it with a box. Redaction only deals with - what's on the page, so if the file's properties, comments, or - attachments could reveal the same information, run Remove Metadata - or Sanitize PDF on the result as well. + >. Marking text or an area only flags it. Once you apply the + redaction, the marked content is permanently removed from the page + instead of just being covered with a box, and if you download with + marks that haven't been applied yet, the editor offers to apply + them first. When you redact selected text, copies of it in the + file's properties, bookmarks, comments, and attachments are + removed on download too, unless the file is encrypted. Area + redactions have no text to search for, so if the file's + properties, comments, or attachments could reveal the same + information, run Remove Metadata or Sanitize PDF on the result as + well.