feat(security): enhance security policies and implement input validation across multiple files

This commit is contained in:
alam00000
2026-07-14 19:46:25 +05:30
parent 0ef279e0da
commit 58cd1a8893
13 changed files with 293 additions and 52 deletions

View File

@@ -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'"
</IfModule>
# Block source maps from being served
<FilesMatch "\.map$">
Require all denied
</FilesMatch>
# ============================================
# 2. BROWSER CACHING
# ============================================

View File

@@ -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',
},
});

View File

@@ -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": {

View File

@@ -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);

View File

@@ -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') {

View File

@@ -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);

View File

@@ -273,7 +273,7 @@ placeholder="${escapeHTML(field.placeholder || '')}" />
<div class="grid grid-cols-2 gap-2">
<div>
<label class="text-xs text-gray-600">Page</label>
<input type="number" id="modal-dest-page" min="1" max="${field.maxPages || 1}" value="${defaultValues.destPage || field.page || 1}"
<input type="number" id="modal-dest-page" min="1" max="${escapeHTML(String(field.maxPages || 1))}" value="${escapeHTML(String(defaultValues.destPage || field.page || 1))}"
class="w-full px-2 py-1 border border-gray-300 rounded text-sm text-gray-900" step="1" />
</div>
<div>
@@ -1703,7 +1703,7 @@ function createNodeElement(node: BookmarkNode, level = 0): HTMLLIElement {
titleDiv.innerHTML = `
<span class="text-sm block ${styleClass} ${textColorClass}" ${customColorStyle}>${escapeHTML(node.title)}${destinationIcon}</span>
<span class="text-xs text-gray-500">Page ${node.page}</span>
<span class="text-xs text-gray-500">Page ${escapeHTML(String(node.page))}</span>
`;
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);
}
}

View File

@@ -134,9 +134,27 @@ async function convertCbzToPdf(file: File): Promise<Blob> {
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);

View File

@@ -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;
});

View File

@@ -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<HTMLButtonElement>(
'[id^="back-to-tools"]'
) as NodeListOf<HTMLButtonElement>;
);
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'
) {

View File

@@ -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) => {

View File

@@ -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) {

View File

@@ -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) {