diff --git a/.htaccess b/.htaccess index 1a21bcc3..81710c99 100644 --- a/.htaccess +++ b/.htaccess @@ -12,8 +12,14 @@ 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'" +# Block source maps from being served + + Require all denied + + # ============================================ # 2. BROWSER CACHING # ============================================ diff --git a/cloudflare/cors-proxy-worker.js b/cloudflare/cors-proxy-worker.js index fd5de902..79d7f107 100644 --- a/cloudflare/cors-proxy-worker.js +++ b/cloudflare/cors-proxy-worker.js @@ -13,13 +13,11 @@ */ const ALLOWED_PATH_PATTERNS = [ - /\.crt$/i, - /\.cer$/i, - /\.pem$/i, - /\/certs\//i, - /\/ocsp/i, - /\/crl/i, - /caIssuers/i, + /\.(crt|cer|pem|der|p7c|p7b)$/i, + /(^|\/)certs(\/|$)/i, + /(^|\/)ocsp(\/|$)/i, + /(^|\/)crl(\/|$)/i, + /(^|\/)caissuers(\/|$)/i, ]; const ALLOWED_TSA_HOSTS = new Set([ @@ -123,6 +121,31 @@ function isPrivateOrReservedHost(hostname) { return false; } +async function hostnameResolvesToPrivate(hostname) { + const clean = hostname.replace(/^\[|\]$/g, ''); + if (/^\d+\.\d+\.\d+\.\d+$/.test(clean) || clean.includes(':')) { + return isPrivateOrReservedHost(clean); + } + const ips = []; + for (const type of ['A', 'AAAA']) { + try { + const res = await fetch( + `https://1.1.1.1/dns-query?name=${encodeURIComponent(clean)}&type=${type}`, + { headers: { accept: 'application/dns-json' } } + ); + 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(/\.$/, '')); + } + } catch { + return true; + } + } + if (ips.length === 0) return true; + return ips.some((ip) => isPrivateOrReservedHost(ip)); +} + function isValidCertificateUrl(urlString) { try { const url = new URL(urlString); @@ -181,7 +204,7 @@ function handleOptions(request) { } export default { - async fetch(request, env, ctx) { + async fetch(request, env) { const url = new URL(request.url); const origin = request.headers.get('Origin'); @@ -293,7 +316,8 @@ export default { const now = Date.now(); if ( isNaN(requestTime) || - Math.abs(now - requestTime) > MAX_TIMESTAMP_AGE_MS + requestTime > now + 60000 || + now - requestTime > MAX_TIMESTAMP_AGE_MS ) { return new Response( JSON.stringify({ @@ -384,9 +408,33 @@ export default { ); } + let targetHostname; + try { + targetHostname = new URL(targetUrl).hostname; + } catch { + targetHostname = ''; + } + if (!targetHostname || (await hostnameResolvesToPrivate(targetHostname))) { + return new Response( + JSON.stringify({ + error: 'Forbidden destination', + message: 'The requested host is not permitted', + }), + { + status: 403, + headers: { + ...corsHeaders(origin), + 'Content-Type': 'application/json', + }, + } + ); + } + try { const upstreamInit = { method: request.method, + redirect: 'manual', + signal: AbortSignal.timeout(10000), headers: { 'User-Agent': 'BentoPDF-CertProxy/1.0', }, @@ -399,6 +447,22 @@ export default { const response = await fetch(targetUrl, upstreamInit); + if (response.status >= 300 && response.status < 400) { + return new Response( + JSON.stringify({ + error: 'Redirect blocked', + message: 'Upstream redirects are not allowed', + }), + { + status: 502, + headers: { + ...corsHeaders(origin), + 'Content-Type': 'application/json', + }, + } + ); + } + if (!response.ok) { return new Response( JSON.stringify({ @@ -480,7 +544,7 @@ export default { response.headers.get('Content-Type') ), 'Content-Length': totalSize.toString(), - 'Cache-Control': 'public, max-age=86400', + 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', }, }); diff --git a/package.json b/package.json index deee2635..be955fcb 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,8 @@ "lint:security": "eslint . --no-inline-config --rule 'no-unsanitized/method:error' --rule 'no-unsanitized/property:error' --rule 'security/detect-eval-with-expression:error'", "security:codeql": "codeql database create ./codeql-db --language=javascript-typescript --source-root=. --overwrite --threads=0 && codeql database analyze ./codeql-db --format=sarif-latest --output=codeql-results.sarif --threads=0 codeql/javascript-queries:codeql-suites/javascript-security-extended.qls", "security:codeql:quick": "codeql database analyze ./codeql-db --format=csv --output=codeql-results.csv --threads=0 codeql/javascript-queries:codeql-suites/javascript-security-extended.qls", - "security:audit": "npm audit --audit-level=high && npm run lint:security", + "security:patterns": "node scripts/security-patterns-check.mjs", + "security:audit": "npm audit --audit-level=high && npm run lint:security && npm run security:patterns", "prepare": "husky" }, "devDependencies": { diff --git a/public/pdfjs-viewer/form-viewer.html b/public/pdfjs-viewer/form-viewer.html index 42a79b94..cfdc7772 100644 --- a/public/pdfjs-viewer/form-viewer.html +++ b/public/pdfjs-viewer/form-viewer.html @@ -220,6 +220,7 @@ // Listen for messages from parent window window.addEventListener('message', async (event) => { + if (event.origin !== window.location.origin) return; if (event.data.type === 'loadPDF') { const { data } = event.data; loadPDF(data); diff --git a/public/pdfjs-viewer/sign-viewer.html b/public/pdfjs-viewer/sign-viewer.html index 8e6d05ea..44397cf8 100644 --- a/public/pdfjs-viewer/sign-viewer.html +++ b/public/pdfjs-viewer/sign-viewer.html @@ -239,6 +239,7 @@ // Listen for messages from parent window window.addEventListener('message', async (event) => { + if (event.origin !== window.location.origin) return; console.log('Sign viewer received message:', event.data.type); if (event.data.type === 'loadPDF') { diff --git a/scripts/security-patterns-check.mjs b/scripts/security-patterns-check.mjs new file mode 100644 index 00000000..7f4fcd72 --- /dev/null +++ b/scripts/security-patterns-check.mjs @@ -0,0 +1,121 @@ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, '..'); +const SCAN_DIRS = ['src/js', 'bentopdf-pymupdf-wasm/src', 'cloudflare']; +const SKIP = ['node_modules', 'dist', '.git', 'coverage', 'tests', 'test']; + +const findings = []; + +function walk(dir) { + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const e of entries) { + const full = path.join(dir, e.name); + if ( + SKIP.some( + (s) => + full.includes(`${path.sep}${s}${path.sep}`) || + full.endsWith(`${path.sep}${s}`) + ) + ) { + continue; + } + if (e.isDirectory()) walk(full); + else if (/\.(ts|js|mjs)$/.test(e.name) && !/\.test\.|\.d\.ts$/.test(e.name)) + checkFile(full); + } +} + +function checkFile(file) { + const rel = path.relative(ROOT, file); + const src = fs.readFileSync(file, 'utf-8'); + const lines = src.split('\n'); + + // Rule 1: no raw string interpolation into a Python/eval template. + const codeSink = /(runPython|\.eval)\s*\(\s*`/; + for (let i = 0; i < lines.length; i++) { + if (codeSink.test(lines[i])) { + for (let j = i; j < Math.min(i + 60, lines.length); j++) { + const interps = lines[j].match(/\$\{[^}]+\}/g) || []; + for (const it of interps) { + const danger = + /^\$\{\s*(text|name|uri|url|format|password|passwd|title|label|filename|content|search|searchText|query|font|fontname|intent|usage|replaceText)\s*(\}|\.|\))/.test( + it + ); + const safe = /pyStr|JSON\.stringify|escapeHtml/.test(it); + if (danger && !safe) { + findings.push({ + rule: 'code-injection', + file: rel, + line: j + 1, + detail: `Unescaped user-string interpolation ${it} into a Python/eval template — pass it via pyStr()/JSON.stringify()/globals.set().`, + }); + } + } + if (lines[j].includes('`)') || lines[j].trim().endsWith('`);')) break; + } + } + } + + // Rule 2: string mutation applied AFTER a sanitize call (sanitize-then-mutate). + const sanitizeLine = lines.findIndex((l) => + /DOMPurify\.sanitize|sanitizeEmailHtml|sanitize\(/.test(l) + ); + if (sanitizeLine !== -1) { + for ( + let j = sanitizeLine + 1; + j < Math.min(sanitizeLine + 40, lines.length); + j++ + ) { + if ( + /\.replace\(|innerHTML\s*=|\+=\s*`/.test(lines[j]) && + !/escapeHtml|escapeHTML|DOMPurify/.test(lines[j]) + ) { + findings.push({ + rule: 'sanitize-then-mutate', + file: rel, + line: j + 1, + detail: `Possible mutation of sanitized HTML. Escape/validate at the final sink, or re-sanitize after mutation.`, + }); + break; + } + } + } +} + +for (const d of SCAN_DIRS) walk(path.join(ROOT, d)); + +const blocking = findings.filter((f) => f.rule === 'code-injection'); +const warnings = findings.filter((f) => f.rule !== 'code-injection'); + +for (const f of warnings) { + console.warn(` ⚠ [${f.rule}] ${f.file}:${f.line}\n ${f.detail}`); +} +if (warnings.length) { + console.warn( + `\n${warnings.length} sanitize-then-mutate warning(s) — review each: escape/validate at the final sink.\n` + ); +} + +if (blocking.length === 0) { + console.log('✅ security-patterns-check: no code-injection patterns found'); + process.exit(0); +} + +console.error( + `\n❌ security-patterns-check found ${blocking.length} code-injection issue(s):\n` +); +for (const f of blocking) { + console.error(` [${f.rule}] ${f.file}:${f.line}\n ${f.detail}\n`); +} +console.error( + 'This pattern caused a prior CVE (pymupdf runPython injection). Pass values via pyStr()/JSON.stringify()/globals.set().\n' +); +process.exit(1); diff --git a/src/js/logic/bookmark-pdf.ts b/src/js/logic/bookmark-pdf.ts index 2ba4294a..ae938152 100644 --- a/src/js/logic/bookmark-pdf.ts +++ b/src/js/logic/bookmark-pdf.ts @@ -273,7 +273,7 @@ placeholder="${escapeHTML(field.placeholder || '')}" />
-
@@ -1703,7 +1703,7 @@ function createNodeElement(node: BookmarkNode, level = 0): HTMLLIElement { titleDiv.innerHTML = ` ${escapeHTML(node.title)}${destinationIcon} - Page ${node.page} + Page ${escapeHTML(String(node.page))} `; titleDiv.addEventListener('click', async () => { @@ -1951,7 +1951,14 @@ exportCsvBtn?.addEventListener('click', () => { const csv = 'title,page,level\n' + flat - .map((b) => `"${b.title.replace(/"/g, '""')}",${b.page},${b.level}`) + .map((b) => { + const first = b.title.charAt(0); + const t = + /[=+\-@]/.test(first) || first === '\t' || first === '\r' + ? `'${b.title}` + : b.title; + return `"${t.replace(/"/g, '""')}",${b.page},${b.level}`; + }) .join('\n'); const blob = new Blob([csv], { type: 'text/csv' }); @@ -2007,8 +2014,12 @@ jsonImportHidden?.addEventListener('change', async (e: Event) => { const imported = JSON.parse(text) as BookmarkTree; function cleanImportedTree(nodes: BookmarkNode[]): void { if (!nodes) return; + const validStyles = ['bold', 'italic', 'bold-italic']; for (const node of nodes) { if (node.title) node.title = cleanTitle(node.title); + node.page = Number.isFinite(Number(node.page)) + ? Math.max(1, Math.trunc(Number(node.page))) + : 1; if ( typeof node.color === 'string' && node.color.startsWith('#') && @@ -2016,6 +2027,21 @@ jsonImportHidden?.addEventListener('change', async (e: Event) => { ) { node.color = ''; } + if (!validStyles.includes(node.style as string)) { + node.style = null; + } + node.destX = + node.destX != null && Number.isFinite(Number(node.destX)) + ? Number(node.destX) + : null; + node.destY = + node.destY != null && Number.isFinite(Number(node.destY)) + ? Number(node.destY) + : null; + node.zoom = + node.zoom != null && /^-?\d*\.?\d+$/.test(String(node.zoom)) + ? String(node.zoom) + : null; if (node.children) cleanImportedTree(node.children); } } diff --git a/src/js/logic/cbz-to-pdf-page.ts b/src/js/logic/cbz-to-pdf-page.ts index f8d18167..2730b172 100644 --- a/src/js/logic/cbz-to-pdf-page.ts +++ b/src/js/logic/cbz-to-pdf-page.ts @@ -134,9 +134,27 @@ async function convertCbzToPdf(file: File): Promise { a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }) ); + const MAX_CBZ_PAGES = 2000; + const MAX_ENTRY_BYTES = 100 * 1024 * 1024; + const MAX_TOTAL_BYTES = 500 * 1024 * 1024; + if (imageFiles.length > MAX_CBZ_PAGES) { + throw new Error(`Archive has too many images (max ${MAX_CBZ_PAGES}).`); + } + let totalBytes = 0; + for (const filename of imageFiles) { const zipEntry = zip.files[filename]; + const declared = ( + zipEntry as unknown as { _data?: { uncompressedSize?: number } } + )._data?.uncompressedSize; + if (typeof declared === 'number' && declared > MAX_ENTRY_BYTES) { + throw new Error('Archive contains an oversized image entry.'); + } const imageData = await zipEntry.async('arraybuffer'); + totalBytes += imageData.byteLength; + if (totalBytes > MAX_TOTAL_BYTES) { + throw new Error('Archive is too large when decompressed.'); + } const dataArray = new Uint8Array(imageData); const actualFormat = detectImageFormat(dataArray); diff --git a/src/js/logic/email-to-pdf.ts b/src/js/logic/email-to-pdf.ts index 481fb339..bc812019 100644 --- a/src/js/logic/email-to-pdf.ts +++ b/src/js/logic/email-to-pdf.ts @@ -217,7 +217,13 @@ function processInlineImages( const att = cidMap.get(cid); if (att && att.content) { const base64 = uint8ArrayToBase64(att.content); - return `src="data:${att.contentType};base64,${base64}"`; + const safeType = + /^[a-zA-Z0-9][a-zA-Z0-9!#$&^_.+-]*\/[a-zA-Z0-9][a-zA-Z0-9!#$&^_.+-]*$/.test( + att.contentType + ) + ? att.contentType + : 'application/octet-stream'; + return `src="data:${safeType};base64,${base64}"`; } return match; }); diff --git a/src/js/logic/form-creator.ts b/src/js/logic/form-creator.ts index 318b482f..387c407d 100644 --- a/src/js/logic/form-creator.ts +++ b/src/js/logic/form-creator.ts @@ -90,9 +90,6 @@ const pdfFileInput = document.getElementById( 'pdfFileInput' ) as HTMLInputElement; const blankPdfBtn = document.getElementById('blankPdfBtn') as HTMLButtonElement; -const pdfUploadInput = document.getElementById( - 'pdfUploadInput' -) as HTMLInputElement; const pageSizeSelector = document.getElementById( 'pageSizeSelector' ) as HTMLDivElement; @@ -117,9 +114,6 @@ const nextPageBtn = document.getElementById('nextPageBtn') as HTMLButtonElement; const addPageBtn = document.getElementById('addPageBtn') as HTMLButtonElement; const resetBtn = document.getElementById('resetBtn') as HTMLButtonElement; const downloadBtn = document.getElementById('downloadBtn') as HTMLButtonElement; -const backToToolsBtn = document.getElementById( - 'back-to-tools' -) as HTMLButtonElement | null; const gotoPageInput = document.getElementById( 'gotoPageInput' ) as HTMLInputElement; @@ -794,14 +788,12 @@ function renderField(field: FormField): void { }); // Touch events for moving fields - let touchMoveStarted = false; fieldWrapper.addEventListener( 'touchstart', (e) => { if ((e.target as HTMLElement).classList.contains('resize-handle')) { return; } - touchMoveStarted = false; const touch = e.touches[0]; const rect = canvas.getBoundingClientRect(); offsetX = touch.clientX - rect.left - field.x; @@ -813,7 +805,6 @@ function renderField(field: FormField): void { fieldWrapper.addEventListener('touchmove', (e) => { e.preventDefault(); - touchMoveStarted = true; const touch = e.touches[0]; const rect = canvas.getBoundingClientRect(); let newX = touch.clientX - rect.left - offsetX; @@ -829,10 +820,6 @@ function renderField(field: FormField): void { field.y = newY; }); - fieldWrapper.addEventListener('touchend', () => { - touchMoveStarted = false; - }); - // Add resize handles to the container - hidden by default const handles = ['nw', 'ne', 'sw', 'se', 'n', 's', 'e', 'w']; handles.forEach((pos) => { @@ -2381,7 +2368,6 @@ downloadBtn.addEventListener('click', async () => { else if (field.options && field.options.length > 0) dropdown.select(field.options[0]); - const rgbColor = hexToRgb(field.textColor); dropdown.acroField.setFontSize(field.fontSize); dropdown.acroField.setDefaultAppearance( `0 0 0 rg /Helv ${field.fontSize} Tf` @@ -2417,7 +2403,6 @@ downloadBtn.addEventListener('click', async () => { else if (field.options && field.options.length > 0) optionList.select(field.options[0]); - const rgbColor = hexToRgb(field.textColor); optionList.acroField.setFontSize(field.fontSize); optionList.acroField.setDefaultAppearance( `0 0 0 rg /Helv ${field.fontSize} Tf` @@ -2547,7 +2532,10 @@ downloadBtn.addEventListener('click', async () => { }); // Add Date Format and Keystroke Actions to the FIELD (not widget) - const dateFormat = field.dateFormat || 'mm/dd/yyyy'; + const dateFormat = (field.dateFormat || 'mm/dd/yyyy').replace( + /[^a-zA-Z0-9/:.,\- ]/g, + '' + ); const formatAction = pdfDoc.context.obj({ Type: 'Action', @@ -2757,9 +2745,9 @@ downloadBtn.addEventListener('click', async () => { }); // Back to tools button -const backToToolsBtns = document.querySelectorAll( +const backToToolsBtns = document.querySelectorAll( '[id^="back-to-tools"]' -) as NodeListOf; +); backToToolsBtns.forEach((btn) => { btn.addEventListener('click', () => { window.location.href = import.meta.env.BASE_URL; @@ -3129,7 +3117,7 @@ let modalCloseCallback: (() => void) | null = null; function showModal( title: string, message: string, - type: 'error' | 'warning' | 'info' = 'error', + _type: 'error' | 'warning' | 'info' = 'error', onClose?: () => void, buttonText: string = 'Close' ) { diff --git a/src/js/logic/validate-signature-pdf-page.ts b/src/js/logic/validate-signature-pdf-page.ts index 71d343fb..b67fbb53 100644 --- a/src/js/logic/validate-signature-pdf-page.ts +++ b/src/js/logic/validate-signature-pdf-page.ts @@ -322,7 +322,7 @@ function displayResults(): void { 'mb-4 p-3 bg-gray-700 rounded-lg border border-gray-600'; const validCount = state.results.filter( - (r) => r.isValid && !r.isExpired + (r) => r.isValid && !r.isExpired && r.isTrusted ).length; const trustVerified = state.trustedCert ? state.results.filter((r) => r.isTrusted).length @@ -396,10 +396,12 @@ function createSignatureCard( statusColor = 'text-yellow-400'; statusIcon = 'alert-triangle'; statusText = 'Certificate Expired'; - } else if (result.isSelfSigned) { + } else if (!result.isTrusted) { statusColor = 'text-yellow-400'; statusIcon = 'alert-triangle'; - statusText = 'Self-Signed Certificate'; + statusText = result.isSelfSigned + ? 'Self-Signed — Signer Identity Not Verified' + : 'Signature Intact — Signer Identity Not Verified'; } const formatDate = (date: Date) => { diff --git a/src/js/logic/validate-signature-pdf.ts b/src/js/logic/validate-signature-pdf.ts index 7ebeb02e..7525aae7 100644 --- a/src/js/logic/validate-signature-pdf.ts +++ b/src/js/logic/validate-signature-pdf.ts @@ -145,25 +145,30 @@ export async function validateSignature( result.isSelfSigned = signerCert.isIssuer(signerCert); - // Check trust against provided certificate + // Check trust against provided certificate (cryptographic, not name/serial match) if (trustedCert) { try { - const isTrustedIssuer = trustedCert.isIssuer(signerCert); - const isSameCert = signerCert.serialNumber === trustedCert.serialNumber; + const trustedPem = forge.pki.certificateToPem(trustedCert); + const isSameCert = + forge.pki.certificateToPem(signerCert) === trustedPem; - let chainTrusted = false; - for (const cert of p7.certificates) { - if ( - trustedCert.isIssuer(cert) || - (cert as forge.pki.Certificate).serialNumber === - trustedCert.serialNumber - ) { - chainTrusted = true; - break; + let cryptoIssued = false; + const chain = [ + signerCert, + ...(p7.certificates as forge.pki.Certificate[]), + ]; + for (const cert of chain) { + try { + if (trustedCert.verify(cert)) { + cryptoIssued = true; + break; + } + } catch { + continue; } } - result.isTrusted = isTrustedIssuer || isSameCert || chainTrusted; + result.isTrusted = isSameCert || cryptoIssued; } catch { result.isTrusted = false; } @@ -223,7 +228,7 @@ export async function validateSignature( } if (signature.byteRange && signature.byteRange.length === 4) { - const [, len1, start2, len2] = signature.byteRange; + const [, , start2, len2] = signature.byteRange; const expectedEnd = start2 + len2; if (expectedEnd === pdfBytes.length) { diff --git a/src/js/workflow/nodes/redact-node.ts b/src/js/workflow/nodes/redact-node.ts index 2160d4b2..d9175705 100644 --- a/src/js/workflow/nodes/redact-node.ts +++ b/src/js/workflow/nodes/redact-node.ts @@ -67,7 +67,9 @@ export class RedactNode extends BaseWorkflowNode { const pdfInputs = requirePdfInput(inputs, 'Redact'); const mode = this.getText('redactMode', 'text'); - const searchText = this.getText('text', ''); + const searchText = this.getText('text', '') + .replace(/\\/g, '') + .replace(/\p{Cc}/gu, ''); const fill = hexToRgb(this.getText('fillColor', '#000000')); if (mode === 'text' && !searchText) {