mirror of
https://github.com/alam00000/bentopdf.git
synced 2026-07-31 16:07:19 -04:00
feat(security): enhance security policies and implement input validation across multiple files
This commit is contained in:
@@ -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
|
||||
# ============================================
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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') {
|
||||
|
||||
121
scripts/security-patterns-check.mjs
Normal file
121
scripts/security-patterns-check.mjs
Normal 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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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'
|
||||
) {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user