fix(security): harden sanitize, flatten, redaction, signature validation, CSP, proxies and CI

This commit is contained in:
alam00000
2026-10-19 14:45:00 +05:30
parent b0a186839a
commit edd959f345
62 changed files with 4813 additions and 926 deletions
+3 -2
View File
@@ -6,6 +6,7 @@ import {
downloadFile,
} from '../utils/helpers.js';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import { sanitizeTranslationHtml } from '../utils/translation-html.js';
import { t } from '../i18n/i18n';
import {
signPdf,
@@ -404,7 +405,7 @@ async function handleCertFile(file: File): Promise<void> {
const certStatus = getElement<HTMLDivElement>('cert-status');
if (certStatus) {
certStatus.innerHTML = `${t('tools:digitalSignPdf.certificateLoaded')} <i data-lucide="check" class="inline w-4 h-4"></i>`;
certStatus.innerHTML = `${sanitizeTranslationHtml(t('tools:digitalSignPdf.certificateLoaded'))} <i data-lucide="check" class="inline w-4 h-4"></i>`;
createIcons({ icons });
certStatus.className = 'text-xs text-green-400';
}
@@ -559,7 +560,7 @@ async function handlePasswordInput(): Promise<void> {
const certStatus = getElement<HTMLDivElement>('cert-status');
if (certStatus) {
certStatus.innerHTML = `${t('tools:digitalSignPdf.certificateUnlocked')} <i data-lucide="check-circle" class="inline w-4 h-4"></i>`;
certStatus.innerHTML = `${sanitizeTranslationHtml(t('tools:digitalSignPdf.certificateUnlocked'))} <i data-lucide="check-circle" class="inline w-4 h-4"></i>`;
createIcons({ icons });
certStatus.className = 'text-xs text-green-400';
}
+232 -1
View File
@@ -15,13 +15,164 @@ const embedPdfWasmUrl = new URL(
import type { EmbedPdfContainer } from 'bentopdf-viewer';
import type {
ActiveDocumentSource,
AnnotationPluginLite,
AppliedRedactionTracker,
DocManagerPlugin,
FreeTextSystemFontAnnotation,
RedactionPluginLite,
} from '@/types';
const FREETEXT_SUBTYPE = 3;
export function trackAppliedRedactions(
redaction: RedactionPluginLite
): AppliedRedactionTracker {
const pendingByDoc = new Map<string, Map<string, string | undefined>>();
const appliedByDoc = new Map<string, Map<string, string | undefined>>();
const lastLeftPendingByDoc = new Map<string, string[]>();
const getAppliedRedactions = (documentId: string) => {
let applied = appliedByDoc.get(documentId);
if (!applied) {
applied = new Map();
appliedByDoc.set(documentId, applied);
}
return applied;
};
redaction.onPendingChange(({ documentId, pending }) => {
const previous = pendingByDoc.get(documentId) ?? new Map();
const next = new Map<string, string | undefined>();
for (const items of Object.values(pending)) {
for (const item of items) {
next.set(item.id, item.text ?? previous.get(item.id));
}
}
const applied = getAppliedRedactions(documentId);
const leftPending: string[] = [];
for (const [id, text] of previous) {
if (!next.has(id)) {
applied.set(id, text);
leftPending.push(id);
}
}
for (const id of next.keys()) applied.delete(id);
pendingByDoc.set(documentId, next);
lastLeftPendingByDoc.set(documentId, leftPending);
});
redaction.onRedactionEvent((event) => {
const applied = appliedByDoc.get(event.documentId);
if (event.type === 'remove' && event.id) {
applied?.delete(event.id);
} else if (event.type === 'clear') {
for (const id of lastLeftPendingByDoc.get(event.documentId) ?? []) {
applied?.delete(id);
}
}
});
return {
appliedTexts: (documentId) => [
...(appliedByDoc.get(documentId)?.values() ?? []),
],
forget: (documentId) => {
pendingByDoc.delete(documentId);
appliedByDoc.delete(documentId);
lastLeftPendingByDoc.delete(documentId);
},
};
}
export function redactionScrubTerms(
texts: Iterable<string | undefined>
): string[] {
const terms = new Map<string, string>();
for (const text of texts) {
for (const line of text?.split(/[\r\n]+/) ?? []) {
const term = line.trim();
const key = term.toLowerCase();
if (term && !terms.has(key)) terms.set(key, term);
}
}
return [...terms.values()].sort((a, b) => b.length - a.length);
}
export async function scrubRedactedText(
bytes: Uint8Array,
terms: string[]
): Promise<Uint8Array> {
const { redactDocumentData } =
await import('../utils/redact-document-data.js');
return redactDocumentData(bytes, terms);
}
function confirmChoice(
title: string,
message: string,
confirmLabel: string
): Promise<boolean> {
return new Promise((resolve) => {
const previousActive = document.activeElement as HTMLElement | null;
const overlay = document.createElement('div');
overlay.className =
'fixed inset-0 bg-gray-900 bg-opacity-90 flex items-center justify-center z-50';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.setAttribute('aria-labelledby', 'edit-pdf-confirm-title');
const panel = document.createElement('div');
panel.className =
'bg-gray-800 rounded-lg shadow-xl p-6 max-w-sm w-full border border-gray-700';
const heading = document.createElement('h3');
heading.id = 'edit-pdf-confirm-title';
heading.className = 'text-xl font-bold text-white mb-2';
heading.textContent = title;
const body = document.createElement('p');
body.className = 'text-gray-300 mb-6';
body.textContent = message;
const cancelBtn = document.createElement('button');
cancelBtn.type = 'button';
cancelBtn.className =
'flex-1 bg-gray-700 hover:bg-gray-600 text-white font-semibold py-2 px-4 rounded-lg transition-colors duration-200';
cancelBtn.textContent = 'Cancel';
const confirmBtn = document.createElement('button');
confirmBtn.type = 'button';
confirmBtn.className =
'flex-1 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-2 px-4 rounded-lg transition-colors duration-200';
confirmBtn.textContent = confirmLabel;
const actions = document.createElement('div');
actions.className = 'flex gap-3';
actions.append(cancelBtn, confirmBtn);
panel.append(heading, body, actions);
overlay.append(panel);
const close = (result: boolean) => {
overlay.remove();
document.removeEventListener('keydown', onKeydown);
previousActive?.focus();
resolve(result);
};
const onKeydown = (e: KeyboardEvent) => {
if (e.key === 'Escape') close(false);
};
cancelBtn.addEventListener('click', () => close(false));
confirmBtn.addEventListener('click', () => close(true));
overlay.addEventListener('click', (e) => {
if (e.target === overlay) close(false);
});
document.addEventListener('keydown', onKeydown);
document.body.append(overlay);
cancelBtn.focus();
});
}
function collectAllFreeTexts(
annotationPlugin: AnnotationPluginLite | null
): FreeTextSystemFontAnnotation[] {
@@ -177,7 +328,10 @@ async function handleFiles(files: FileList) {
pdfWrapper.classList.remove('hidden');
const { default: EmbedPDF } = await import('bentopdf-viewer');
const disabledCategories = getEditorDisabledCategories();
const disabledCategories = [
...getEditorDisabledCategories(),
'document-export',
];
viewerInstance = EmbedPDF.init({
disabledCategories,
type: 'container',
@@ -198,9 +352,24 @@ async function handleFiles(files: FileList) {
docManagerPlugin = registry
.getPlugin('document-manager')
.provides() as unknown as DocManagerPlugin;
const activeDocuments =
docManagerPlugin as unknown as ActiveDocumentSource;
let redactionPlugin: RedactionPluginLite | null = null;
try {
redactionPlugin = registry
.getPlugin('redaction')
.provides() as unknown as RedactionPluginLite;
} catch {
redactionPlugin = null;
}
const redactionTracker = redactionPlugin
? trackAppliedRedactions(redactionPlugin)
: null;
docManagerPlugin.onDocumentClosed((data: { id?: string }) => {
const docId = data?.id || '';
redactionTracker?.forget(docId);
removeFileEntry(docId);
});
@@ -258,6 +427,32 @@ async function handleFiles(files: FileList) {
downloadBtn.onclick = async () => {
try {
const documentId = activeDocuments.getActiveDocumentId();
const pendingCount = redactionPlugin?.getState().pendingCount ?? 0;
if (redactionPlugin && pendingCount > 0) {
const marks =
pendingCount === 1
? "1 redaction mark that hasn't"
: `${pendingCount} redaction marks that haven't`;
const shouldApply = await confirmChoice(
'Apply redactions?',
`This document has ${marks} been applied. Until a mark is applied, the content under it is still in the file. Apply all marks and download?`,
'Apply and download'
);
if (!shouldApply) return;
try {
await redactionPlugin.commitAllPending().toPromise();
} catch (err) {
console.error('Applying redactions failed:', err);
}
if (redactionPlugin.getState().pendingCount > 0) {
showAlert(
'Redaction Failed',
'Some redaction marks could not be applied, so the file was not downloaded. Apply or remove them in the editor and try again.'
);
return;
}
}
const exportPlugin = registry.getPlugin('export').provides();
const arrayBuffer = await exportPlugin.saveAsCopy().toPromise();
let outBytes = new Uint8Array(arrayBuffer);
@@ -301,6 +496,42 @@ async function handleFiles(files: FileList) {
console.error('Font embed pass failed:', err);
}
}
const appliedTexts =
documentId && redactionTracker
? redactionTracker.appliedTexts(documentId)
: [];
if (appliedTexts.length > 0) {
const { PDFDocument } = await import('pdf-lib');
const exported = await PDFDocument.load(outBytes, {
ignoreEncryption: true,
updateMetadata: false,
});
if (exported.isEncrypted) {
const shouldDownload = await confirmChoice(
'Encrypted file',
"Your redactions are applied to the pages, but this file is encrypted, so BentoPDF can't also remove copies of the redacted text from its properties, bookmarks, and attachments. For a full cleanup, redact an unencrypted copy and add a password afterwards with Protect PDF. Download anyway?",
'Download anyway'
);
if (!shouldDownload) return;
} else {
showLoader('Removing redacted text from document data...');
try {
outBytes = await scrubRedactedText(
outBytes,
redactionScrubTerms(appliedTexts)
);
} catch (err) {
console.error('Redaction cleanup failed:', err);
showAlert(
'Error',
'Could not remove the redacted text from the document data, so the file was not downloaded.'
);
return;
} finally {
hideLoader();
}
}
}
const blob = new Blob([new Uint8Array(outBytes)], {
type: 'application/pdf',
});
+4 -7
View File
@@ -1,8 +1,10 @@
import { showAlert } from '../ui.js';
import { downloadFile, formatBytes } from '../utils/helpers.js';
import { batchDecryptIfNeeded } from '../utils/password-prompt.js';
import { PDFDocument } from 'pdf-lib';
import { flattenAnnotations } from '../utils/flatten-annotations.js';
import {
flattenAnnotations,
flattenFormsInDoc,
} from '../utils/flatten-annotations.js';
import { icons, createIcons } from 'lucide';
import JSZip from 'jszip';
import { deduplicateFileName } from '../utils/deduplicate-filename.js';
@@ -16,11 +18,6 @@ const pageState: FlattenPdfState = {
files: [],
};
function flattenFormsInDoc(pdfDoc: PDFDocument) {
const form = pdfDoc.getForm();
form.flatten();
}
function resetState() {
pageState.files = [];
+24 -187
View File
@@ -43,7 +43,7 @@ import {
templateNameExists,
deleteTemplate,
} from '@/js/workflow/serialization';
import { getAvailableTesseractLanguageEntries } from '@/js/utils/tesseract-language-availability.js';
import { getControlSpec } from '@/js/workflow/nodes/control-specs';
import { isToolDisabled } from '@/js/utils/disabled-tools.js';
import { IMAGE_ACCEPT } from '@/js/utils/image-input-utils.js';
@@ -1124,181 +1124,8 @@ function showNodeSettings(node: BaseWorkflowNode) {
return;
}
const dropdownOptions: Record<string, { label: string; value: string }[]> = {
format: [
{ label: 'JPG', value: 'jpg' },
{ label: 'PNG', value: 'png' },
{ label: 'WebP', value: 'webp' },
{ label: 'SVG', value: 'svg' },
],
position: [
{ label: 'Bottom Center', value: 'bottom-center' },
{ label: 'Bottom Left', value: 'bottom-left' },
{ label: 'Bottom Right', value: 'bottom-right' },
{ label: 'Top Center', value: 'top-center' },
{ label: 'Top Left', value: 'top-left' },
{ label: 'Top Right', value: 'top-right' },
],
orientation: [
{ label: 'Auto (Keep Original)', value: 'auto' },
{ label: 'Portrait', value: 'portrait' },
{ label: 'Landscape', value: 'landscape' },
],
direction: [
{ label: 'Vertical', value: 'vertical' },
{ label: 'Horizontal', value: 'horizontal' },
],
pagesPerSheet: [
{ label: '2', value: '2' },
{ label: '4', value: '4' },
{ label: '9', value: '9' },
{ label: '16', value: '16' },
],
fontFamily: [
{ label: 'Helvetica', value: 'helv' },
{ label: 'Times Roman', value: 'times' },
{ label: 'Courier', value: 'cour' },
{ label: 'Times Italic', value: 'tiro' },
],
pageSize: [
{ label: 'A4', value: 'a4' },
{ label: 'Letter', value: 'letter' },
{ label: 'Legal', value: 'legal' },
],
targetSize: [
{ label: 'A4', value: 'A4' },
{ label: 'Letter', value: 'Letter' },
{ label: 'Legal', value: 'Legal' },
{ label: 'A3', value: 'A3' },
{ label: 'A5', value: 'A5' },
{ label: 'Tabloid', value: 'Tabloid' },
{ label: 'Custom', value: 'Custom' },
],
scalingMode: [
{ label: 'Fit (keep full page visible)', value: 'fit' },
{ label: 'Fill (cover full target page)', value: 'fill' },
],
customUnits: [
{ label: 'Millimeters (mm)', value: 'mm' },
{ label: 'Inches (in)', value: 'in' },
],
numberFormat: [
{ label: 'Simple (1, 2, 3)', value: 'simple' },
{ label: 'Page X of Y', value: 'page_x_of_y' },
],
angle: [
{ label: '90° Clockwise', value: '90' },
{ label: '180°', value: '180' },
{ label: '90° Counter-clockwise', value: '270' },
],
blankPosition: [
{ label: 'End', value: 'end' },
{ label: 'Beginning', value: 'start' },
{ label: 'After Page...', value: 'after' },
],
resolution: [
{ label: 'Standard (192 DPI)', value: '2.0' },
{ label: 'High (288 DPI)', value: '3.0' },
{ label: 'Ultra (384 DPI)', value: '4.0' },
],
language: getAvailableTesseractLanguageEntries().map(([code, name]) => ({
label: name,
value: code,
})),
gridMode: [
{ label: '1x2 (Booklet)', value: '1x2' },
{ label: '2x2 (4-up)', value: '2x2' },
{ label: '2x4 (8-up)', value: '2x4' },
{ label: '4x4 (16-up)', value: '4x4' },
],
paperSize: [
{ label: 'Letter', value: 'Letter' },
{ label: 'A4', value: 'A4' },
{ label: 'A3', value: 'A3' },
{ label: 'Tabloid', value: 'Tabloid' },
{ label: 'Legal', value: 'Legal' },
],
rasterizeDpi: [
{ label: '72 (Screen)', value: '72' },
{ label: '150 (Default)', value: '150' },
{ label: '200 (Good)', value: '200' },
{ label: '300 (Print)', value: '300' },
{ label: '600 (High Quality)', value: '600' },
],
imageFormat: [
{ label: 'PNG (Lossless)', value: 'png' },
{ label: 'JPEG (Smaller file size)', value: 'jpeg' },
],
skewThreshold: [
{ label: '0.1° (Very Sensitive)', value: '0.1' },
{ label: '0.5° (Default)', value: '0.5' },
{ label: '1.0° (Normal)', value: '1.0' },
{ label: '2.0° (Less Sensitive)', value: '2.0' },
],
processingDpi: [
{ label: '100 (Fast)', value: '100' },
{ label: '150 (Default)', value: '150' },
{ label: '200 (Better)', value: '200' },
{ label: '300 (Best Quality)', value: '300' },
],
level: [
{ label: 'PDF/A-1b (Strict, no transparency)', value: 'PDF/A-1b' },
{ label: 'PDF/A-2b (Recommended)', value: 'PDF/A-2b' },
{ label: 'PDF/A-3b (Modern, allows attachments)', value: 'PDF/A-3b' },
],
algorithm: [
{ label: 'Condense (Smart, requires PyMuPDF)', value: 'condense' },
{ label: 'Photon (Rasterize pages)', value: 'photon' },
],
compressionLevel: [
{ label: 'Light', value: 'light' },
{ label: 'Balanced', value: 'balanced' },
{ label: 'Aggressive', value: 'aggressive' },
{ label: 'Extreme', value: 'extreme' },
],
redactMode: [
{ label: 'Search Text', value: 'text' },
{ label: 'Area (Coordinates)', value: 'area' },
],
};
const booleanControls = new Set([
'grayscale',
'border',
'margins',
'separator',
'sepia',
'includeCcBcc',
'includeAttachments',
'binarize',
'preFlatten',
'flattenForms',
'removeMetadata',
'removeAnnotations',
'removeJavascript',
'removeEmbeddedFiles',
'removeLayers',
'removeLinks',
'removeStructureTree',
'removeMarkInfo',
'removeFonts',
'subsetFonts',
'convertToGrayscale',
'removeThumbnails',
'retainPageLabels',
]);
const multiSelectDropdowns = new Set(['language']);
const advancedControls = new Set(['resolution', 'binarize', 'whitelist']);
const colorControls = new Set([
'color',
'borderColor',
'backgroundColor',
'separatorColor',
'fontColor',
'fillColor',
]);
const controlHints: Record<string, string> = {
pages: 'e.g. 1-3, 5, 7-9',
whitelist: 'Limit recognized characters (leave empty for all)',
@@ -1333,15 +1160,17 @@ function showNodeSettings(node: BaseWorkflowNode) {
controlWrappers[key] = wrapper;
const ctrl = control as { value?: unknown; type?: string };
const currentValue = String(ctrl.value ?? '');
const spec = getControlSpec(node.nodeType, key);
const controlLabel = document.createElement('label');
controlLabel.className = 'block text-xs text-gray-400 mb-1';
controlLabel.textContent = formatLabel(key);
wrapper.appendChild(controlLabel);
if (dropdownOptions[key] && multiSelectDropdowns.has(key)) {
if (spec?.kind === 'multiselect') {
const { options, separator } = spec;
const selectedValues = new Set(
currentValue ? currentValue.split('+') : []
currentValue ? currentValue.split(separator) : []
);
const container = document.createElement('div');
container.className = 'flex flex-col gap-1';
@@ -1364,7 +1193,7 @@ function showNodeSettings(node: BaseWorkflowNode) {
function updateTags() {
tagsDiv.innerHTML = '';
for (const val of selectedValues) {
const opt = dropdownOptions[key].find((o) => o.value === val);
const opt = options.find((o) => o.value === val);
if (!opt) continue;
const tag = document.createElement('span');
tag.className =
@@ -1388,13 +1217,13 @@ function showNodeSettings(node: BaseWorkflowNode) {
function updateCtrl() {
(ctrl as { value: string }).value =
Array.from(selectedValues).join('+');
Array.from(selectedValues).join(separator);
}
function renderList(filter: string) {
listDiv.innerHTML = '';
const lowerFilter = filter.toLowerCase();
for (const opt of dropdownOptions[key]) {
for (const opt of options) {
if (lowerFilter && !opt.label.toLowerCase().includes(lowerFilter))
continue;
const label = document.createElement('label');
@@ -1427,10 +1256,10 @@ function showNodeSettings(node: BaseWorkflowNode) {
updateTags();
renderList('');
wrapper.appendChild(container);
} else if (dropdownOptions[key]) {
} else if (spec?.kind === 'select') {
const select = document.createElement('select');
select.className = inputClass;
for (const opt of dropdownOptions[key]) {
for (const opt of spec.options) {
const option = document.createElement('option');
option.value = opt.value;
option.textContent = opt.label;
@@ -1438,13 +1267,14 @@ function showNodeSettings(node: BaseWorkflowNode) {
select.appendChild(option);
}
select.addEventListener('change', () => {
(ctrl as { value: string }).value = select.value;
ctrl.value =
typeof ctrl.value === 'number' ? Number(select.value) : select.value;
if (conditionalVisibility[key]) {
applyConditionalVisibility(key, select.value);
}
});
wrapper.appendChild(select);
} else if (booleanControls.has(key)) {
} else if (spec?.kind === 'boolean') {
const toggle = document.createElement('button');
toggle.type = 'button';
const isOn = currentValue === 'true';
@@ -1461,7 +1291,7 @@ function showNodeSettings(node: BaseWorkflowNode) {
dot.className = `pointer-events-none absolute top-[3px] left-[3px] h-[18px] w-[18px] rounded-full bg-white shadow-md transition-transform duration-200 ${on ? 'translate-x-5' : 'translate-x-0'}`;
});
wrapper.appendChild(toggle);
} else if (colorControls.has(key)) {
} else if (spec?.kind === 'color') {
const colorRow = document.createElement('div');
colorRow.className = 'flex items-center gap-2';
const colorInput = document.createElement('input');
@@ -1489,18 +1319,25 @@ function showNodeSettings(node: BaseWorkflowNode) {
const input = document.createElement('input');
input.type = 'number';
input.className = inputClass;
if (spec?.kind === 'number') {
input.min = String(spec.min);
input.max = String(spec.max);
input.step = spec.step === undefined ? 'any' : String(spec.step);
}
input.value = currentValue;
input.addEventListener('input', () => {
const num = parseFloat(input.value);
if (!isNaN(num)) {
(ctrl as { value: number }).value = num;
ctrl.value = node.sanitizeControlValue(key, num);
}
});
input.addEventListener('change', () => {
input.value = String(ctrl.value ?? '');
});
wrapper.appendChild(input);
} else {
const input = document.createElement('input');
const isPasswordField = key === 'password' || key === 'ownerPassword';
input.type = isPasswordField ? 'password' : 'text';
input.type = spec?.kind === 'password' ? 'password' : 'text';
input.className = inputClass;
input.value = currentValue;
input.addEventListener('input', () => {
+19 -8
View File
@@ -149,14 +149,25 @@ async function runSanitize() {
new Blob([new Uint8Array(result.bytes)], { type: 'application/pdf' }),
pageState.file?.name || 'document.pdf'
);
showAlert(
'Success',
'PDF has been sanitized and downloaded.',
'success',
() => {
resetState();
}
);
if (result.skipped.length > 0) {
showAlert(
'Sanitized With Warnings',
`The PDF was downloaded, but these steps could not be completed: ${result.skipped.join(', ')}. The content they cover may still be in the file.`,
'warning',
() => {
resetState();
}
);
} else {
showAlert(
'Success',
'PDF has been sanitized and downloaded.',
'success',
() => {
resetState();
}
);
}
} catch (e: unknown) {
console.error('Sanitization Error:', e);
const msg = e instanceof Error ? e.message : String(e);
+12 -4
View File
@@ -7,8 +7,14 @@ import {
} from '../utils/helpers.js';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import { t } from '../i18n/i18n';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
import { flattenAnnotations } from '../utils/flatten-annotations.js';
import {
loadPdfDocument,
removeUnreferencedObjects,
} from '../utils/load-pdf-document.js';
import {
flattenAnnotations,
flattenFormsInDoc,
} from '../utils/flatten-annotations.js';
import type { SignState, PDFViewerWindow } from '@/types';
const signState: SignState = {
@@ -285,7 +291,7 @@ async function applyAndSaveSignatures() {
const pdfBytes = new Uint8Array(rawPdfBytes);
const pdfDoc = await loadPdfDocument(pdfBytes);
try {
pdfDoc.getForm().flatten();
flattenFormsInDoc(pdfDoc);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
if (!msg.includes('getForm')) {
@@ -298,7 +304,9 @@ async function applyAndSaveSignatures() {
const msg = e instanceof Error ? e.message : String(e);
console.warn('Could not flatten annotations:', msg);
}
const flattenedPdfBytes = await pdfDoc.save();
const flattenedPdfBytes = await removeUnreferencedObjects(
await pdfDoc.save()
);
const blob = new Blob([new Uint8Array(flattenedPdfBytes)], {
type: 'application/pdf',
+3 -3
View File
@@ -377,9 +377,9 @@ function createSignatureCard(
result.cryptoVerified === true &&
result.coverageStatus === 'partial'
) {
statusColor = 'text-red-400';
statusIcon = 'x-circle';
statusText = 'Invalid — Modified After Signing (partial coverage)';
statusColor = 'text-yellow-400';
statusIcon = 'alert-triangle';
statusText = 'Modified After Signing (signed part intact)';
} else {
statusColor = 'text-red-400';
statusIcon = 'x-circle';
+284 -79
View File
@@ -1,82 +1,296 @@
import forge from 'node-forge';
import { ExtractedSignature, SignatureValidationResult } from '@/types';
import { SignatureValidationResult } from '@/types';
import type { ExtractedSignatureWithError, PdfValueSpan } 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 PDF_WHITESPACE = new Set('\0\t\n\f\r ');
const PDF_TOKEN_TERMINATORS = new Set('\0\t\n\f\r ()<>[]{}/%');
const PDF_STRING_ESCAPES: Record<string, string> = {
n: '\n',
r: '\r',
t: '\t',
b: '\b',
f: '\f',
'\r\n': '',
'\r': '',
'\n': '',
};
export function extractSignatures(
pdfBytes: Uint8Array
): ExtractedSignatureWithError[] {
const signatures: ExtractedSignatureWithError[] = [];
const pdfString = new TextDecoder('latin1').decode(pdfBytes);
// Find all signature objects for /Type /Sig
const sigRegex = /\/Type\s*\/Sig\b/g;
let sigMatch;
let sigIndex = 0;
const objectHeader = /\d+\s+\d+\s+obj\b/g;
let endobj = -1;
while ((sigMatch = sigRegex.exec(pdfString)) !== null) {
try {
const searchStart = Math.max(0, sigMatch.index - 5000);
const searchEnd = Math.min(pdfString.length, sigMatch.index + 10000);
const context = pdfString.substring(searchStart, searchEnd);
const byteRangeMatch = context.match(
/\/ByteRange\s*\[\s*(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s*\]/
);
if (!byteRangeMatch) continue;
while (objectHeader.exec(pdfString) !== null) {
const start = objectHeader.lastIndex;
if (endobj < start) {
endobj = pdfString.indexOf('endobj', start);
if (endobj < 0) endobj = pdfString.length;
}
const body = pdfString.slice(0, endobj);
const dictionaries: Map<string, PdfValueSpan>[] = [];
const end = parsePdfValue(
body,
skipPdfWhitespace(body, start),
dictionaries,
0
);
objectHeader.lastIndex = end < 0 ? endobj : end;
if (end < 0) continue;
const byteRange = [
parseInt(byteRangeMatch[1], 10),
parseInt(byteRangeMatch[2], 10),
parseInt(byteRangeMatch[3], 10),
parseInt(byteRangeMatch[4], 10),
];
const contentsMatch = context.match(/\/Contents\s*<([0-9A-Fa-f]+)>/);
if (!contentsMatch) continue;
const hexContents = contentsMatch[1];
const contentsBytes = hexToBytes(hexContents);
const reasonMatch = context.match(/\/Reason\s*\(([^)]*)\)/);
const locationMatch = context.match(/\/Location\s*\(([^)]*)\)/);
const contactMatch = context.match(/\/ContactInfo\s*\(([^)]*)\)/);
const nameMatch = context.match(/\/Name\s*\(([^)]*)\)/);
const timeMatch = context.match(
/\/M\s*\(D:(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/
);
let signingTime: string | undefined;
if (timeMatch) {
signingTime = `${timeMatch[1]}-${timeMatch[2]}-${timeMatch[3]}T${timeMatch[4]}:${timeMatch[5]}:${timeMatch[6]}`;
for (const entries of dictionaries) {
const type = entries.get('Type');
if (type && pdfString.slice(type.start, type.end) === '/Sig') {
signatures.push(
readSignatureDictionary(
pdfString,
pdfBytes,
entries,
signatures.length
)
);
}
signatures.push({
index: sigIndex++,
contents: contentsBytes,
byteRange,
reason: reasonMatch
? decodeURIComponent(escape(reasonMatch[1]))
: undefined,
location: locationMatch
? decodeURIComponent(escape(locationMatch[1]))
: undefined,
contactInfo: contactMatch
? decodeURIComponent(escape(contactMatch[1]))
: undefined,
name: nameMatch ? decodeURIComponent(escape(nameMatch[1])) : undefined,
signingTime,
});
} catch (e) {
console.warn('Error extracting signature at index', sigIndex, e);
}
}
return signatures;
}
function readSignatureDictionary(
pdfString: string,
pdfBytes: Uint8Array,
entries: Map<string, PdfValueSpan>,
index: number
): ExtractedSignatureWithError {
const text = (key: string) => decodePdfText(pdfBytes, entries.get(key));
const timeMatch = text('M')?.match(
/^D:(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/
);
const signature: ExtractedSignatureWithError = {
index,
contents: new Uint8Array(0),
byteRange: [],
reason: text('Reason'),
location: text('Location'),
contactInfo: text('ContactInfo'),
name: text('Name'),
signingTime: timeMatch
? `${timeMatch[1]}-${timeMatch[2]}-${timeMatch[3]}T${timeMatch[4]}:${timeMatch[5]}:${timeMatch[6]}`
: undefined,
};
const rangeSpan = entries.get('ByteRange');
const rangeMatch = rangeSpan
? /^\[\s*(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s*\]$/.exec(
pdfString.slice(rangeSpan.start, rangeSpan.end)
)
: null;
if (!rangeMatch) {
signature.extractionError =
'Signature has a missing or malformed /ByteRange';
return signature;
}
signature.byteRange = rangeMatch.slice(1).map(Number);
const byteRangeError = getByteRangeError(
signature.byteRange,
pdfBytes.length
);
if (byteRangeError) {
signature.extractionError = byteRangeError;
return signature;
}
const gapStart = signature.byteRange[0] + signature.byteRange[1];
const gapEnd = signature.byteRange[2];
const gap = pdfString.slice(gapStart, gapEnd);
if (gap.length % 2 !== 0 || !/^<[0-9A-Fa-f]+>$/.test(gap)) {
signature.extractionError =
'The bytes excluded by /ByteRange are not exactly a hex-encoded signature';
return signature;
}
const contentsSpan = entries.get('Contents');
if (contentsSpan?.start !== gapStart || contentsSpan.end !== gapEnd) {
signature.extractionError =
'The bytes excluded by /ByteRange are not the /Contents of this signature';
return signature;
}
const contents = hexToBytes(gap.slice(1, -1));
const derObjectLength = getDerObjectLength(contents);
if (
derObjectLength === null ||
derObjectLength > contents.length ||
contents.subarray(derObjectLength).some((byte) => byte !== 0)
) {
signature.extractionError =
'Signature /Contents is not a single DER object followed by zero padding';
return signature;
}
signature.contents = contents.slice(0, derObjectLength);
return signature;
}
function getByteRangeError(
byteRange: number[],
fileLength: number
): string | null {
if (
byteRange.length !== 4 ||
!byteRange.every((value) => Number.isSafeInteger(value) && value >= 0)
) {
return 'Malformed ByteRange';
}
const [start1, len1, start2, len2] = byteRange;
if (start1 !== 0) {
return 'ByteRange does not start at the beginning of the file';
}
if (start1 + len1 > start2 || start2 + len2 > fileLength) {
return 'ByteRange out of bounds';
}
return null;
}
function skipPdfWhitespace(text: string, pos: number): number {
while (pos < text.length) {
if (text[pos] === '%') {
while (pos < text.length && text[pos] !== '\n' && text[pos] !== '\r') {
pos++;
}
} else if (PDF_WHITESPACE.has(text[pos])) {
pos++;
} else {
break;
}
}
return pos;
}
function findTokenEnd(text: string, pos: number): number {
while (pos < text.length && !PDF_TOKEN_TERMINATORS.has(text[pos])) pos++;
return pos;
}
function findLiteralStringEnd(text: string, start: number): number {
let depth = 0;
for (let pos = start; pos < text.length; pos++) {
if (text[pos] === '\\') {
pos++;
} else if (text[pos] === '(') {
depth++;
} else if (text[pos] === ')') {
depth--;
if (depth === 0) return pos + 1;
}
}
return -1;
}
function parsePdfValue(
text: string,
start: number,
dictionaries: Map<string, PdfValueSpan>[],
depth: number
): number {
if (depth > 32 || start >= text.length) return -1;
if (text.startsWith('<<', start)) {
const entries = new Map<string, PdfValueSpan>();
dictionaries.push(entries);
let pos = skipPdfWhitespace(text, start + 2);
while (!text.startsWith('>>', pos)) {
const keyEnd = text[pos] === '/' ? findTokenEnd(text, pos + 1) : pos;
const valueStart = skipPdfWhitespace(text, keyEnd);
const valueEnd = parsePdfValue(text, valueStart, dictionaries, depth + 1);
if (valueEnd < 0) return -1;
if (keyEnd > pos) {
entries.set(text.slice(pos + 1, keyEnd), {
start: valueStart,
end: valueEnd,
});
}
pos = skipPdfWhitespace(text, valueEnd);
}
return pos + 2;
}
if (text[start] === '<') {
const end = text.indexOf('>', start);
return end < 0 ? -1 : end + 1;
}
if (text[start] === '(') return findLiteralStringEnd(text, start);
if (text[start] === '[') {
let pos = skipPdfWhitespace(text, start + 1);
while (text[pos] !== ']') {
const end = parsePdfValue(text, pos, dictionaries, depth + 1);
if (end < 0) return -1;
pos = skipPdfWhitespace(text, end);
}
return pos + 1;
}
const end = findTokenEnd(text, text[start] === '/' ? start + 1 : start);
return end > start ? end : -1;
}
function decodePdfText(
pdfBytes: Uint8Array,
span: PdfValueSpan | undefined
): string | undefined {
if (!span) return undefined;
const raw = uint8ToLatin1(pdfBytes.subarray(span.start, span.end));
let text: string;
if (raw.startsWith('(')) {
text = raw
.slice(1, -1)
.replace(/\\([0-7]{1,3}|\r\n|[\s\S])/g, (_, escaped: string) =>
/^[0-7]/.test(escaped)
? String.fromCharCode(parseInt(escaped, 8) & 0xff)
: (PDF_STRING_ESCAPES[escaped] ?? escaped)
);
} else if (/^<[0-9A-Fa-f\s]*>$/.test(raw)) {
const hex = raw.slice(1, -1).replace(/\s/g, '');
text = uint8ToLatin1(hexToBytes(hex.length % 2 ? hex + '0' : hex));
} else {
return undefined;
}
const bytes = latin1ToUint8(text);
if (text.startsWith('\xfe\xff')) {
return new TextDecoder('utf-16be').decode(bytes.subarray(2));
}
try {
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
} catch {
return text;
}
}
function getDerObjectLength(bytes: Uint8Array): number | null {
if (bytes.length < 2) return null;
if (bytes[1] < 0x80) return 2 + bytes[1];
const count = bytes[1] & 0x7f;
if (count === 0 || count > 4 || bytes.length < 2 + count) return null;
let length = 0;
for (let i = 0; i < count; i++) length = length * 256 + bytes[2 + i];
return 2 + count + length;
}
export async function validateSignature(
signature: ExtractedSignature,
signature: ExtractedSignatureWithError,
pdfBytes: Uint8Array,
trustedCert?: forge.pki.Certificate
): Promise<SignatureValidationResult> {
@@ -99,6 +313,13 @@ export async function validateSignature(
contactInfo: signature.contactInfo,
};
if (signature.extractionError) {
result.errorMessage = signature.extractionError;
result.cryptoVerified = false;
result.cryptoVerificationStatus = 'failed';
return result;
}
try {
const binaryString = String.fromCharCode.apply(
null,
@@ -341,13 +562,7 @@ function hexToBytes(hex: string): Uint8Array {
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
}
let actualLength = bytes.length;
while (actualLength > 0 && bytes[actualLength - 1] === 0) {
actualLength--;
}
return bytes.slice(0, actualLength);
return bytes;
}
function getDigestAlgorithmName(oid: string): string {
@@ -745,8 +960,9 @@ async function performCryptoVerification(
if (!fields) {
return { status: 'failed', reason: 'Could not parse signer info' };
}
if (byteRange.length !== 4) {
return { status: 'failed', reason: 'Malformed ByteRange' };
const byteRangeError = getByteRangeError(byteRange, pdfBytes.length);
if (byteRangeError) {
return { status: 'failed', reason: byteRangeError };
}
const md = createMd(fields.digestOid);
@@ -758,17 +974,6 @@ async function performCryptoVerification(
}
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);
+5 -4
View File
@@ -29,6 +29,7 @@ import {
setStoredItem,
removeStoredItem,
} from './utils/safe-storage.js';
import { sanitizeTranslationHtml } from './utils/translation-html.js';
declare const __BRAND_NAME__: string;
const init = async () => {
@@ -48,9 +49,9 @@ const init = async () => {
main.innerHTML = `
<div class="flex flex-col items-center justify-center min-h-[60vh] text-center px-4">
<i class="ph ph-prohibit text-6xl text-gray-500 mb-4"></i>
<h1 class="text-2xl font-bold text-white mb-2">${heading}</h1>
<p class="text-gray-400 mb-6">${message}</p>
<a href="${import.meta.env.BASE_URL}" class="px-6 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition">${backHome}</a>
<h1 class="text-2xl font-bold text-white mb-2">${sanitizeTranslationHtml(heading)}</h1>
<p class="text-gray-400 mb-6">${sanitizeTranslationHtml(message)}</p>
<a href="${import.meta.env.BASE_URL}" class="px-6 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition">${sanitizeTranslationHtml(backHome)}</a>
</div>
`;
return;
@@ -898,7 +899,7 @@ const init = async () => {
}
dom.warningTitle.textContent = title;
dom.warningMessage.innerHTML = message;
dom.warningMessage.innerHTML = sanitizeTranslationHtml(message);
dom.warningModal.classList.remove('hidden');
dom.warningModal.classList.add('flex');
+36
View File
@@ -0,0 +1,36 @@
export interface PendingRedactionItem {
id: string;
kind: 'text' | 'area';
text?: string;
}
export interface PendingRedactionChange {
documentId: string;
pending: Record<number, PendingRedactionItem[]>;
}
export interface RedactionPluginEvent {
type: string;
documentId: string;
id?: string;
}
export interface RedactionPluginLite {
getState: () => { pendingCount: number };
commitAllPending: () => { toPromise: () => Promise<boolean> };
onPendingChange: (
listener: (event: PendingRedactionChange) => void
) => () => void;
onRedactionEvent: (
listener: (event: RedactionPluginEvent) => void
) => () => void;
}
export interface ActiveDocumentSource {
getActiveDocumentId: () => string | null;
}
export interface AppliedRedactionTracker {
appliedTexts: (documentId: string) => (string | undefined)[];
forget: (documentId: string) => void;
}
+4
View File
@@ -66,3 +66,7 @@ export * from './ui-type.ts';
export * from './markdown-editor-type.ts';
export * from './sanitize-type.ts';
export * from './overlay-pdf-type.ts';
export * from './workflow-control-spec-type.ts';
export * from './edit-pdf-redaction-type.ts';
export * from './validate-signature-extraction-type.ts';
export * from './pdf-content-tokens-type.ts';
+13
View File
@@ -0,0 +1,13 @@
export interface ContentToken {
kind: 'name' | 'dict' | 'operand' | 'operator';
value: string;
start: number;
end: number;
}
export interface ContentOperation {
operator: string;
operands: ContentToken[];
start: number;
end: number;
}
@@ -0,0 +1,10 @@
import { ExtractedSignature } from './validate-signature-type.ts';
export interface PdfValueSpan {
start: number;
end: number;
}
export interface ExtractedSignatureWithError extends ExtractedSignature {
extractionError?: string;
}
@@ -0,0 +1,12 @@
export interface WorkflowControlOption {
label: string;
value: string;
}
export type WorkflowControlSpec =
| { kind: 'select'; options: WorkflowControlOption[] }
| { kind: 'multiselect'; options: WorkflowControlOption[]; separator: string }
| { kind: 'number'; min: number; max: number; step?: number }
| { kind: 'boolean' }
| { kind: 'color' }
| { kind: 'password' };
+58 -3
View File
@@ -7,8 +7,65 @@ import {
PDFNumber,
PDFOperator,
PDFOperatorNames,
PDFObject,
PDFTextField,
} from 'pdf-lib';
const HIDDEN_ANNOTATION_FLAGS = 0x02 | 0x20;
function isHiddenAnnotation(annot: PDFDict): boolean {
const flags = annot.lookup(PDFName.of('F'));
return (
flags instanceof PDFNumber &&
(flags.asNumber() & HIDDEN_ANNOTATION_FLAGS) !== 0
);
}
function detachAnnotations(pdfDoc: PDFDocument, refs: Set<PDFObject>) {
for (const page of pdfDoc.getPages()) {
const annots = page.node.Annots();
if (!annots) continue;
for (let i = annots.size() - 1; i >= 0; i--) {
if (refs.has(annots.get(i))) annots.remove(i);
}
}
for (const ref of refs) {
if (ref instanceof PDFRef) pdfDoc.context.delete(ref);
}
}
function removeHiddenFormFields(pdfDoc: PDFDocument) {
const form = pdfDoc.getForm();
const removedRefs = new Set<PDFObject>();
for (const field of form.getFields()) {
const removeAll = field instanceof PDFTextField && field.isPassword();
const kids = field.acroField.Kids();
if (kids) {
for (let i = kids.size() - 1; i >= 0; i--) {
const widget = kids.lookup(i);
if (
removeAll ||
(widget instanceof PDFDict && isHiddenAnnotation(widget))
) {
removedRefs.add(kids.get(i));
kids.remove(i);
}
}
if (kids.size() > 0) continue;
} else if (!removeAll && !isHiddenAnnotation(field.acroField.dict)) {
continue;
}
form.acroForm.removeField(field.acroField);
removedRefs.add(field.ref);
}
if (removedRefs.size > 0) detachAnnotations(pdfDoc, removedRefs);
}
export function flattenFormsInDoc(pdfDoc: PDFDocument): void {
removeHiddenFormFields(pdfDoc);
pdfDoc.getForm().flatten();
}
function extractNumbers(arr: PDFArray, count: number): number[] | null {
if (arr.size() < count) return null;
const result: number[] = [];
@@ -68,9 +125,7 @@ export function flattenAnnotations(pdfDoc: PDFDocument): void {
continue;
}
const flagsObj = annot.get(PDFName.of('F'));
const flags = flagsObj instanceof PDFNumber ? flagsObj.asNumber() : 0;
if (flags & 0x02) {
if (isHiddenAnnotation(annot)) {
hasChanges = true;
continue;
}
+2 -2
View File
@@ -163,7 +163,7 @@ export async function convertToPdfA(
}
const args = [
'-dNOSAFER',
'-dSAFER',
'-dBATCH',
'-dNOPAUSE',
'-sDEVICE=pdfwrite',
@@ -418,7 +418,7 @@ export async function convertFontsToOutlines(
onProgress?.('Converting fonts to outlines...');
const args = [
'-dNOSAFER',
'-dSAFER',
'-dBATCH',
'-dNOPAUSE',
'-sDEVICE=pdfwrite',
+299
View File
@@ -0,0 +1,299 @@
import type { ContentOperation, ContentToken } from '@/types';
const charCode = (char: string) => char.charCodeAt(0);
const BACKSLASH = charCode('\\');
const OPEN_PAREN = charCode('(');
const CLOSE_PAREN = charCode(')');
const LESS_THAN = charCode('<');
const GREATER_THAN = charCode('>');
const OPEN_BRACKET = charCode('[');
const CLOSE_BRACKET = charCode(']');
const SLASH = charCode('/');
const PERCENT = charCode('%');
const LINE_FEED = charCode('\n');
const CARRIAGE_RETURN = charCode('\r');
const LETTER_E = charCode('E');
const LETTER_I = charCode('I');
const WHITESPACE = new Set([0x00, 0x09, 0x0a, 0x0c, 0x0d, 0x20]);
const DELIMITERS = new Set([...'()<>[]{}/%'].map(charCode));
const OPERAND_KEYWORDS = new Set(['true', 'false', 'null']);
const TEXT_CHECK_LENGTH = 16;
const COMPONENTS_PER_COLOR_SPACE = new Map([
['G', 1],
['DeviceGray', 1],
['RGB', 3],
['DeviceRGB', 3],
['CMYK', 4],
['DeviceCMYK', 4],
['I', 1],
['Indexed', 1],
]);
function isRegularChar(byte: number): boolean {
return !WHITESPACE.has(byte) && !DELIMITERS.has(byte);
}
function isHexDigit(byte: number): boolean {
return (
(byte >= charCode('0') && byte <= charCode('9')) ||
(byte >= charCode('A') && byte <= charCode('F')) ||
(byte >= charCode('a') && byte <= charCode('f'))
);
}
function bytesToString(bytes: Uint8Array, start: number, end: number): string {
let text = '';
for (let i = start; i < end; i++) text += String.fromCharCode(bytes[i]);
return text;
}
function skipWhitespaceAndComments(bytes: Uint8Array, pos: number): number {
while (pos < bytes.length) {
if (WHITESPACE.has(bytes[pos])) {
pos++;
} else if (bytes[pos] === PERCENT) {
while (
pos < bytes.length &&
bytes[pos] !== LINE_FEED &&
bytes[pos] !== CARRIAGE_RETURN
) {
pos++;
}
} else {
break;
}
}
return pos;
}
function findLiteralStringEnd(bytes: Uint8Array, start: number): number {
let depth = 0;
for (let i = start; i < bytes.length; i++) {
const byte = bytes[i];
if (byte === BACKSLASH) {
i++;
} else if (byte === OPEN_PAREN) {
depth++;
} else if (byte === CLOSE_PAREN) {
depth--;
if (depth === 0) return i + 1;
}
}
throw new Error('Unterminated string in content stream');
}
function findHexStringEnd(bytes: Uint8Array, start: number): number {
for (let i = start + 1; i < bytes.length; i++) {
const byte = bytes[i];
if (byte === GREATER_THAN) return i + 1;
if (!isHexDigit(byte) && !WHITESPACE.has(byte)) {
throw new Error('Invalid hex string in content stream');
}
}
throw new Error('Unterminated hex string in content stream');
}
function findClosingDelimiter(
bytes: Uint8Array,
start: number,
closing: number[]
): number {
let pos = start;
while (true) {
pos = skipWhitespaceAndComments(bytes, pos);
if (pos >= bytes.length) {
throw new Error('Unterminated array or dictionary in content stream');
}
if (closing.every((byte, offset) => bytes[pos + offset] === byte)) {
return pos + closing.length;
}
pos = readToken(bytes, pos).end;
}
}
function readToken(bytes: Uint8Array, start: number): ContentToken {
const byte = bytes[start];
if (byte === OPEN_PAREN) {
const end = findLiteralStringEnd(bytes, start);
return { kind: 'operand', value: '', start, end };
}
if (byte === LESS_THAN && bytes[start + 1] === LESS_THAN) {
const end = findClosingDelimiter(bytes, start + 2, [
GREATER_THAN,
GREATER_THAN,
]);
return { kind: 'dict', value: '', start, end };
}
if (byte === LESS_THAN) {
const end = findHexStringEnd(bytes, start);
return { kind: 'operand', value: '', start, end };
}
if (byte === OPEN_BRACKET) {
const end = findClosingDelimiter(bytes, start + 1, [CLOSE_BRACKET]);
return { kind: 'operand', value: '', start, end };
}
if (byte === SLASH) {
let end = start + 1;
while (end < bytes.length && isRegularChar(bytes[end])) end++;
const name = bytesToString(bytes, start + 1, end);
return { kind: 'name', value: name, start, end };
}
if (DELIMITERS.has(byte)) {
throw new Error('Unexpected delimiter in content stream');
}
let end = start;
while (end < bytes.length && isRegularChar(bytes[end])) end++;
const word = bytesToString(bytes, start, end);
const isNumber = /^[\d+.-]/.test(word);
const kind = isNumber || OPERAND_KEYWORDS.has(word) ? 'operand' : 'operator';
return { kind, value: word, start, end };
}
function isFollowedByText(bytes: Uint8Array, start: number): boolean {
const end = Math.min(bytes.length, start + TEXT_CHECK_LENGTH);
for (let i = start; i < end; i++) {
const byte = bytes[i];
const isPrintable = byte >= 0x20 && byte <= 0x7e;
if (!isPrintable && !WHITESPACE.has(byte)) return false;
}
return true;
}
function getImageDataLength(
bytes: Uint8Array,
imageDict: Map<string, ContentToken>
): number | undefined {
const lookup = (shortKey: string, longKey: string) =>
imageDict.get(shortKey) ?? imageDict.get(longKey);
const toInteger = (token: ContentToken | undefined) =>
token?.kind === 'operand' && /^\d+$/.test(token.value)
? Number(token.value)
: undefined;
if (lookup('F', 'Filter')) return undefined;
const isImageMask = lookup('IM', 'ImageMask')?.value === 'true';
const colorSpace = lookup('CS', 'ColorSpace');
const isIndexedColor =
colorSpace?.kind === 'operand' &&
/^\[\s*\/(I|Indexed)[\s/]/.test(
bytesToString(bytes, colorSpace.start, colorSpace.end)
);
let componentsPerPixel: number | undefined;
if (isImageMask || isIndexedColor) {
componentsPerPixel = 1;
} else if (colorSpace?.kind === 'name') {
componentsPerPixel = COMPONENTS_PER_COLOR_SPACE.get(colorSpace.value);
}
const width = toInteger(lookup('W', 'Width'));
const height = toInteger(lookup('H', 'Height'));
const bitsPerComponent = isImageMask
? 1
: toInteger(lookup('BPC', 'BitsPerComponent'));
if (
width === undefined ||
height === undefined ||
bitsPerComponent === undefined ||
componentsPerPixel === undefined
) {
return undefined;
}
const bytesPerRow = Math.ceil(
(width * bitsPerComponent * componentsPerPixel) / 8
);
return height * bytesPerRow;
}
function isEndImageOperator(bytes: Uint8Array, pos: number): boolean {
return (
bytes[pos] === LETTER_E &&
bytes[pos + 1] === LETTER_I &&
(pos + 2 >= bytes.length || !isRegularChar(bytes[pos + 2]))
);
}
function skipInlineImage(bytes: Uint8Array, start: number): number {
const imageDict = new Map<string, ContentToken>();
let pendingKey: ContentToken | undefined;
let pos = start;
while (true) {
pos = skipWhitespaceAndComments(bytes, pos);
if (pos >= bytes.length) {
throw new Error('Unterminated inline image in content stream');
}
const token = readToken(bytes, pos);
pos = token.end;
if (token.kind === 'operator' && token.value === 'ID') break;
if (pendingKey) {
imageDict.set(pendingKey.value, token);
pendingKey = undefined;
} else if (token.kind === 'name') {
pendingKey = token;
} else {
throw new Error('Malformed inline image in content stream');
}
}
const dataStart = WHITESPACE.has(bytes[pos]) ? pos + 1 : pos;
const dataLength = getImageDataLength(bytes, imageDict);
if (dataLength !== undefined) {
let end = dataStart + dataLength;
while (end < bytes.length && WHITESPACE.has(bytes[end])) end++;
if (isEndImageOperator(bytes, end)) return end + 2;
throw new Error('Malformed inline image in content stream');
}
for (let i = dataStart; i + 1 < bytes.length; i++) {
if (!WHITESPACE.has(bytes[i - 1]) || !isEndImageOperator(bytes, i)) {
continue;
}
if (!isFollowedByText(bytes, i + 2)) break;
return i + 2;
}
throw new Error('Unterminated inline image in content stream');
}
export function parseContentOperations(bytes: Uint8Array): ContentOperation[] {
const operations: ContentOperation[] = [];
let operands: ContentToken[] = [];
let pos = 0;
while (true) {
pos = skipWhitespaceAndComments(bytes, pos);
if (pos >= bytes.length) break;
const token = readToken(bytes, pos);
pos = token.end;
if (token.kind !== 'operator') {
operands.push(token);
continue;
}
if (token.value === 'BI') pos = skipInlineImage(bytes, pos);
operations.push({
operator: token.value,
operands,
start: operands.length > 0 ? operands[0].start : token.start,
end: pos,
});
operands = [];
}
if (operands.length > 0) {
operations.push({
operator: '',
operands,
start: operands[0].start,
end: operands[operands.length - 1].end,
});
}
return operations;
}
+30 -7
View File
@@ -17,6 +17,15 @@ import {
} from './load-pdf-document.js';
const ANNOTATION_TEXT_KEYS = ['Contents', 'RC', 'T', 'Subj'];
const NON_TEXT_KEYS = new Set(
['CreationDate', 'ModDate', 'M', 'ID'].map((key) => PDFName.of(key))
);
const SIGNATURE_TYPES = new Set(
['Sig', 'DocTimeStamp'].map((type) => PDFName.of(type))
);
const SIGNATURE_BINARY_KEYS = new Set(
['Contents', 'Cert'].map((key) => PDFName.of(key))
);
function decodeText(value: PDFObject | undefined): string | null {
if (value instanceof PDFString || value instanceof PDFHexString) {
@@ -33,11 +42,20 @@ function decodeStream(stream: PDFRawStream): Uint8Array | null {
}
}
function removeTextFromDocument(doc: PDFDocument, term: string): void {
function removeTextFromDocument(doc: PDFDocument, terms: string[]): void {
const context = doc.context;
const needle = term.toLowerCase();
const pattern = new RegExp(term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
const contains = (text: string) => text.toLowerCase().includes(needle);
const needles = terms.map((term) => term.toLowerCase());
const pattern = new RegExp(
[...terms]
.sort((a, b) => b.length - a.length)
.map((term) => term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.join('|'),
'gi'
);
const contains = (text: string) => {
const lowered = text.toLowerCase();
return needles.some((needle) => lowered.includes(needle));
};
const valueContains = (value: PDFObject | undefined): boolean => {
if (value instanceof PDFArray) return value.asArray().some(valueContains);
const text = decodeText(value);
@@ -180,7 +198,11 @@ function removeTextFromDocument(doc: PDFDocument, term: string): void {
const scrubContainer = (container: PDFDict | PDFArray) => {
if (container instanceof PDFDict) {
const type = container.get(PDFName.of('Type'));
const isSignature = type instanceof PDFName && SIGNATURE_TYPES.has(type);
for (const [key, value] of container.entries()) {
if (NON_TEXT_KEYS.has(key)) continue;
if (isSignature && SIGNATURE_BINARY_KEYS.has(key)) continue;
const replacement = scrubValue(value);
if (replacement) container.set(key, replacement);
}
@@ -218,11 +240,12 @@ function removeTextFromDocument(doc: PDFDocument, term: string): void {
export async function redactDocumentData(
pdf: Uint8Array,
term?: string
terms: readonly string[] = []
): Promise<Uint8Array> {
const doc = await loadPdfDocument(pdf);
if (term) {
removeTextFromDocument(doc, term);
const searchTerms = terms.filter((term) => term.length > 0);
if (searchTerms.length > 0) {
removeTextFromDocument(doc, searchTerms);
} else {
for (const page of doc.getPages()) page.node.delete(PDFName.of('Thumb'));
}
+521 -128
View File
@@ -1,9 +1,24 @@
import { PDFDocument, PDFName, PDFDict, PDFArray, PDFStream } from 'pdf-lib';
import {
PDFDocument,
PDFName,
PDFDict,
PDFArray,
PDFStream,
PDFObject,
PDFRef,
PDFRawStream,
PDFFlateStream,
PDFPageLeaf,
PDFContext,
decodePDFRawStream,
} from 'pdf-lib';
import {
loadPdfDocument,
removeUnreferencedObjects,
} from './load-pdf-document.js';
import type { PDFDocumentInternal } from '@/types';
import { flattenFormsInDoc } from './flatten-annotations.js';
import { parseContentOperations } from './pdf-content-tokens.js';
import type { ContentOperation, PDFDocumentInternal } from '@/types';
function getCatalogDict(pdfDoc: PDFDocument): PDFDict {
return pdfDoc.catalog;
@@ -33,6 +48,22 @@ function getErrorMessage(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
function forEachDict(pdfDoc: PDFDocument, visit: (dict: PDFDict) => void) {
const walk = (value: PDFObject) => {
if (value instanceof PDFStream) {
walk(value.dict);
} else if (value instanceof PDFDict) {
visit(value);
for (const [, entry] of value.entries()) walk(entry);
} else if (value instanceof PDFArray) {
for (const entry of value.asArray()) walk(entry);
}
};
for (const [, object] of pdfDoc.context.enumerateIndirectObjects()) {
walk(object);
}
}
export interface SanitizeOptions {
flattenForms: boolean;
removeMetadata: boolean;
@@ -74,10 +105,11 @@ function removeMetadataFromDoc(pdfDoc: PDFDocument) {
pdfDoc.setProducer('');
try {
const catalogDict = getCatalogDict(pdfDoc);
if (catalogDict.has(PDFName.of('Metadata'))) {
catalogDict.delete(PDFName.of('Metadata'));
}
forEachDict(pdfDoc, (dict) => {
dict.delete(PDFName.of('Metadata'));
dict.delete(PDFName.of('PieceInfo'));
dict.delete(PDFName.of('Thumb'));
});
} catch (e: unknown) {
console.warn('Could not remove XMP metadata:', getErrorMessage(e));
}
@@ -90,15 +122,6 @@ function removeMetadataFromDoc(pdfDoc: PDFDocument) {
} catch (e: unknown) {
console.warn('Could not remove document IDs:', getErrorMessage(e));
}
try {
const catalogDict = getCatalogDict(pdfDoc);
if (catalogDict.has(PDFName.of('PieceInfo'))) {
catalogDict.delete(PDFName.of('PieceInfo'));
}
} catch (e: unknown) {
console.warn('Could not remove PieceInfo:', getErrorMessage(e));
}
}
function removeAnnotationsFromDoc(pdfDoc: PDFDocument) {
@@ -115,9 +138,72 @@ function removeAnnotationsFromDoc(pdfDoc: PDFDocument) {
}
}
function flattenFormsInDoc(pdfDoc: PDFDocument) {
const form = pdfDoc.getForm();
form.flatten();
const DANGEROUS_ACTIONS = new Set([
'JavaScript',
'Launch',
'ImportData',
'SubmitForm',
'GoToE',
'Rendition',
]);
function isDangerousAction(action: PDFDict): boolean {
const type = action.lookup(PDFName.of('S'));
return (
action.has(PDFName.of('JS')) ||
(type instanceof PDFName && DANGEROUS_ACTIONS.has(type.decodeText()))
);
}
function keepSafeActions(
context: PDFContext,
value: PDFObject,
seen: Set<PDFObject>
): PDFObject[] {
const resolved = context.lookup(value);
if (resolved instanceof PDFArray) {
if (seen.has(resolved)) return [];
seen.add(resolved);
return resolved
.asArray()
.flatMap((item) => keepSafeActions(context, item, seen));
}
if (!(resolved instanceof PDFDict) || !isDangerousAction(resolved)) {
return [value];
}
if (seen.has(resolved)) return [];
seen.add(resolved);
const next = resolved.get(PDFName.of('Next'));
return next ? keepSafeActions(context, next, seen) : [];
}
function removeDangerousActions(context: PDFContext, dict: PDFDict) {
dict.delete(PDFName.of('AA'));
const keys = ['A', 'OpenAction', 'PA'];
if (dict.has(PDFName.of('S'))) keys.push('Next');
for (const key of keys) {
const name = PDFName.of(key);
const value = dict.get(name);
if (value === undefined) continue;
const resolved = context.lookup(value);
if (key !== 'Next' && !(resolved instanceof PDFDict)) continue;
const safeActions = keepSafeActions(context, value, new Set());
const originalActions =
resolved instanceof PDFArray ? resolved.asArray() : [value];
if (
safeActions.length === originalActions.length &&
safeActions.every((item, i) => item === originalActions[i])
) {
continue;
}
if (safeActions.length === 0) {
dict.delete(name);
} else if (key === 'Next' && safeActions.length > 1) {
dict.set(name, context.obj(safeActions));
} else {
dict.set(name, safeActions[0]);
}
}
}
function removeJavascriptFromDoc(pdfDoc: PDFDocument) {
@@ -140,92 +226,13 @@ function removeJavascriptFromDoc(pdfDoc: PDFDocument) {
}
}
if (catalogDict.has(PDFName.of('OpenAction'))) {
catalogDict.delete(PDFName.of('OpenAction'));
const acroForm = catalogDict.lookup(PDFName.of('AcroForm'));
if (acroForm instanceof PDFDict) {
acroForm.delete(PDFName.of('XFA'));
catalogDict.delete(PDFName.of('NeedsRendering'));
}
if (catalogDict.has(PDFName.of('AA'))) {
catalogDict.delete(PDFName.of('AA'));
}
const pages = pdfDoc.getPages();
for (const page of pages) {
try {
const pageDict = page.node;
if (pageDict.has(PDFName.of('AA'))) {
pageDict.delete(PDFName.of('AA'));
}
const annotRefs = pageDict.Annots()?.asArray() || [];
for (const annotRef of annotRefs) {
try {
const annot = lookupAsDict(pdfDoc, annotRef);
if (!annot) continue;
if (annot.has(PDFName.of('A'))) {
const actionRef = annot.get(PDFName.of('A'));
try {
const actionDict = lookupAsDict(pdfDoc, actionRef);
const actionType = actionDict
?.get(PDFName.of('S'))
?.toString()
.substring(1);
if (actionType === 'JavaScript') {
annot.delete(PDFName.of('A'));
}
} catch (e: unknown) {
console.warn('Could not read action:', getErrorMessage(e));
}
}
if (annot.has(PDFName.of('AA'))) {
annot.delete(PDFName.of('AA'));
}
} catch (e: unknown) {
console.warn(
'Could not process annotation for JS:',
getErrorMessage(e)
);
}
}
} catch (e: unknown) {
console.warn('Could not remove page actions:', getErrorMessage(e));
}
}
try {
const acroFormRef = catalogDict.get(PDFName.of('AcroForm'));
if (acroFormRef) {
const acroFormDict = lookupAsDict(pdfDoc, acroFormRef);
const fieldsRef = acroFormDict?.get(PDFName.of('Fields'));
if (fieldsRef) {
const fieldsArray = lookupAsArray(pdfDoc, fieldsRef);
const fields = fieldsArray?.asArray() || [];
for (const fieldRef of fields) {
try {
const field = lookupAsDict(pdfDoc, fieldRef);
if (!field) continue;
if (field.has(PDFName.of('A'))) {
field.delete(PDFName.of('A'));
}
if (field.has(PDFName.of('AA'))) {
field.delete(PDFName.of('AA'));
}
} catch (e: unknown) {
console.warn('Could not process field for JS:', getErrorMessage(e));
}
}
}
}
} catch (e: unknown) {
console.warn('Could not process form fields for JS:', getErrorMessage(e));
}
forEachDict(pdfDoc, (dict) => removeDangerousActions(pdfDoc.context, dict));
}
function removeEmbeddedFilesFromDoc(pdfDoc: PDFDocument) {
@@ -302,37 +309,410 @@ function removeEmbeddedFilesFromDoc(pdfDoc: PDFDocument) {
}
}
function removeLayersFromDoc(pdfDoc: PDFDocument) {
const catalogDict = getCatalogDict(pdfDoc);
function createOptionalContentVisibility(pdfDoc: PDFDocument) {
const context = pdfDoc.context;
const ocProperties = pdfDoc.catalog.lookup(PDFName.of('OCProperties'));
const config =
ocProperties instanceof PDFDict
? ocProperties.lookup(PDFName.of('D'))
: undefined;
const getConfigGroups = (key: string) => {
const list =
config instanceof PDFDict ? config.lookup(PDFName.of(key)) : undefined;
return new Set(
list instanceof PDFArray
? list.asArray().map((item) => context.lookup(item))
: []
);
};
const onGroups = getConfigGroups('ON');
const offGroups = getConfigGroups('OFF');
const hiddenByDefault =
config instanceof PDFDict &&
config.lookup(PDFName.of('BaseState')) === PDFName.of('OFF');
const isGroupVisible = (group: PDFObject) =>
!offGroups.has(group) && (!hiddenByDefault || onGroups.has(group));
const expressionCache = new Map<PDFObject, boolean | undefined>();
if (catalogDict.has(PDFName.of('OCProperties'))) {
catalogDict.delete(PDFName.of('OCProperties'));
const isExpressionVisible = (
value: PDFObject | undefined,
depth: number
): boolean | undefined => {
const resolved = context.lookup(value);
if (resolved instanceof PDFDict) return isGroupVisible(resolved);
if (!(resolved instanceof PDFArray) || resolved.size() < 2 || depth > 32) {
return undefined;
}
if (expressionCache.has(resolved)) return expressionCache.get(resolved);
const operator = resolved.lookup(0);
const results = resolved
.asArray()
.slice(1)
.map((item) => isExpressionVisible(item, depth + 1));
let result: boolean | undefined;
if (!results.every((item) => item !== undefined)) {
result = undefined;
} else if (operator === PDFName.of('And')) {
result = results.every(Boolean);
} else if (operator === PDFName.of('Or')) {
result = results.some(Boolean);
} else if (operator === PDFName.of('Not') && results.length === 1) {
result = !results[0];
}
expressionCache.set(resolved, result);
return result;
};
return (value: PDFObject | undefined): boolean => {
const dict = context.lookup(value);
if (!(dict instanceof PDFDict)) return true;
if (dict.lookup(PDFName.of('Type')) !== PDFName.of('OCMD')) {
return isGroupVisible(dict);
}
if (dict.has(PDFName.of('VE'))) {
return isExpressionVisible(dict.get(PDFName.of('VE')), 0) === true;
}
const groups = dict.lookup(PDFName.of('OCGs'));
const states = (
groups instanceof PDFArray
? groups.asArray().map((group) => context.lookup(group))
: [groups]
)
.filter((group) => group instanceof PDFDict)
.map((group) => isGroupVisible(group));
if (states.length === 0) return true;
const policy = dict.lookup(PDFName.of('P'));
if (policy === undefined || policy === PDFName.of('AnyOn')) {
return states.some(Boolean);
}
if (policy === PDFName.of('AllOn')) return states.every(Boolean);
if (policy === PDFName.of('AnyOff')) return !states.every(Boolean);
if (policy === PDFName.of('AllOff')) return !states.some(Boolean);
return false;
};
}
const STATE_OPERATORS = new Set(
'q Q cm w J j M d ri i gs CS cs SC SCN sc scn G g RG rg K k BT ET Tc Tw Tz TL Tf Tr Ts Td TD Tm T* BX EX'.split(
' '
)
);
const PATH_OPERATORS = new Set('m l c v y h re W W*'.split(' '));
const PAINT_OPERATORS = new Set('S s f F f* B B* b b* n'.split(' '));
function readStreamBytes(stream: PDFStream): Uint8Array {
if (stream instanceof PDFRawStream) {
return decodePDFRawStream(stream).decode();
}
if (stream instanceof PDFFlateStream) return stream.getUnencodedContents();
return stream.getContents();
}
const pages = pdfDoc.getPages();
for (const page of pages) {
try {
const pageDict = page.node;
const NEWLINE = new Uint8Array([0x0a]);
if (pageDict.has(PDFName.of('OCProperties'))) {
pageDict.delete(PDFName.of('OCProperties'));
function concatBytes(pieces: Uint8Array[]): Uint8Array {
const result = new Uint8Array(
pieces.reduce((size, piece) => size + piece.length, 0)
);
let offset = 0;
for (const piece of pieces) {
result.set(piece, offset);
offset += piece.length;
}
return result;
}
function readPageContent(page: PDFPageLeaf): Uint8Array | undefined {
const contents = page.Contents();
if (!contents) return undefined;
const streams =
contents instanceof PDFArray ? contents.asArray() : [contents];
return concatBytes(
streams.flatMap((item) => {
const stream = page.context.lookup(item);
return stream instanceof PDFStream
? [readStreamBytes(stream), NEWLINE]
: [];
})
);
}
function isContentStream(dict: PDFDict): boolean {
const subtype = dict.lookup(PDFName.of('Subtype'));
return (
subtype === PDFName.of('Form') ||
(subtype === undefined &&
dict.has(PDFName.of('BBox')) &&
!dict.has(PDFName.of('ShadingType')))
);
}
function removeHiddenContent(
context: PDFContext,
bytes: Uint8Array,
resources: PDFDict | undefined,
isVisible: (value: PDFObject | undefined) => boolean
): Uint8Array | undefined {
const hiddenXObjects = new Set<PDFName>();
const xObjects = resources?.lookup(PDFName.of('XObject'));
if (xObjects instanceof PDFDict) {
for (const [name, value] of xObjects.entries()) {
const xObject = context.lookup(value);
if (
xObject instanceof PDFStream &&
xObject.dict.has(PDFName.of('OC')) &&
!isVisible(xObject.dict.get(PDFName.of('OC')))
) {
hiddenXObjects.add(name);
}
const resourcesRef = pageDict.get(PDFName.of('Resources'));
if (resourcesRef) {
try {
const resourcesDict = lookupAsDict(pdfDoc, resourcesRef);
if (resourcesDict?.has(PDFName.of('Properties'))) {
resourcesDict.delete(PDFName.of('Properties'));
}
} catch (e: unknown) {
console.warn('Could not access Resources:', getErrorMessage(e));
}
}
} catch (e: unknown) {
console.warn('Could not remove page layers:', getErrorMessage(e));
}
}
if (
hiddenXObjects.size === 0 &&
!new TextDecoder('latin1').decode(bytes).includes('BDC')
) {
return undefined;
}
const properties = resources?.lookup(PDFName.of('Properties'));
const startsHiddenSection = (operation: ContentOperation) => {
const [tag, property] = operation.operands;
if (tag?.kind !== 'name' || PDFName.of(tag.value) !== PDFName.of('OC')) {
return false;
}
const value =
property?.kind === 'name' && properties instanceof PDFDict
? properties.get(PDFName.of(property.value))
: undefined;
if (value === undefined) {
throw new Error('Unresolved optional content reference');
}
return !isVisible(value);
};
const encoder = new TextEncoder();
const pieces: Uint8Array[] = [];
const write = (...parts: Uint8Array[]) => pieces.push(NEWLINE, ...parts);
const writeOperation = (operation: ContentOperation) =>
write(bytes.subarray(operation.start, operation.end));
const openSections: boolean[] = [];
let hiddenDepth = 0;
let path: ContentOperation[] = [];
let changed = false;
let previousEnd = 0;
for (const operation of parseContentOperations(bytes)) {
const { operator, operands } = operation;
const hidden = hiddenDepth > 0;
const gapStart = previousEnd;
previousEnd = operation.end;
const keepOriginal = () =>
pieces.push(bytes.subarray(gapStart, operation.end));
if (operator === 'BMC' || operator === 'BDC') {
const startsHidden =
!hidden && operator === 'BDC' && startsHiddenSection(operation);
openSections.push(startsHidden);
if (startsHidden) hiddenDepth++;
if (hidden || startsHidden) {
changed = true;
} else {
keepOriginal();
}
continue;
}
if (operator === 'EMC') {
if (openSections.pop()) hiddenDepth--;
if (hidden) {
changed = true;
if (hiddenDepth === 0) path = [];
} else {
keepOriginal();
}
continue;
}
if (!hidden) {
const target = operands[0];
if (
operator === 'Do' &&
target?.kind === 'name' &&
hiddenXObjects.has(PDFName.of(target.value))
) {
changed = true;
} else {
keepOriginal();
}
continue;
}
changed = true;
if (STATE_OPERATORS.has(operator)) {
writeOperation(operation);
} else if (PATH_OPERATORS.has(operator)) {
path.push(operation);
} else if (PAINT_OPERATORS.has(operator)) {
if (
path.some((item) => item.operator === 'W' || item.operator === 'W*')
) {
path.forEach(writeOperation);
write(encoder.encode('n'));
}
path = [];
} else if (operator === "'") {
write(encoder.encode('T*'));
} else if (operator === '"' && operands.length === 3) {
write(
bytes.subarray(operands[0].start, operands[0].end),
encoder.encode(' Tw '),
bytes.subarray(operands[1].start, operands[1].end),
encoder.encode(' Tc T*')
);
}
}
if (!changed) return undefined;
if (hiddenDepth === 0) pieces.push(bytes.subarray(previousEnd));
return concatBytes(pieces);
}
function mergePageResources(pdfDoc: PDFDocument): PDFDict {
const context = pdfDoc.context;
const merged = new Map<string, Map<string, PDFObject>>();
const conflicts = new Set<string>();
for (const page of pdfDoc.getPages()) {
const resources = page.node.Resources();
for (const category of ['XObject', 'Properties']) {
const entries = resources?.lookup(PDFName.of(category));
if (!(entries instanceof PDFDict)) continue;
const mergedEntries = merged.get(category) ?? new Map();
merged.set(category, mergedEntries);
for (const [name, value] of entries.entries()) {
const key = `${category}/${name.decodeText()}`;
const existing = mergedEntries.get(name.decodeText());
if (existing && existing.toString() !== value.toString()) {
conflicts.add(key);
}
mergedEntries.set(name.decodeText(), value);
}
}
}
const resources = context.obj({});
for (const [category, entries] of merged) {
const dict = context.obj({});
for (const [name, value] of entries) {
if (!conflicts.has(`${category}/${name}`)) {
dict.set(PDFName.of(name), value);
}
}
resources.set(PDFName.of(category), dict);
}
return resources;
}
function removeLayersFromDoc(pdfDoc: PDFDocument) {
const catalogDict = getCatalogDict(pdfDoc);
const context = pdfDoc.context;
const pageContents: [PDFPageLeaf, Uint8Array][] = [];
const streamContents: [PDFRef, PDFStream, Uint8Array][] = [];
const hiddenAnnots = new Set<PDFObject>();
const hiddenXObjects = new Set<PDFObject>();
if (catalogDict.has(PDFName.of('OCProperties'))) {
const isVisible = createOptionalContentVisibility(pdfDoc);
for (const page of pdfDoc.getPages()) {
const content = readPageContent(page.node);
const filtered =
content &&
removeHiddenContent(context, content, page.node.Resources(), isVisible);
if (filtered) pageContents.push([page.node, filtered]);
for (const annotRef of page.node.Annots()?.asArray() ?? []) {
const annot = context.lookup(annotRef);
if (
annot instanceof PDFDict &&
annot.has(PDFName.of('OC')) &&
!isVisible(annot.get(PDFName.of('OC')))
) {
hiddenAnnots.add(annotRef);
}
}
}
const inheritedResources = mergePageResources(pdfDoc);
for (const [ref, object] of context.enumerateIndirectObjects()) {
if (!(object instanceof PDFStream)) continue;
if (
object.dict.has(PDFName.of('OC')) &&
!isVisible(object.dict.get(PDFName.of('OC')))
) {
hiddenXObjects.add(ref);
}
if (!isContentStream(object.dict)) continue;
const resources = object.dict.lookup(PDFName.of('Resources'));
const filtered = removeHiddenContent(
context,
readStreamBytes(object),
resources instanceof PDFDict ? resources : inheritedResources,
isVisible
);
if (filtered) streamContents.push([ref, object, filtered]);
}
}
for (const [page, content] of pageContents) {
page.set(
PDFName.of('Contents'),
context.register(context.flateStream(content))
);
}
for (const [ref, stream, content] of streamContents) {
const replacement = context.flateStream(content);
for (const [key, value] of stream.dict.entries()) {
if (
!['Filter', 'DecodeParms', 'Length', 'DL'].includes(key.decodeText())
) {
replacement.dict.set(key, value);
}
}
context.assign(ref, replacement);
}
for (const page of pdfDoc.getPages()) {
const annots = page.node.Annots();
if (!annots) continue;
for (let i = annots.size() - 1; i >= 0; i--) {
if (hiddenAnnots.has(annots.get(i))) annots.remove(i);
}
}
catalogDict.delete(PDFName.of('OCProperties'));
forEachDict(pdfDoc, (dict) => {
dict.delete(PDFName.of('OCProperties'));
dict.delete(PDFName.of('OC'));
const xObjects = dict.lookup(PDFName.of('XObject'));
if (xObjects instanceof PDFDict) {
for (const [name, value] of xObjects.entries()) {
if (hiddenXObjects.has(value)) xObjects.delete(name);
}
}
const properties = dict.lookup(PDFName.of('Properties'));
if (properties instanceof PDFDict) {
for (const [name, value] of properties.entries()) {
const type = lookupAsDict(pdfDoc, value)?.lookup(PDFName.of('Type'));
if (type === PDFName.of('OCG') || type === PDFName.of('OCMD')) {
properties.delete(name);
}
}
if (properties.entries().length === 0) {
dict.delete(PDFName.of('Properties'));
}
}
});
}
function removeLinksFromDoc(pdfDoc: PDFDocument) {
@@ -565,8 +945,9 @@ function removeFontsFromDoc(pdfDoc: PDFDocument) {
export async function sanitizePdf(
pdfBytes: Uint8Array,
options: SanitizeOptions
): Promise<{ pdfDoc: PDFDocument; bytes: Uint8Array }> {
): Promise<{ pdfDoc: PDFDocument; bytes: Uint8Array; skipped: string[] }> {
const pdfDoc = await loadPdfDocument(pdfBytes);
const skipped: string[] = [];
if (options.flattenForms) {
try {
@@ -583,6 +964,7 @@ export async function sanitizePdf(
'Could not remove AcroForm:',
getErrorMessage(removeError)
);
skipped.push('flatten forms');
}
}
}
@@ -600,6 +982,7 @@ export async function sanitizePdf(
removeJavascriptFromDoc(pdfDoc);
} catch (e: unknown) {
console.warn(`Could not remove JavaScript: ${getErrorMessage(e)}`);
skipped.push('remove JavaScript');
}
}
@@ -608,6 +991,7 @@ export async function sanitizePdf(
removeEmbeddedFilesFromDoc(pdfDoc);
} catch (e: unknown) {
console.warn(`Could not remove embedded files: ${getErrorMessage(e)}`);
skipped.push('remove embedded files');
}
}
@@ -616,6 +1000,7 @@ export async function sanitizePdf(
removeLayersFromDoc(pdfDoc);
} catch (e: unknown) {
console.warn(`Could not remove layers: ${getErrorMessage(e)}`);
skipped.push('remove layers');
}
}
@@ -624,6 +1009,7 @@ export async function sanitizePdf(
removeLinksFromDoc(pdfDoc);
} catch (e: unknown) {
console.warn(`Could not remove links: ${getErrorMessage(e)}`);
skipped.push('remove links');
}
}
@@ -632,6 +1018,7 @@ export async function sanitizePdf(
removeStructureTreeFromDoc(pdfDoc);
} catch (e: unknown) {
console.warn(`Could not remove structure tree: ${getErrorMessage(e)}`);
skipped.push('remove the structure tree');
}
}
@@ -640,6 +1027,7 @@ export async function sanitizePdf(
removeMarkInfoFromDoc(pdfDoc);
} catch (e: unknown) {
console.warn(`Could not remove MarkInfo: ${getErrorMessage(e)}`);
skipped.push('remove MarkInfo');
}
}
@@ -648,9 +1036,14 @@ export async function sanitizePdf(
removeFontsFromDoc(pdfDoc);
} catch (e: unknown) {
console.warn(`Could not remove fonts: ${getErrorMessage(e)}`);
skipped.push('remove fonts');
}
}
const savedBytes = await pdfDoc.save();
return { pdfDoc, bytes: await removeUnreferencedObjects(savedBytes) };
return {
pdfDoc,
bytes: await removeUnreferencedObjects(savedBytes),
skipped,
};
}
+7
View File
@@ -0,0 +1,7 @@
import DOMPurify from 'dompurify';
const ALLOWED_TAGS = ['br', 'strong', 'em'];
export function sanitizeTranslationHtml(html: string): string {
return DOMPurify.sanitize(html, { ALLOWED_TAGS, ALLOWED_ATTR: [] });
}
+6 -1
View File
@@ -1,5 +1,6 @@
import { ClassicPreset } from 'rete';
import type { NodeCategory, NodeMeta, SocketData } from '../types';
import { getControlSpec, sanitizeWithControlSpec } from './control-specs';
export abstract class BaseWorkflowNode extends ClassicPreset.Node {
abstract readonly category: NodeCategory;
@@ -41,6 +42,10 @@ export abstract class BaseWorkflowNode extends ClassicPreset.Node {
if (typeof value !== typeof current) {
return current;
}
return value;
return sanitizeWithControlSpec(
getControlSpec(this.nodeType, key),
value,
current
);
}
}
+387
View File
@@ -0,0 +1,387 @@
import type { WorkflowControlOption, WorkflowControlSpec } from '@/types';
import { getAvailableTesseractLanguageEntries } from '../../utils/tesseract-language-availability.js';
export const MAX_TEXT_CONTROL_LENGTH = 1000;
const BOOLEAN: WorkflowControlSpec = { kind: 'boolean' };
const COLOR: WorkflowControlSpec = { kind: 'color' };
const PASSWORD: WorkflowControlSpec = { kind: 'password' };
const HEX_COLOR = /^#[0-9a-f]{6}$/i;
function toOptions(entries: [string, string][]): WorkflowControlOption[] {
return entries.map(([value, label]) => ({ value, label }));
}
function selectSpec(...entries: [string, string][]): WorkflowControlSpec {
return { kind: 'select', options: toOptions(entries) };
}
function numberSpec(
min: number,
max: number,
step?: number
): WorkflowControlSpec {
return { kind: 'number', min, max, step };
}
const PAGE_ORIENTATION = selectSpec(
['auto', 'Auto (Keep Original)'],
['portrait', 'Portrait'],
['landscape', 'Landscape']
);
const PAGE_COORDINATE = numberSpec(0, 14400);
const CONTROL_SPECS: Record<string, Record<string, WorkflowControlSpec>> = {
AddBlankPageNode: {
blankPosition: selectSpec(
['end', 'End'],
['start', 'Beginning'],
['after', 'After Page...']
),
afterPage: numberSpec(1, 100000, 1),
count: numberSpec(1, 100, 1),
},
AdjustColorsNode: {
brightness: numberSpec(-100, 100, 1),
contrast: numberSpec(-100, 100, 1),
saturation: numberSpec(-100, 100, 1),
hueShift: numberSpec(0, 360, 1),
temperature: numberSpec(-50, 50, 1),
tint: numberSpec(-50, 50, 1),
gamma: numberSpec(0.2, 5, 0.1),
sepia: numberSpec(0, 100, 1),
},
AlternateMergeNode: {
retainPageLabels: BOOLEAN,
},
BackgroundColorNode: {
color: COLOR,
},
BookletNode: {
gridMode: selectSpec(
['1x2', '1x2 (Booklet)'],
['2x2', '2x2 (4-up)'],
['2x4', '2x4 (8-up)'],
['4x4', '4x4 (16-up)']
),
paperSize: selectSpec(
['Letter', 'Letter'],
['A4', 'A4'],
['A3', 'A3'],
['Tabloid', 'Tabloid'],
['Legal', 'Legal']
),
orientation: PAGE_ORIENTATION,
},
CombineSinglePageNode: {
orientation: selectSpec(
['vertical', 'Vertical'],
['horizontal', 'Horizontal']
),
spacing: numberSpec(0, 1000),
backgroundColor: COLOR,
separator: BOOLEAN,
separatorThickness: numberSpec(0.5, 50, 0.5),
separatorColor: COLOR,
},
CompressNode: {
algorithm: selectSpec(
['condense', 'Condense (Smart, requires PyMuPDF)'],
['photon', 'Photon (Rasterize pages)']
),
compressionLevel: selectSpec(
['light', 'Light'],
['balanced', 'Balanced'],
['aggressive', 'Aggressive'],
['extreme', 'Extreme']
),
imageQuality: numberSpec(1, 100, 1),
dpiTarget: numberSpec(36, 300, 1),
dpiThreshold: numberSpec(72, 600, 1),
removeMetadata: BOOLEAN,
subsetFonts: BOOLEAN,
convertToGrayscale: BOOLEAN,
removeThumbnails: BOOLEAN,
},
CropNode: {
top: PAGE_COORDINATE,
bottom: PAGE_COORDINATE,
left: PAGE_COORDINATE,
right: PAGE_COORDINATE,
},
DecryptNode: {
password: PASSWORD,
},
DeskewNode: {
skewThreshold: selectSpec(
['0.1', '0.1° (Very Sensitive)'],
['0.5', '0.5° (Default)'],
['1.0', '1.0° (Normal)'],
['2.0', '2.0° (Less Sensitive)']
),
processingDpi: selectSpec(
['100', '100 (Fast)'],
['150', '150 (Default)'],
['200', '200 (Better)'],
['300', '300 (Best Quality)']
),
},
DividePagesNode: {
direction: selectSpec(
['vertical', 'Vertical'],
['horizontal', 'Horizontal']
),
},
EmailToPdfNode: {
pageSize: selectSpec(
['a4', 'A4'],
['letter', 'Letter'],
['legal', 'Legal']
),
includeCcBcc: BOOLEAN,
includeAttachments: BOOLEAN,
},
EncryptNode: {
userPassword: PASSWORD,
ownerPassword: PASSWORD,
},
FixPageSizeNode: {
targetSize: selectSpec(
['A4', 'A4'],
['Letter', 'Letter'],
['Legal', 'Legal'],
['A3', 'A3'],
['A5', 'A5'],
['Tabloid', 'Tabloid'],
['Custom', 'Custom']
),
orientation: PAGE_ORIENTATION,
scalingMode: selectSpec(
['fit', 'Fit (keep full page visible)'],
['fill', 'Fill (cover full target page)']
),
backgroundColor: COLOR,
customWidth: numberSpec(1, 5080),
customHeight: numberSpec(1, 5080),
customUnits: selectSpec(['mm', 'Millimeters (mm)'], ['in', 'Inches (in)']),
},
HeaderFooterNode: {
fontSize: numberSpec(4, 72, 1),
color: COLOR,
},
MergeNode: {
retainPageLabels: BOOLEAN,
},
NUpNode: {
pagesPerSheet: selectSpec(['2', '2'], ['4', '4'], ['9', '9'], ['16', '16']),
orientation: PAGE_ORIENTATION,
margins: BOOLEAN,
border: BOOLEAN,
borderColor: COLOR,
},
OCRNode: {
language: {
kind: 'multiselect',
options: toOptions(getAvailableTesseractLanguageEntries()),
separator: '+',
},
resolution: selectSpec(
['2.0', 'Standard (192 DPI)'],
['3.0', 'High (288 DPI)'],
['4.0', 'Ultra (384 DPI)']
),
binarize: BOOLEAN,
},
PageNumbersNode: {
position: selectSpec(
['bottom-center', 'Bottom Center'],
['bottom-left', 'Bottom Left'],
['bottom-right', 'Bottom Right'],
['top-center', 'Top Center'],
['top-left', 'Top Left'],
['top-right', 'Top Right']
),
fontSize: numberSpec(4, 72, 1),
numberFormat: selectSpec(
['simple', 'Simple (1, 2, 3)'],
['page_x_of_y', 'Page X of Y']
),
color: COLOR,
},
PdfToImagesNode: {
format: selectSpec(
['jpg', 'JPG'],
['png', 'PNG'],
['webp', 'WebP'],
['svg', 'SVG']
),
quality: numberSpec(10, 100, 1),
dpi: numberSpec(72, 600, 1),
},
PdfToPdfANode: {
level: selectSpec(
['PDF/A-1b', 'PDF/A-1b (Strict, no transparency)'],
['PDF/A-2b', 'PDF/A-2b (Recommended)'],
['PDF/A-3b', 'PDF/A-3b (Modern, allows attachments)']
),
preFlatten: BOOLEAN,
},
PosterizeNode: {
rows: numberSpec(1, 8, 1),
cols: numberSpec(1, 8, 1),
},
RasterizeNode: {
rasterizeDpi: selectSpec(
['72', '72 (Screen)'],
['150', '150 (Default)'],
['200', '200 (Good)'],
['300', '300 (Print)'],
['600', '600 (High Quality)']
),
imageFormat: selectSpec(
['png', 'PNG (Lossless)'],
['jpeg', 'JPEG (Smaller file size)']
),
grayscale: BOOLEAN,
},
RedactNode: {
redactMode: selectSpec(
['text', 'Search Text'],
['area', 'Area (Coordinates)']
),
x0: PAGE_COORDINATE,
y0: PAGE_COORDINATE,
x1: PAGE_COORDINATE,
y1: PAGE_COORDINATE,
fillColor: COLOR,
},
RemoveBlankPagesNode: {
threshold: numberSpec(0.1, 5, 0.1),
},
RotateNode: {
angle: selectSpec(
['90', '90° Clockwise'],
['180', '180°'],
['270', '90° Counter-clockwise']
),
},
SanitizeNode: {
flattenForms: BOOLEAN,
removeMetadata: BOOLEAN,
removeAnnotations: BOOLEAN,
removeJavascript: BOOLEAN,
removeEmbeddedFiles: BOOLEAN,
removeLayers: BOOLEAN,
removeLinks: BOOLEAN,
removeStructureTree: BOOLEAN,
removeMarkInfo: BOOLEAN,
removeFonts: BOOLEAN,
},
ScannerEffectNode: {
grayscale: BOOLEAN,
border: BOOLEAN,
rotation: numberSpec(-5, 5, 0.1),
rotationVariance: numberSpec(0, 3, 0.1),
brightness: numberSpec(-50, 50, 1),
contrast: numberSpec(-50, 50, 1),
blur: numberSpec(0, 3, 0.1),
noise: numberSpec(0, 50, 1),
yellowish: numberSpec(0, 50, 1),
resolution: numberSpec(72, 300, 1),
},
TableOfContentsNode: {
fontSize: numberSpec(10, 72, 1),
fontFamily: selectSpec(
['0', 'Times Roman'],
['1', 'Times Bold'],
['2', 'Times Italic'],
['3', 'Times Bold Italic'],
['4', 'Helvetica'],
['5', 'Helvetica Bold'],
['6', 'Helvetica Oblique'],
['7', 'Helvetica Bold Oblique'],
['8', 'Courier'],
['9', 'Courier Bold'],
['10', 'Courier Oblique'],
['11', 'Courier Bold Oblique']
),
},
TextToPdfNode: {
fontSize: numberSpec(6, 72, 1),
fontFamily: selectSpec(
['helv', 'Helvetica'],
['times', 'Times Roman'],
['cour', 'Courier'],
['tiro', 'Times Italic']
),
fontColor: COLOR,
},
WatermarkNode: {
fontSize: numberSpec(10, 200, 1),
color: COLOR,
opacity: numberSpec(0, 100, 1),
angle: numberSpec(-180, 180, 1),
position: selectSpec(
['center', 'Center'],
['top-left', 'Top Left'],
['top', 'Top'],
['top-right', 'Top Right'],
['left', 'Left'],
['right', 'Right'],
['bottom-left', 'Bottom Left'],
['bottom', 'Bottom'],
['bottom-right', 'Bottom Right']
),
tileGapX: numberSpec(0, 200, 1),
tileGapY: numberSpec(0, 200, 1),
},
};
export function getControlSpec(
nodeType: string,
key: string
): WorkflowControlSpec | undefined {
if (!Object.hasOwn(CONTROL_SPECS, nodeType)) return undefined;
const specs = CONTROL_SPECS[nodeType];
return Object.hasOwn(specs, key) ? specs[key] : undefined;
}
export function sanitizeWithControlSpec(
spec: WorkflowControlSpec | undefined,
value: unknown,
fallback: unknown
): unknown {
switch (spec?.kind) {
case 'password':
return '';
case 'select':
return spec.options.some((option) => option.value === String(value))
? value
: fallback;
case 'multiselect': {
if (typeof value !== 'string') return fallback;
const allowed = new Set(spec.options.map((option) => option.value));
const kept = [...new Set(value.split(spec.separator))].filter((part) =>
allowed.has(part)
);
return kept.length > 0 ? kept.join(spec.separator) : fallback;
}
case 'number':
return typeof value === 'number' && Number.isFinite(value)
? Math.min(spec.max, Math.max(spec.min, value))
: fallback;
case 'boolean':
return value === 'true' || value === 'false' ? value : fallback;
case 'color':
return typeof value === 'string' && HEX_COLOR.test(value)
? value
: fallback;
default:
if (typeof value === 'string') {
return value.slice(0, MAX_TEXT_CONTROL_LENGTH);
}
if (typeof value === 'number' && Number.isFinite(value)) return value;
return fallback;
}
}
+5 -3
View File
@@ -3,7 +3,10 @@ import { BaseWorkflowNode } from './base-node';
import { pdfSocket } from '../sockets';
import type { SocketData } from '../types';
import { requirePdfInput, processBatch } from '../types';
import { flattenAnnotations } from '../../utils/flatten-annotations.js';
import {
flattenAnnotations,
flattenFormsInDoc,
} from '../../utils/flatten-annotations.js';
import {
loadPdfDocument,
removeUnreferencedObjects,
@@ -30,8 +33,7 @@ export class FlattenNode extends BaseWorkflowNode {
const pdfDoc = await loadPdfDocument(input.bytes);
try {
const form = pdfDoc.getForm();
form.flatten();
flattenFormsInDoc(pdfDoc);
} catch (err) {
console.error('Flatten form error (may have no forms):', err);
}
+1 -1
View File
@@ -112,7 +112,7 @@ export class RedactNode extends BaseWorkflowNode {
const resultBytes = await redactDocumentData(
redactedBytes,
mode === 'text' ? searchText : undefined
mode === 'text' ? [searchText] : []
);
const resultDoc = await loadPdfDocument(resultBytes);
+1 -1
View File
@@ -698,8 +698,8 @@ export const nodeRegistry: Record<string, NodeRegistryEntry> = {
};
export function createNodeByType(type: string): BaseWorkflowNode | null {
if (!Object.hasOwn(nodeRegistry, type)) return null;
const entry = nodeRegistry[type];
if (!entry) return null;
const node = entry.factory();
node.nodeType = type;
node.label = translateNodeLabel(type, entry);
@@ -19,7 +19,7 @@ export class RemoveBlankPagesNode extends BaseWorkflowNode {
this.addOutput('pdf', new ClassicPreset.Output(pdfSocket, 'PDF'));
this.addControl(
'threshold',
new ClassicPreset.InputControl('number', { initial: 250 })
new ClassicPreset.InputControl('number', { initial: 5 })
);
}
+5
View File
@@ -84,6 +84,11 @@ export class SanitizeNode extends BaseWorkflowNode {
return {
pdf: await processBatch(pdfInputs, async (input) => {
const result = await sanitizePdf(input.bytes, options);
if (result.skipped.length > 0) {
console.warn(
`[Sanitize] ${input.filename}: could not ${result.skipped.join(', ')}`
);
}
return {
type: 'pdf',
document: result.pdfDoc,
+37 -3
View File
@@ -3,6 +3,7 @@ import type { AreaPlugin } from 'rete-area-plugin';
import type { ClassicScheme, LitArea2D } from '@retejs/lit-plugin';
import type { BaseWorkflowNode } from './nodes/base-node';
import { createNodeByType } from './nodes/registry';
import { getControlSpec } from './nodes/control-specs';
import { ClassicPreset } from 'rete';
import type {
SerializedWorkflow,
@@ -18,6 +19,24 @@ function getNodeType(node: BaseWorkflowNode): string | null {
return node.nodeType || null;
}
function clearPasswordControls(workflow: SerializedWorkflow): boolean {
let cleared = false;
for (const node of Array.isArray(workflow?.nodes) ? workflow.nodes : []) {
const controls = node?.controls;
if (!controls || typeof controls !== 'object') continue;
for (const key of Object.keys(controls)) {
if (
controls[key] !== '' &&
getControlSpec(String(node.type), key)?.kind === 'password'
) {
controls[key] = '';
cleared = true;
}
}
}
return cleared;
}
function serializeWorkflow(
editor: NodeEditor<ClassicScheme>,
area: AreaPlugin<ClassicScheme, AreaExtra>
@@ -56,11 +75,13 @@ function serializeWorkflow(
});
}
return {
const workflow: SerializedWorkflow = {
version: WORKFLOW_VERSION,
nodes,
connections,
} as SerializedWorkflow;
};
clearPasswordControls(workflow);
return workflow;
}
async function deserializeWorkflow(
@@ -96,6 +117,7 @@ async function deserializeWorkflow(
}
for (const [key, value] of Object.entries(serializedNode.controls || {})) {
if (!Object.hasOwn(node.controls, key)) continue;
const control = node.controls[key];
if (control && 'value' in control) {
(control as { value: unknown }).value = node.sanitizeControlValue(
@@ -144,7 +166,19 @@ function getStoredTemplates(): StoredTemplates {
const json = localStorage.getItem(TEMPLATES_KEY);
if (!json) return {};
try {
return JSON.parse(json) as StoredTemplates;
const templates = JSON.parse(json) as StoredTemplates;
let cleared = false;
for (const template of Object.values(templates)) {
if (clearPasswordControls(template)) cleared = true;
}
if (cleared) {
try {
localStorage.setItem(TEMPLATES_KEY, JSON.stringify(templates));
} catch (e) {
console.warn('Failed to clear stored workflow passwords:', e);
}
}
return templates;
} catch {
return {};
}
+8 -3
View File
@@ -350,9 +350,14 @@
<i data-lucide="plus" class="faq-d-icon"></i>
</summary>
<p class="faq-d-a">
Yes. Applying a redaction removes the content underneath the marked
area from the file rather than covering it with a box, so the text
can't be recovered by copying or searching. Open the downloaded file
Yes, once it's applied. Marking text or an area only flags it.
Applying a redaction removes the content underneath the marked area
from the file rather than covering it with a box, so the text can't
be recovered by copying or searching. If you download with marks
that haven't been applied yet, the editor offers to apply them
first. When you redact selected text, copies of it in the file's
properties, bookmarks, comments, and attachments are removed on
download too, unless the file is encrypted. Open the downloaded file
and check before you share it.
</p>
</details>
+7 -6
View File
@@ -401,12 +401,13 @@
<i data-lucide="plus" class="faq-d-icon"></i>
</summary>
<p class="faq-d-a">
It means bytes were appended after this signature was applied, and
the tool reports it as Invalid with the note Modified After Signing
(partial coverage). That is not proof of tampering by itself: a
second signature or an incremental save produces the same result,
and only the last signature added can cover the entire file. Treat
it as a reason to look at what was added.
It means bytes were appended after this signature was applied. The
part that was signed is still intact, so the tool shows a Modified
After Signing warning instead of marking the signature invalid. That
is not proof of tampering by itself: a second signature or an
incremental save produces the same result, and only the last
signature added can cover the entire file. Treat it as a reason to
look at what was added.
</p>
</details>
<details class="faq-d">
+155
View File
@@ -0,0 +1,155 @@
import fs from 'node:fs';
import path from 'node:path';
import { describe, it, expect } from 'vitest';
import {
buildSecurityHeaders,
DOCS_INLINE_SCRIPT_HASHES,
} from '../../scripts/generate-security-headers.mjs';
import { WasmProvider } from '../js/utils/wasm-provider';
const ROOT = path.resolve(__dirname, '../..');
const read = (file: string) => fs.readFileSync(path.join(ROOT, file), 'utf8');
const defaults = buildSecurityHeaders({});
function directive(policy: string, name: string): string[] {
const found = policy
.split(';')
.map((d) => d.trim().split(/\s+/))
.find(([n]) => n === name);
return found ? found.slice(1) : [];
}
function allowedBy(sources: string[], url: string): boolean {
return sources.some((source) =>
source.endsWith('/')
? url.startsWith(source)
: new URL(url).origin === source
);
}
function headerRuleCsp(file: string, source: string): string | undefined {
const config = JSON.parse(read(file)) as {
headers: Array<{
source: string;
headers: Array<{ key: string; value: string }>;
}>;
};
return config.headers
.find((rule) => rule.source === source)
?.headers.find((h) => h.key === 'Content-Security-Policy')?.value;
}
describe('CSP script-src', () => {
const scriptSrc = directive(defaults.csp, 'script-src');
it('allows jsDelivr only through version-pinned package prefixes', () => {
const jsdelivr = scriptSrc.filter((s) => s.includes('cdn.jsdelivr.net'));
expect(jsdelivr.length).toBeGreaterThan(0);
for (const source of jsdelivr) {
expect(source.startsWith('https://cdn.jsdelivr.net/npm/'), source).toBe(
true
);
expect(source.endsWith('/'), source).toBe(true);
expect(source).toMatch(/@v?\d+\.\d+\.\d+\//);
}
});
it('covers every script the app loads from its default CDN URLs', () => {
const { pymupdf, ghostscript, cpdf } = WasmProvider.getAllProviders();
const tesseract = JSON.parse(
read('node_modules/tesseract.js/package.json')
) as { version: string; dependencies: Record<string, string> };
const core = tesseract.dependencies['tesseract.js-core'].substring(1);
const urls = [
`${pymupdf}dist/index.js`,
`${pymupdf}assets/pyodide.js`,
`${pymupdf}assets/pyodide.asm.js`,
`${ghostscript}gs.js`,
`${cpdf}coherentpdf.browser.min.js`,
`https://cdn.jsdelivr.net/npm/tesseract.js@v${tesseract.version}/dist/worker.min.js`,
`https://cdn.jsdelivr.net/npm/tesseract.js-core@v${core}/tesseract-core-simd-lstm.wasm.js`,
];
for (const url of urls) {
expect(allowedBy(scriptSrc, url), url).toBe(true);
}
});
it('rejects arbitrary jsDelivr packages and GitHub repos', () => {
for (const url of [
'https://cdn.jsdelivr.net/gh/attacker/repo@main/x.js',
'https://cdn.jsdelivr.net/npm/evil-package@1.0.0/x.js',
'https://cdn.jsdelivr.net/npm/coherentpdf@2.5.6/dist/coherentpdf.browser.min.js',
'https://cdn.jsdelivr.net/combine/npm/evil@1',
]) {
expect(allowedBy(scriptSrc, url), url).toBe(false);
}
});
it('keeps self-hosted VITE_* URLs working', () => {
const custom = buildSecurityHeaders({
VITE_WASM_PYMUPDF_URL: 'https://internal.example.com/wasm/pymupdf/',
VITE_WASM_GS_URL:
'https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm@0.1.1/assets',
VITE_WASM_CPDF_URL: 'https://cdn.jsdelivr.net/npm/x@1.0.0/a;b,c/',
VITE_TESSERACT_WORKER_URL:
'https://cdn.jsdelivr.net/npm/tesseract.js@v7.0.0/dist/worker.min.js',
VITE_TESSERACT_CORE_URL: 'https://ocr.example.com/core',
VITE_TESSERACT_LANG_URL: 'https://ocr.example.com/lang',
});
expect(custom.scriptSources).toEqual([
'https://internal.example.com',
'https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm@0.1.1/assets/',
'https://cdn.jsdelivr.net/npm/x@1.0.0/a%3Bb%2Cc/',
'https://cdn.jsdelivr.net/npm/tesseract.js@v7.0.0/dist/',
'https://ocr.example.com',
]);
expect(directive(custom.csp, 'connect-src')).toEqual(
expect.arrayContaining([
'https://internal.example.com',
'https://cdn.jsdelivr.net',
'https://ocr.example.com',
])
);
});
it('only allows the pinned docs inline scripts', () => {
const sources = directive(defaults.docsCsp, 'script-src');
expect(sources).toEqual([
"'self'",
...DOCS_INLINE_SCRIPT_HASHES.map((hash) => `'${hash}'`),
]);
expect(sources).not.toContain("'unsafe-inline'");
});
});
describe('deployment configs match the generator', () => {
it('vercel.json', () => {
expect(headerRuleCsp('vercel.json', '/(.*)')).toBe(defaults.csp);
expect(headerRuleCsp('vercel.json', '/docs')).toBe(defaults.docsCsp);
expect(headerRuleCsp('vercel.json', '/docs/:path*')).toBe(defaults.docsCsp);
});
it('serve.json', () => {
expect(headerRuleCsp('serve.json', '**/*')).toBe(defaults.csp);
expect(headerRuleCsp('serve.json', 'docs/**')).toBe(defaults.docsCsp);
});
it('public/_headers for Netlify and Cloudflare Pages', () => {
const file = read('public/_headers');
expect(file).toBe(defaults.staticHeaders);
expect(file).toContain(
`/docs/*\n ! Content-Security-Policy\n Content-Security-Policy: ${defaults.docsCsp}\n`
);
for (const line of file.split('\n')) {
expect(line.length).toBeLessThanOrEqual(2000);
}
});
it('nginx docs config and .htaccess', () => {
expect(read('security-headers-docs.conf')).toBe(defaults.nginxDocs);
expect(read('.htaccess')).toContain(
`Header always set Content-Security-Policy "${defaults.csp}"`
);
});
});
+186
View File
@@ -0,0 +1,186 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type {
PendingRedactionChange,
PendingRedactionItem,
RedactionPluginEvent,
RedactionPluginLite,
} from '../js/types/edit-pdf-redaction-type.js';
const { redactDocumentData } = vi.hoisted(() => ({
redactDocumentData: vi.fn(
async (pdf: Uint8Array, terms: readonly string[] = []) =>
Uint8Array.from([...pdf, terms.length])
),
}));
vi.mock('../js/utils/redact-document-data.js', () => ({
redactDocumentData,
}));
vi.mock('../js/utils/disabled-tools.js', () => ({
getEditorDisabledCategories: (): string[] => [],
}));
const { trackAppliedRedactions, redactionScrubTerms, scrubRedactedText } =
await import('../js/logic/edit-pdf-page.js');
function mockRedactionPlugin() {
const pendingListeners = new Set<(event: PendingRedactionChange) => void>();
const eventListeners = new Set<(event: RedactionPluginEvent) => void>();
const plugin: RedactionPluginLite = {
getState: () => ({ pendingCount: 0 }),
commitAllPending: () => ({ toPromise: async () => true }),
onPendingChange: (listener) => {
pendingListeners.add(listener);
return () => pendingListeners.delete(listener);
},
onRedactionEvent: (listener) => {
eventListeners.add(listener);
return () => eventListeners.delete(listener);
},
};
const pending = new Map<string, PendingRedactionItem[]>();
const publish = (documentId: string) => {
const items = pending.get(documentId) ?? [];
for (const listener of pendingListeners) {
listener({ documentId, pending: items.length ? { 0: items } : {} });
}
};
const emit = (event: RedactionPluginEvent) => {
for (const listener of eventListeners) listener(event);
};
return {
plugin,
mark(documentId: string, item: PendingRedactionItem) {
pending.set(documentId, [...(pending.get(documentId) ?? []), item]);
publish(documentId);
emit({ type: 'add', documentId });
},
unmark(documentId: string, id: string) {
pending.set(
documentId,
(pending.get(documentId) ?? []).filter((item) => item.id !== id)
);
publish(documentId);
emit({ type: 'remove', documentId, id });
},
clearAll(documentId: string) {
pending.set(documentId, []);
publish(documentId);
emit({ type: 'clear', documentId });
},
apply(documentId: string, ids?: string[]) {
pending.set(
documentId,
ids
? (pending.get(documentId) ?? []).filter(
(item) => !ids.includes(item.id)
)
: []
);
publish(documentId);
emit({ type: 'commit', documentId });
},
};
}
describe('trackAppliedRedactions', () => {
it('reports nothing when no redaction was applied', () => {
const viewer = mockRedactionPlugin();
const tracker = trackAppliedRedactions(viewer.plugin);
viewer.mark('doc', { id: 'a', kind: 'text', text: 'Jane Doe' });
expect(tracker.appliedTexts('doc')).toEqual([]);
});
it('keeps the text of applied text marks and skips removed marks', () => {
const viewer = mockRedactionPlugin();
const tracker = trackAppliedRedactions(viewer.plugin);
viewer.mark('doc', { id: 'a', kind: 'text', text: 'Jane Doe' });
viewer.mark('doc', { id: 'b', kind: 'text', text: 'Removed Mark' });
viewer.unmark('doc', 'b');
viewer.mark('doc', { id: 'c', kind: 'area' });
viewer.apply('doc');
expect(tracker.appliedTexts('doc')).toEqual(['Jane Doe', undefined]);
});
it('does not count marks removed with Clear all as applied', () => {
const viewer = mockRedactionPlugin();
const tracker = trackAppliedRedactions(viewer.plugin);
viewer.mark('doc', { id: 'a', kind: 'text', text: 'Applied Text' });
viewer.apply('doc');
viewer.mark('doc', { id: 'b', kind: 'text', text: 'Acme Corp' });
viewer.clearAll('doc');
expect(tracker.appliedTexts('doc')).toEqual(['Applied Text']);
});
it('counts a single applied mark while others stay pending', () => {
const viewer = mockRedactionPlugin();
const tracker = trackAppliedRedactions(viewer.plugin);
viewer.mark('doc', { id: 'a', kind: 'text', text: 'First' });
viewer.mark('doc', { id: 'b', kind: 'text', text: 'Second' });
viewer.apply('doc', ['a']);
expect(tracker.appliedTexts('doc')).toEqual(['First']);
viewer.apply('doc', ['b']);
expect(tracker.appliedTexts('doc')).toEqual(['First', 'Second']);
});
it('keeps applied marks separate per document and forgets closed ones', () => {
const viewer = mockRedactionPlugin();
const tracker = trackAppliedRedactions(viewer.plugin);
viewer.mark('one', { id: 'a', kind: 'text', text: 'Alpha' });
viewer.mark('two', { id: 'b', kind: 'area' });
viewer.apply('one');
expect(tracker.appliedTexts('one')).toEqual(['Alpha']);
expect(tracker.appliedTexts('two')).toEqual([]);
tracker.forget('one');
expect(tracker.appliedTexts('one')).toEqual([]);
});
it('treats a mark that comes back after a reload as pending again', () => {
const viewer = mockRedactionPlugin();
const tracker = trackAppliedRedactions(viewer.plugin);
const item: PendingRedactionItem = { id: 'a', kind: 'text', text: 'X' };
viewer.mark('doc', item);
viewer.apply('doc');
viewer.mark('doc', item);
expect(tracker.appliedTexts('doc')).toEqual([]);
});
});
describe('redactionScrubTerms', () => {
it('splits lines, trims, dedupes case-insensitively and sorts longest first', () => {
expect(
redactionScrubTerms([
'John',
undefined,
' John Smith \r\nAcme Corp',
'john',
'\n',
])
).toEqual(['John Smith', 'Acme Corp', 'John']);
});
it('returns no terms for area-only redactions', () => {
expect(redactionScrubTerms([undefined, undefined])).toEqual([]);
});
});
describe('scrubRedactedText', () => {
beforeEach(() => {
redactDocumentData.mockClear();
});
it('runs one termless pass when no text is known', async () => {
const out = await scrubRedactedText(Uint8Array.from([1]), []);
expect(redactDocumentData).toHaveBeenCalledTimes(1);
expect(redactDocumentData.mock.calls[0][1]).toEqual([]);
expect([...out]).toEqual([1, 0]);
});
it('removes every term in a single pass', async () => {
const out = await scrubRedactedText(Uint8Array.from([1]), ['abc', 'de']);
expect(redactDocumentData).toHaveBeenCalledTimes(1);
expect(redactDocumentData.mock.calls[0][1]).toEqual(['abc', 'de']);
expect([...out]).toEqual([1, 2]);
});
});
+212
View File
@@ -0,0 +1,212 @@
import { describe, it, expect, vi } from 'vitest';
import { readFileSync } from 'fs';
import { resolve } from 'path';
import {
PDFArray,
PDFDict,
PDFDocument,
PDFName,
PDFRawStream,
decodePDFRawStream,
} from 'pdf-lib';
vi.mock('../js/utils/helpers.js', async () => {
const { default: createModule } = await import('@neslinesli93/qpdf-wasm');
const wasmPath = resolve(
'node_modules/@neslinesli93/qpdf-wasm/dist/qpdf.wasm'
);
const options = {
locateFile: () => wasmPath,
wasmBinary: readFileSync(wasmPath),
print: () => {},
printErr: () => {},
};
const qpdf = await createModule(options);
return { initializeQpdf: async () => qpdf };
});
vi.mock('rete', () => ({
ClassicPreset: {
Input: class {},
Output: class {},
},
}));
vi.mock('@/js/workflow/sockets', () => ({ pdfSocket: {} }));
vi.mock('@/js/workflow/nodes/base-node', () => ({
BaseWorkflowNode: class {
addInput() {}
addOutput() {}
},
}));
const { flattenAnnotations, flattenFormsInDoc } =
await import('../js/utils/flatten-annotations.js');
const { removeUnreferencedObjects } =
await import('../js/utils/load-pdf-document.js');
const { sanitizePdf } = await import('../js/utils/sanitize.js');
const { FlattenNode } = await import('../js/workflow/nodes/flatten-node.js');
const SECRETS = [
'HIDDEN-VALUE',
'NOVIEW-VALUE',
'PASSWORD-SECRET',
'GROUP-SECRET',
];
async function buildFormDocument(): Promise<Uint8Array> {
const doc = await PDFDocument.create();
const page = doc.addPage([400, 400]);
const form = doc.getForm();
let y = 10;
const addField = (name: string, value: string, flags?: number) => {
const field = form.createTextField(name);
field.setText(value);
field.addToPage(page, { x: 10, y, width: 200, height: 20 });
y += 30;
if (flags !== undefined) {
const widgets = field.acroField.getWidgets();
widgets[widgets.length - 1].setFlags(flags);
}
return field;
};
addField('visible', 'VISIBLE-VALUE');
addField('hiddenField', 'HIDDEN-VALUE', 2);
addField('noViewField', 'NOVIEW-VALUE', 32 | 4);
addField('group.secret', 'GROUP-SECRET', 2);
const password = addField('pw', 'PASSWORD-SECRET');
password.enablePassword();
password.acroField.getWidgets()[0].dict.delete(PDFName.of('AP'));
const multi = addField('multi', 'MULTI-VALUE');
multi.addToPage(page, { x: 250, y: 10, width: 120, height: 20 });
multi.acroField.getWidgets()[1].setFlags(2);
const ctx = doc.context;
const appearance = (text: string) =>
ctx.register(
ctx.flateStream(`BT /Helv 10 Tf (${text}) Tj ET`, {
Type: 'XObject',
Subtype: 'Form',
BBox: [0, 0, 100, 20],
})
);
const annots = page.node.Annots() as PDFArray;
annots.push(
ctx.register(
ctx.obj({
Type: 'Annot',
Subtype: 'Square',
Rect: [10, 300, 110, 320],
F: 32 | 4,
AP: { N: appearance('NOVIEW-ANNOT') },
})
)
);
annots.push(
ctx.register(
ctx.obj({
Type: 'Annot',
Subtype: 'Square',
Rect: [10, 330, 110, 350],
AP: { N: appearance('SHOWN-ANNOT') },
})
)
);
return doc.save();
}
function hex(text: string): string {
return Buffer.from(text).toString('hex');
}
async function inspect(bytes: Uint8Array) {
const doc = await PDFDocument.load(bytes, { updateMetadata: false });
const streams: string[] = [];
for (const [, object] of doc.context.enumerateIndirectObjects()) {
if (object instanceof PDFRawStream) {
streams.push(
Buffer.from(decodePDFRawStream(object).decode()).toString('latin1')
);
}
}
const text = [Buffer.from(bytes).toString('latin1'), ...streams]
.join('\n')
.toLowerCase();
const count = (value: string) =>
text.split(value.toLowerCase()).length -
1 +
text.split(hex(value)).length -
1;
return { doc, count };
}
async function expectHiddenFieldsGone(bytes: Uint8Array) {
const { doc, count } = await inspect(bytes);
for (const secret of SECRETS) expect(count(secret)).toBe(0);
expect(count('VISIBLE-VALUE')).toBe(1);
expect(count('MULTI-VALUE')).toBe(1);
const acroForm = doc.catalog.lookup(PDFName.of('AcroForm'));
const fields =
acroForm instanceof PDFDict
? acroForm.lookup(PDFName.of('Fields'), PDFArray)
: undefined;
expect(fields?.size() ?? 0).toBe(0);
return count;
}
describe('flattenFormsInDoc', () => {
it('does not draw hidden, no-view or password fields', async () => {
const input = await buildFormDocument();
const before = await inspect(input);
for (const secret of SECRETS)
expect(before.count(secret)).toBeGreaterThan(0);
const doc = await PDFDocument.load(input);
flattenFormsInDoc(doc);
flattenAnnotations(doc);
const bytes = await removeUnreferencedObjects(await doc.save());
const count = await expectHiddenFieldsGone(bytes);
expect(count('NOVIEW-ANNOT')).toBe(0);
expect(count('SHOWN-ANNOT')).toBe(1);
});
it('is used by the sanitize flatten option', async () => {
const { bytes } = await sanitizePdf(await buildFormDocument(), {
flattenForms: true,
removeMetadata: false,
removeAnnotations: false,
removeJavascript: false,
removeEmbeddedFiles: false,
removeLayers: false,
removeLinks: false,
removeStructureTree: false,
removeMarkInfo: false,
removeFonts: false,
});
await expectHiddenFieldsGone(bytes);
});
it('is used by the workflow flatten node', async () => {
const node = new FlattenNode();
const result = await node.data({
pdf: [
{
type: 'pdf',
document: await PDFDocument.create(),
bytes: await buildFormDocument(),
filename: 'form.pdf',
},
],
});
const output = result.pdf;
if (output.type !== 'pdf') throw new Error('Expected a single PDF');
const count = await expectHiddenFieldsGone(output.bytes);
expect(count('NOVIEW-ANNOT')).toBe(0);
});
});
+57 -1
View File
@@ -184,7 +184,7 @@ describe('redactDocumentData', () => {
SECRET.toLowerCase()
);
const output = await redactDocumentData(input, secret());
const output = await redactDocumentData(input, [secret()]);
const text = await allText(output);
expect(text.toLowerCase()).not.toContain(SECRET.toLowerCase());
@@ -200,6 +200,62 @@ describe('redactDocumentData', () => {
expect(result.getPages()[0].node.get(PDFName.of('Thumb'))).toBeUndefined();
});
it('keeps dates intact when the redacted text is short', async () => {
const doc = await PDFDocument.create();
doc.addPage();
const created = new Date(Date.UTC(2026, 0, 2, 3, 4, 5));
doc.setCreationDate(created);
doc.setModificationDate(created);
doc.setTitle('Invoice 20 of 2026');
const output = await redactDocumentData(await doc.save(), ['20']);
const result = await PDFDocument.load(output, { updateMetadata: false });
expect(result.getCreationDate()?.toISOString()).toBe(created.toISOString());
expect(Number.isNaN(result.getModificationDate()?.getTime())).toBe(false);
expect(result.getTitle()).toBe('Invoice of 26');
});
it('removes the text from signature fields but keeps the signature value', async () => {
const doc = await PDFDocument.create();
doc.addPage();
const signatureValue = PDFHexString.of('3082ABCDEF01');
const signature = doc.context.register(
doc.context.obj({
Type: 'Sig',
Name: PDFString.of(`Signed by ${SECRET}`),
Reason: PDFString.of(`Approved by ${SECRET}`),
Contents: signatureValue,
})
);
doc.catalog.set(PDFName.of('TestSig'), signature);
const output = await redactDocumentData(await doc.save(), [SECRET]);
const result = await PDFDocument.load(output, { updateMetadata: false });
const saved = result.catalog.lookup(PDFName.of('TestSig'), PDFDict);
expect(saved.lookup(PDFName.of('Name'), PDFHexString).decodeText()).toBe(
'Signed by '
);
expect(saved.lookup(PDFName.of('Reason'), PDFHexString).decodeText()).toBe(
'Approved by '
);
expect(
Array.from(saved.lookup(PDFName.of('Contents'), PDFHexString).asBytes())
).toEqual(Array.from(signatureValue.asBytes()));
});
it('removes several terms in one pass, longest match first', async () => {
const doc = await PDFDocument.create();
doc.addPage();
doc.setTitle('Report by John Smith for Acme and John');
doc.setSubject('acme corp');
const output = await redactDocumentData(await doc.save(), [
'John',
'John Smith',
'Acme',
]);
const result = await PDFDocument.load(output, { updateMetadata: false });
expect(result.getTitle()).toBe('Report by for and ');
expect(result.getSubject()).toBe(' corp');
});
it('only drops thumbnails and leftovers when no search text is given', async () => {
const input = await buildDocument();
const output = await redactDocumentData(input);
+662
View File
@@ -0,0 +1,662 @@
import { describe, it, expect, vi } from 'vitest';
import { readFileSync } from 'fs';
import { resolve } from 'path';
import {
PDFArray,
PDFDict,
PDFDocument,
PDFHexString,
PDFName,
PDFObject,
PDFRawStream,
PDFRef,
PDFString,
decodePDFRawStream,
} from 'pdf-lib';
vi.mock('../js/utils/helpers.js', async () => {
const { default: createModule } = await import('@neslinesli93/qpdf-wasm');
const wasmPath = resolve(
'node_modules/@neslinesli93/qpdf-wasm/dist/qpdf.wasm'
);
const options = {
locateFile: () => wasmPath,
wasmBinary: readFileSync(wasmPath),
print: () => {},
printErr: () => {},
};
const qpdf = await createModule(options);
return { initializeQpdf: async () => qpdf };
});
const { sanitizePdf, defaultSanitizeOptions } =
await import('../js/utils/sanitize.js');
const { parseContentOperations } =
await import('../js/utils/pdf-content-tokens.js');
const NONE = {
flattenForms: false,
removeMetadata: false,
removeAnnotations: false,
removeJavascript: false,
removeEmbeddedFiles: false,
removeLayers: false,
removeLinks: false,
removeStructureTree: false,
removeMarkInfo: false,
removeFonts: false,
};
const latin1 = (bytes: Uint8Array) => Buffer.from(bytes).toString('latin1');
function collectText(value: PDFObject | undefined, out: string[]) {
if (value instanceof PDFString || value instanceof PDFHexString) {
out.push(value.decodeText());
} else if (value instanceof PDFDict) {
for (const [key, v] of value.entries()) {
out.push(key.decodeText());
collectText(v, out);
}
} else if (value instanceof PDFArray) {
for (const v of value.asArray()) collectText(v, out);
}
}
async function allText(bytes: Uint8Array): Promise<string> {
const doc = await PDFDocument.load(bytes, { updateMetadata: false });
const out: string[] = [latin1(bytes)];
for (const [, object] of doc.context.enumerateIndirectObjects()) {
if (object instanceof PDFRawStream) {
collectText(object.dict, out);
out.push(latin1(decodePDFRawStream(object).decode()));
} else {
collectText(object, out);
}
}
return out.join('\n');
}
async function pageContent(doc: PDFDocument): Promise<string> {
const contents = doc.getPages()[0].node.Contents();
const streams =
contents instanceof PDFArray ? contents.asArray() : [contents];
return streams
.map((item) => doc.context.lookup(item))
.map((stream) =>
stream instanceof PDFRawStream
? latin1(decodePDFRawStream(stream).decode())
: ''
)
.join('\n');
}
function lookupDict(doc: PDFDocument, value: PDFObject | undefined) {
const result = doc.context.lookup(value);
return result instanceof PDFDict ? result : undefined;
}
async function buildActionDocument(): Promise<Uint8Array> {
const doc = await PDFDocument.create();
const page = doc.addPage([300, 300]);
const ctx = doc.context;
const outlines = ctx.nextRef();
const first = ctx.nextRef();
const second = ctx.nextRef();
ctx.assign(
first,
ctx.obj({
Title: PDFString.of('Chapter one'),
Parent: outlines,
Next: second,
A: ctx.register(
ctx.obj({
S: 'JavaScript',
JS: PDFString.of("app.alert('outline-js')"),
})
),
})
);
ctx.assign(
second,
ctx.obj({
Title: PDFString.of('Chapter two'),
Parent: outlines,
Prev: first,
A: ctx.obj({
S: 'Launch',
F: PDFString.of('launch-calc.exe'),
}),
})
);
ctx.assign(
outlines,
ctx.obj({ Type: 'Outlines', First: first, Last: second, Count: 2 })
);
doc.catalog.set(PDFName.of('Outlines'), outlines);
doc.catalog.set(
PDFName.of('OpenAction'),
ctx.obj([page.ref, PDFName.of('Fit')])
);
const nextJs = ctx.register(
ctx.obj({ S: 'JavaScript', JS: PDFString.of("app.alert('next-js')") })
);
const link = ctx.register(
ctx.obj({
Type: 'Annot',
Subtype: 'Link',
Rect: [10, 10, 100, 30],
A: ctx.obj({
S: 'URI',
URI: PDFString.of('https://example.com/keep'),
Next: nextJs,
}),
})
);
const chained = ctx.register(
ctx.obj({
Type: 'Annot',
Subtype: 'Link',
Rect: [10, 40, 100, 60],
A: ctx.obj({
S: 'SubmitForm',
F: PDFString.of('https://submit-url.example'),
Next: ctx.obj({
S: 'URI',
URI: PDFString.of('https://example.com/after-submit'),
}),
}),
})
);
const importData = ctx.register(
ctx.obj({
Type: 'Annot',
Subtype: 'Link',
Rect: [10, 70, 100, 90],
A: ctx.obj({ S: 'ImportData', F: PDFString.of('import-data.fdf') }),
})
);
const appearance = ctx.register(
ctx.flateStream('0 0 1 rg 0 0 80 20 re f', {
Type: 'XObject',
Subtype: 'Form',
BBox: [0, 0, 80, 20],
})
);
const parentField = ctx.nextRef();
const childField = ctx.register(
ctx.obj({
Type: 'Annot',
Subtype: 'Widget',
FT: 'Tx',
T: PDFString.of('child'),
Parent: parentField,
Rect: [10, 100, 90, 120],
P: page.ref,
AP: { N: appearance },
AA: {
K: ctx.obj({
S: 'JavaScript',
JS: PDFString.of("app.alert('nested-aa-js')"),
}),
},
})
);
ctx.assign(
parentField,
ctx.obj({ T: PDFString.of('parent'), Kids: [childField] })
);
const xfa = ctx.register(
ctx.flateStream('<xdp:xdp><script>xfa-script</script></xdp:xdp>')
);
doc.catalog.set(
PDFName.of('AcroForm'),
ctx.obj({ Fields: [parentField], XFA: xfa })
);
page.node.set(
PDFName.of('Annots'),
ctx.obj([link, chained, importData, childField])
);
return doc.save();
}
async function buildMetadataDocument(): Promise<Uint8Array> {
const doc = await PDFDocument.create();
const page = doc.addPage([300, 300]);
const ctx = doc.context;
const xmp = (text: string) =>
ctx.register(
ctx.flateStream(`<x:xmpmeta>${text}</x:xmpmeta>`, {
Type: 'Metadata',
Subtype: 'XML',
})
);
const image = ctx.register(
ctx.stream(new Uint8Array([0, 128, 255, 64]), {
Type: 'XObject',
Subtype: 'Image',
Width: 2,
Height: 2,
ColorSpace: 'DeviceGray',
BitsPerComponent: 8,
Metadata: xmp('image-xmp-secret'),
})
);
const form = ctx.register(
ctx.flateStream('0 0 1 rg 0 0 10 10 re f', {
Type: 'XObject',
Subtype: 'Form',
BBox: [0, 0, 10, 10],
Metadata: xmp('form-xmp-secret'),
PieceInfo: { App: { Private: PDFString.of('form-piece-secret') } },
})
);
page.node.set(
PDFName.of('Resources'),
ctx.obj({ XObject: { Im1: image, Fm1: form } })
);
page.node.set(
PDFName.of('Contents'),
ctx.register(ctx.flateStream('q 100 0 0 100 0 0 cm /Im1 Do Q q /Fm1 Do Q'))
);
page.node.set(PDFName.of('Metadata'), xmp('page-xmp-secret'));
page.node.set(
PDFName.of('PieceInfo'),
ctx.obj({ App: { Private: PDFString.of('page-piece-secret') } })
);
page.node.set(
PDFName.of('Thumb'),
ctx.register(
ctx.flateStream('thumb-secret-pixels', {
Width: 1,
Height: 1,
ColorSpace: 'DeviceGray',
BitsPerComponent: 8,
})
)
);
doc.catalog.set(
PDFName.of('PieceInfo'),
ctx.obj({ App: { Private: PDFString.of('catalog-piece-secret') } })
);
doc.catalog.set(PDFName.of('Metadata'), xmp('catalog-xmp-secret'));
return doc.save();
}
const PAGE_CONTENT = [
'q',
'/OC /MCon BDC 0 0 1 rg 10 10 50 50 re f',
'BT /F1 12 Tf 20 200 Td (ON-LAYER-TEXT) Tj ET',
'BI /W 4 /H 1 /BPC 8 /CS /G ID \x20EI\x80 EI',
'EMC',
'/OC /MCoff BDC 1 0 0 rg 100 100 m 200 200 l S',
'q 0 0 10 10 re W n Q',
'BT /F1 12 Tf 20 220 Td (OFF-LAYER-TEXT EMC (inner) \\) tail) Tj',
'<4f46462d484558> Tj % EMC in a comment',
'/Span <</ActualText (OFF-ACTUAL)>> BDC (x) Tj EMC ET',
'BI /W 2 /H 1 /BPC 8 /CS /G ID \x10\xff EI',
'/ImOff Do',
'EMC',
'/OC /MCmd BDC BT /F1 12 Tf 20 240 Td (OCMD-HIDDEN-TEXT) Tj ET EMC',
'/OC /MCve BDC BT /F1 12 Tf 20 260 Td (VE-HIDDEN-TEXT) Tj ET EMC',
'/FmOff Do',
'/FmOn Do',
'/ImOff Do',
'Q',
].join('\n');
async function buildLayerDocument(content = PAGE_CONTENT): Promise<Uint8Array> {
const doc = await PDFDocument.create();
const page = doc.addPage([300, 300]);
const ctx = doc.context;
const on = ctx.register(ctx.obj({ Type: 'OCG', Name: PDFString.of('On') }));
const off = ctx.register(ctx.obj({ Type: 'OCG', Name: PDFString.of('Off') }));
const ocmd = ctx.register(
ctx.obj({ Type: 'OCMD', OCGs: [on, off], P: 'AllOn' })
);
const ocmdVe = ctx.register(
ctx.obj({
Type: 'OCMD',
OCGs: [on],
VE: [PDFName.of('And'), on, [PDFName.of('Not'), on]],
})
);
doc.catalog.set(
PDFName.of('OCProperties'),
ctx.obj({ OCGs: [on, off], D: { Order: [on, off], ON: [on], OFF: [off] } })
);
const font = ctx.register(
ctx.obj({ Type: 'Font', Subtype: 'Type1', BaseFont: 'Helvetica' })
);
const formOff = ctx.register(
ctx.flateStream('BT /F1 12 Tf (FORM-OFF-TEXT) Tj ET', {
Type: 'XObject',
Subtype: 'Form',
BBox: [0, 0, 300, 300],
OC: off,
Resources: { Font: { F1: font } },
})
);
const formOn = ctx.register(
ctx.flateStream(
'BT /F1 12 Tf (FORM-ON-TEXT) Tj ET /OC /P0 BDC BT /F1 12 Tf (FORM-NESTED-OFF) Tj ET EMC',
{
Type: 'XObject',
Subtype: 'Form',
BBox: [0, 0, 300, 300],
OC: on,
Resources: { Font: { F1: font }, Properties: { P0: off } },
}
)
);
const imageOff = ctx.register(
ctx.stream('IMAGE-OFF-PIXELS', {
Type: 'XObject',
Subtype: 'Image',
Width: 4,
Height: 4,
ColorSpace: 'DeviceGray',
BitsPerComponent: 8,
OC: off,
})
);
page.node.set(
PDFName.of('Resources'),
ctx.obj({
Font: { F1: font },
Properties: { MCon: on, MCoff: off, MCmd: ocmd, MCve: ocmdVe },
XObject: { FmOff: formOff, FmOn: formOn, ImOff: imageOff },
})
);
page.node.set(PDFName.of('Contents'), ctx.register(ctx.flateStream(content)));
const square = (text: string, oc: PDFRef) =>
ctx.register(
ctx.obj({
Type: 'Annot',
Subtype: 'Square',
Rect: [10, 10, 50, 50],
Contents: PDFString.of(text),
OC: oc,
})
);
page.node.set(
PDFName.of('Annots'),
ctx.obj([square('annot-off-layer', off), square('annot-on-layer', on)])
);
return doc.save();
}
describe('sanitize remove JavaScript', () => {
it('removes every reachable script, launch, submit and XFA but keeps links', async () => {
const input = await buildActionDocument();
const before = await allText(input);
for (const secret of [
'outline-js',
'launch-calc.exe',
'next-js',
'nested-aa-js',
'xfa-script',
'submit-url',
'import-data.fdf',
]) {
expect(before).toContain(secret);
}
const { bytes } = await sanitizePdf(input, {
...NONE,
removeJavascript: true,
});
const after = await allText(bytes);
for (const secret of [
'outline-js',
'launch-calc.exe',
'next-js',
'nested-aa-js',
'xfa-script',
'submit-url',
'import-data.fdf',
'JavaScript',
'XFA',
]) {
expect(after).not.toContain(secret);
}
expect(after).toContain('https://example.com/keep');
expect(after).toContain('https://example.com/after-submit');
expect(after).toContain('Chapter one');
expect(after).toContain('Chapter two');
const doc = await PDFDocument.load(bytes, { updateMetadata: false });
const annots = doc.getPages()[0].node.Annots()!;
const linkAction = lookupDict(
doc,
lookupDict(doc, annots.get(0))?.get(PDFName.of('A'))
);
expect(linkAction?.get(PDFName.of('S'))).toBe(PDFName.of('URI'));
expect(linkAction?.has(PDFName.of('Next'))).toBe(false);
const promoted = lookupDict(
doc,
lookupDict(doc, annots.get(1))?.get(PDFName.of('A'))
);
expect(promoted?.get(PDFName.of('S'))).toBe(PDFName.of('URI'));
expect(doc.catalog.get(PDFName.of('OpenAction'))).toBeInstanceOf(PDFArray);
});
});
describe('sanitize remove metadata', () => {
it('drops page, XObject and catalog metadata, piece info and thumbnails', async () => {
const input = await buildMetadataDocument();
const secrets = [
'image-xmp-secret',
'form-xmp-secret',
'form-piece-secret',
'page-xmp-secret',
'page-piece-secret',
'thumb-secret-pixels',
'catalog-piece-secret',
'catalog-xmp-secret',
];
const before = await allText(input);
for (const secret of secrets) expect(before).toContain(secret);
const { bytes } = await sanitizePdf(input, {
...NONE,
removeMetadata: true,
});
const after = await allText(bytes);
for (const secret of secrets) expect(after).not.toContain(secret);
const doc = await PDFDocument.load(bytes, { updateMetadata: false });
const xObjects = doc
.getPages()[0]
.node.Resources()
?.lookup(PDFName.of('XObject'), PDFDict);
expect(xObjects?.has(PDFName.of('Im1'))).toBe(true);
expect(xObjects?.has(PDFName.of('Fm1'))).toBe(true);
});
});
describe('sanitize remove layers', () => {
it('removes content in hidden layers and keeps visible layers', async () => {
const input = await buildLayerDocument();
const { bytes } = await sanitizePdf(input, {
...NONE,
removeLayers: true,
});
const doc = await PDFDocument.load(bytes, { updateMetadata: false });
expect(doc.catalog.has(PDFName.of('OCProperties'))).toBe(false);
const content = await pageContent(doc);
expect(content).toContain('(ON-LAYER-TEXT) Tj');
expect(content).toContain('0 0 1 rg 10 10 50 50 re f');
expect(content).toContain('BI /W 4 /H 1 /BPC 8 /CS /G ID \x20EI\x80 EI');
expect(content).toContain('/FmOn Do');
expect(content).toMatch(/0 0 10 10 re\s+W\s+n/);
for (const hidden of [
'OFF-LAYER-TEXT',
'inner',
'tail',
'4f46462d484558',
'OFF-ACTUAL',
'OCMD-HIDDEN-TEXT',
'VE-HIDDEN-TEXT',
'200 200 l',
'/FmOff',
'/ImOff',
'\x10\xff',
]) {
expect(content).not.toContain(hidden);
}
expect(content.match(/\bq\b/g)?.length).toBe(
content.match(/\bQ\b/g)?.length
);
const text = await allText(bytes);
expect(text).toContain('FORM-ON-TEXT');
expect(text).toContain('annot-on-layer');
for (const hidden of [
'FORM-OFF-TEXT',
'FORM-NESTED-OFF',
'IMAGE-OFF-PIXELS',
'annot-off-layer',
'OCMD',
]) {
expect(text).not.toContain(hidden);
}
expect(doc.getPages()[0].node.Annots()?.size()).toBe(1);
});
it('removes hidden layers with the default options', async () => {
const { bytes } = await sanitizePdf(
await buildLayerDocument(),
defaultSanitizeOptions
);
const doc = await PDFDocument.load(bytes, { updateMetadata: false });
expect(doc.catalog.has(PDFName.of('OCProperties'))).toBe(false);
const text = await allText(bytes);
expect(text).toContain('ON-LAYER-TEXT');
expect(text).toContain('FORM-ON-TEXT');
for (const hidden of [
'OFF-LAYER-TEXT',
'OCMD-HIDDEN-TEXT',
'FORM-OFF-TEXT',
'FORM-NESTED-OFF',
'IMAGE-OFF-PIXELS',
]) {
expect(text).not.toContain(hidden);
}
});
it('keeps the layers when hidden content cannot be parsed safely', async () => {
const input = await buildLayerDocument(
'BT /F1 12 Tf /OC /MCoff BDC (UNPARSABLE-OFF Tj EMC ET'
);
const { bytes, skipped } = await sanitizePdf(input, {
...NONE,
removeLayers: true,
});
expect(skipped).toEqual(['remove layers']);
const doc = await PDFDocument.load(bytes, { updateMetadata: false });
expect(doc.catalog.has(PDFName.of('OCProperties'))).toBe(true);
const properties = doc
.getPages()[0]
.node.Resources()
?.lookup(PDFName.of('Properties'), PDFDict);
expect(properties?.has(PDFName.of('MCoff'))).toBe(true);
});
});
describe('sanitize remove layers edge cases', () => {
it('uses page resources for forms without their own and ignores missing content streams', async () => {
const doc = await PDFDocument.create();
const page = doc.addPage([300, 300]);
const ctx = doc.context;
const off = ctx.register(
ctx.obj({ Type: 'OCG', Name: PDFString.of('Off') })
);
doc.catalog.set(
PDFName.of('OCProperties'),
ctx.obj({ OCGs: [off], D: { OFF: [off] } })
);
const font = ctx.register(
ctx.obj({ Type: 'Font', Subtype: 'Type1', BaseFont: 'Helvetica' })
);
const bareForm = ctx.register(
ctx.flateStream(
'BT /F1 12 Tf (FORM-VISIBLE-TEXT) Tj ET /OC /MCoff BDC BT /F1 12 Tf (INHERITED-OFF-TEXT) Tj ET EMC',
{ Type: 'XObject', Subtype: 'Form', BBox: [0, 0, 300, 300] }
)
);
page.node.set(
PDFName.of('Resources'),
ctx.obj({
Font: { F1: font },
Properties: { MCoff: off },
XObject: { FmBare: bareForm },
})
);
page.node.set(
PDFName.of('Contents'),
ctx.obj([ctx.register(ctx.flateStream('/FmBare Do')), ctx.nextRef()])
);
const { bytes, skipped } = await sanitizePdf(await doc.save(), {
...NONE,
removeLayers: true,
});
expect(skipped).toEqual([]);
const text = await allText(bytes);
expect(text).not.toContain('INHERITED-OFF-TEXT');
expect(text).toContain('FORM-VISIBLE-TEXT');
const result = await PDFDocument.load(bytes, { updateMetadata: false });
expect(result.catalog.has(PDFName.of('OCProperties'))).toBe(false);
});
});
describe('content stream tokenizer', () => {
it('handles strings, comments, dictionaries and inline images', () => {
const source =
'/OC /L1 BDC (a\\) EMC (b)) Tj <45 4d 43> Tj % EMC\n' +
'[(x) -20 (EMC)] TJ /P <</MCID 3 /Alt (EMC)>> BDC EMC ' +
'BI /W 4 /H 1 /CS /G /BPC 8 ID \x00EI\x7f EI EMC ' +
'BI /W 4 /H 1 /CS /G /BPC 8 /F /AHx ID 00ff00ff> EI EMC';
const bytes = new Uint8Array([...source].map((c) => c.charCodeAt(0)));
const ops = parseContentOperations(bytes);
expect(ops.map((op) => op.operator)).toEqual([
'BDC',
'Tj',
'Tj',
'TJ',
'BDC',
'EMC',
'BI',
'EMC',
'BI',
'EMC',
]);
expect(ops[4].operands[1].kind).toBe('dict');
});
it('rejects content it cannot tokenize', () => {
const encode = (text: string) =>
new Uint8Array([...text].map((c) => c.charCodeAt(0)));
expect(() => parseContentOperations(encode('(open Tj'))).toThrow();
expect(() => parseContentOperations(encode('<</A 1 BDC'))).toThrow();
expect(() => parseContentOperations(encode('BI /W 1 ID \x00'))).toThrow();
expect(() => parseContentOperations(encode('<zz> Tj'))).toThrow();
expect(() =>
parseContentOperations(encode('BI /F /Fl ID x EI\x00\x01 EI'))
).toThrow();
expect(() =>
parseContentOperations(encode('BI /W 9 /H 1 /CS /G /BPC 8 ID ab EI'))
).toThrow();
});
});
+87
View File
@@ -0,0 +1,87 @@
import fs from 'node:fs';
import path from 'node:path';
import { describe, it, expect } from 'vitest';
import { serializeJsonLd } from '../../scripts/json-ld.mjs';
import { sanitizeTranslationHtml } from '../js/utils/translation-html';
const LOCALES_DIR = path.resolve(__dirname, '../../public/locales');
const PAYLOAD =
'Merge</script><script src=https://cdn.jsdelivr.net/gh/a/b/c.js></script>';
function localeStrings(): string[] {
const out: string[] = [];
const visit = (value: unknown) => {
if (typeof value === 'string') out.push(value);
else if (value && typeof value === 'object')
Object.values(value).forEach(visit);
};
for (const lang of fs.readdirSync(LOCALES_DIR)) {
const dir = path.join(LOCALES_DIR, lang);
if (!fs.statSync(dir).isDirectory()) continue;
for (const file of fs.readdirSync(dir).filter((f) => f.endsWith('.json'))) {
visit(JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8')));
}
}
return out;
}
function parse(html: string): Document {
return new DOMParser().parseFromString(html, 'text/html');
}
function scriptsAfterRoundTrip(jsonLd: string): HTMLScriptElement[] {
const doc = parse('<!doctype html><html><head></head><body></body></html>');
const script = doc.createElement('script');
script.setAttribute('type', 'application/ld+json');
script.textContent = jsonLd;
doc.body.appendChild(script);
return Array.from(
parse(doc.documentElement.outerHTML).querySelectorAll('script')
);
}
describe('JSON-LD serialization', () => {
it('raw JSON.stringify lets a translated name break out of the script', () => {
expect(
scriptsAfterRoundTrip(JSON.stringify({ name: PAYLOAD })).length
).toBeGreaterThan(1);
});
it('serializeJsonLd keeps the payload inside one ld+json script', () => {
const data = { name: `${PAYLOAD}\u2028\u2029<!--` };
const scripts = scriptsAfterRoundTrip(serializeJsonLd(data));
expect(scripts).toHaveLength(1);
expect(scripts[0].textContent).not.toMatch(/[<\u2028\u2029]/);
expect(JSON.parse(scripts[0].textContent || '')).toEqual(data);
});
});
describe('sanitizeTranslationHtml', () => {
it('keeps the allowed formatting tags', () => {
expect(
sanitizeTranslationHtml('a<br>b <strong>c</strong> <em>d</em>')
).toBe('a<br>b <strong>c</strong> <em>d</em>');
});
it('strips scripts, event handlers, links and attributes', () => {
expect(sanitizeTranslationHtml(PAYLOAD)).toBe('Merge');
expect(sanitizeTranslationHtml('<img src=x onerror=alert(1)>x')).toBe('x');
expect(
sanitizeTranslationHtml('<a href="javascript:alert(1)">go</a>')
).toBe('go');
expect(
sanitizeTranslationHtml('<strong onclick="alert(1)" class="x">y</strong>')
).toBe('<strong>y</strong>');
expect(
sanitizeTranslationHtml('<iframe srcdoc="<script>alert(1)</script>">')
).toBe('');
});
it('leaves every shipped locale string intact', () => {
const withMarkup = localeStrings().filter((s) => s.includes('<'));
expect(withMarkup.length).toBeGreaterThan(0);
for (const value of withMarkup) {
expect(sanitizeTranslationHtml(value)).toBe(parse(value).body.innerHTML);
}
});
});
@@ -0,0 +1,221 @@
import { describe, it, expect } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import forge from 'node-forge';
import { signPdf, parsePemFiles } from '@/js/logic/digital-sign-pdf';
import {
countSignatures,
extractSignatures,
validatePdfSignatures,
} from '@/js/logic/validate-signature-pdf';
import type { CertificateData } from '@/types';
const RANGE_PLACEHOLDER = '{{RANGE}}';
const RANGE_WIDTH = 64;
const HEX_LENGTH = 8192;
const SIG_HEAD =
'%PDF-1.7\n1 0 obj\n<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /adbe.pkcs7.detached ';
const SIG_TAIL =
' /M (D:20260101120000Z) >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n';
const keys = forge.pki.rsa.generateKeyPair(1024);
const cert = forge.pki.createCertificate();
cert.publicKey = keys.publicKey;
cert.serialNumber = '01';
cert.validity.notBefore = new Date(Date.now() - 86400000);
cert.validity.notAfter = new Date(Date.now() + 86400000 * 365);
cert.setSubject([{ name: 'commonName', value: 'Test Signer' }]);
cert.setIssuer([{ name: 'commonName', value: 'Test Signer' }]);
cert.sign(keys.privateKey, forge.md.sha256.create());
const certificateData: CertificateData = parsePemFiles(
forge.pki.certificateToPem(cert),
forge.pki.privateKeyToPem(keys.privateKey)
);
const samplePdf = new Uint8Array(
fs.readFileSync(path.resolve(__dirname, './fixtures/sample.pdf'))
);
function signedCmsHex(content: string): string {
const p7 = forge.pkcs7.createSignedData();
p7.content = forge.util.createBuffer(content);
p7.addCertificate(cert);
p7.addSigner({
key: keys.privateKey,
certificate: cert,
digestAlgorithm: forge.pki.oids.sha256,
authenticatedAttributes: [
{ type: forge.pki.oids.contentType, value: forge.pki.oids.data },
{ type: forge.pki.oids.messageDigest },
],
});
p7.sign({ detached: true });
return forge.util.bytesToHex(forge.asn1.toDer(p7.toAsn1()).getBytes());
}
function buildSignedPdf(layout: {
prefix?: string;
head: string;
gap: (hex: string) => string;
tail: string;
}): Uint8Array {
const prefix = layout.prefix ?? '';
const headLength =
layout.head.length - RANGE_PLACEHOLDER.length + RANGE_WIDTH;
const gapLength = layout.gap('0'.repeat(HEX_LENGTH)).length;
const byteRange = [
prefix.length,
headLength,
prefix.length + headLength + gapLength,
layout.tail.length,
];
const head = layout.head.replace(
RANGE_PLACEHOLDER,
`/ByteRange [${byteRange.join(' ')}]`.padEnd(RANGE_WIDTH)
);
const hex = signedCmsHex(head + layout.tail).padEnd(HEX_LENGTH, '0');
return new TextEncoder().encode(
prefix + head + layout.gap(hex) + layout.tail
);
}
function appendIncrementalUpdate(pdfBytes: Uint8Array): Uint8Array {
const update = new TextEncoder().encode(
'\n2 0 obj\n<< /Type /Annot /Subtype /Text /Contents (Added later) >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n'
);
const updated = new Uint8Array(pdfBytes.length + update.length);
updated.set(pdfBytes, 0);
updated.set(update, pdfBytes.length);
return updated;
}
describe('validatePdfSignatures extraction and coverage', () => {
it('validates a signature made by the app signer with full coverage', async () => {
const signed = await signPdf(samplePdf, certificateData, {
signatureInfo: {
reason: 'Approved (final) é',
location: '東京',
contactInfo: 'signer@example.com',
},
});
const results = await validatePdfSignatures(signed);
expect(countSignatures(signed)).toBe(1);
expect(results).toHaveLength(1);
expect(results[0].cryptoVerified).toBe(true);
expect(results[0].coverageStatus).toBe('full');
expect(results[0].isValid).toBe(true);
expect(results[0].errorMessage).toBeUndefined();
expect(results[0].signerName).toBe('Test Signer');
expect(results[0].reason).toBe('Approved (final) é');
expect(results[0].location).toBe('東京');
expect(results[0].contactInfo).toBe('signer@example.com');
expect(results[0].signatureDate).toBeInstanceOf(Date);
});
it('validates a hand-assembled signature whose gap is exactly its /Contents', async () => {
const pdfBytes = buildSignedPdf({
head: `${SIG_HEAD}${RANGE_PLACEHOLDER} /Prop_Build << /App << /Name /Builder >> >> /Name (Real Signer) /Contents `,
gap: (hex) => `<${hex}>`,
tail: SIG_TAIL,
});
const [result] = await validatePdfSignatures(pdfBytes);
expect(result.isValid).toBe(true);
expect(result.coverageStatus).toBe('full');
expect(result.signatureDate).toBeInstanceOf(Date);
expect(extractSignatures(pdfBytes)[0].name).toBe('Real Signer');
});
it('reports an intact signature as partial when an incremental update follows', async () => {
const signed = await signPdf(samplePdf, certificateData, {
signatureInfo: { reason: 'Original' },
});
const [result] = await validatePdfSignatures(
appendIncrementalUpdate(signed)
);
expect(result.cryptoVerified).toBe(true);
expect(result.cryptoVerificationStatus).toBe('verified');
expect(result.coverageStatus).toBe('partial');
expect(result.isValid).toBe(false);
expect(result.errorMessage).toBeUndefined();
expect(result.reason).toBe('Original');
});
it('keeps the earlier signature partial and the latest one full in a multi-signed file', async () => {
const first = await signPdf(samplePdf, certificateData, {
signatureInfo: { reason: 'First' },
});
const second = await signPdf(first, certificateData, {
signatureInfo: { reason: 'Second' },
});
const results = await validatePdfSignatures(second);
expect(countSignatures(second)).toBe(2);
expect(results.map((r) => r.reason)).toEqual(['First', 'Second']);
expect(results.map((r) => r.coverageStatus)).toEqual(['partial', 'full']);
expect(results.map((r) => r.cryptoVerified)).toEqual([true, true]);
expect(results.map((r) => r.errorMessage)).toEqual([undefined, undefined]);
expect(results[1].isValid).toBe(true);
});
it('rejects a decoy /Contents whose gap holds a different signature', async () => {
const pdfBytes = buildSignedPdf({
head: `${SIG_HEAD}/Contents <> ${RANGE_PLACEHOLDER} /Decoy /Contents `,
gap: (hex) => `<${hex}>`,
tail: SIG_TAIL,
});
const results = await validatePdfSignatures(pdfBytes);
expect(results).toHaveLength(1);
expect(results[0].isValid).toBe(false);
expect(results[0].cryptoVerified).toBe(false);
expect(results[0].coverageStatus).toBe('unknown');
expect(results[0].errorMessage).toMatch(
/not the \/Contents of this signature/
);
});
it('rejects a ByteRange that does not start at byte 0', async () => {
const pdfBytes = buildSignedPdf({
prefix:
'%PDF-1.7\n9 0 obj\n<< /Type /Catalog /Unsigned true >>\nendobj\n',
head: `${SIG_HEAD}${RANGE_PLACEHOLDER} /Contents `,
gap: (hex) => `<${hex}>`,
tail: SIG_TAIL,
});
const results = await validatePdfSignatures(pdfBytes);
expect(results).toHaveLength(1);
expect(results[0].isValid).toBe(false);
expect(results[0].cryptoVerified).toBe(false);
expect(results[0].errorMessage).toMatch(
/does not start at the beginning of the file/
);
});
it('rejects a gap that carries unsigned bytes after the hex signature', async () => {
const pdfBytes = buildSignedPdf({
head: `${SIG_HEAD}${RANGE_PLACEHOLDER} /Contents `,
gap: (hex) => `<${hex}> /Reason (Approved by the CEO)`,
tail: SIG_TAIL,
});
const results = await validatePdfSignatures(pdfBytes);
expect(results).toHaveLength(1);
expect(results[0].isValid).toBe(false);
expect(results[0].cryptoVerified).toBe(false);
expect(results[0].errorMessage).toMatch(
/not exactly a hex-encoded signature/
);
});
});
@@ -0,0 +1,306 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { createNodeByType, nodeRegistry } from '@/js/workflow/nodes/registry';
import type { BaseWorkflowNode } from '@/js/workflow/nodes/base-node';
import {
getControlSpec,
MAX_TEXT_CONTROL_LENGTH,
} from '@/js/workflow/nodes/control-specs';
import {
exportWorkflow,
getSavedTemplateNames,
loadWorkflow,
saveWorkflow,
} from '@/js/workflow/serialization';
vi.mock('heic2any', () => ({ default: vi.fn() }));
type Editor = Parameters<typeof loadWorkflow>[0];
type Area = Parameters<typeof loadWorkflow>[1];
const TEMPLATES_KEY = 'bento-pdf-workflow-templates';
function createFakeEditor(initial: BaseWorkflowNode[] = []) {
const nodes = new Map<string, BaseWorkflowNode>(
initial.map((node) => [node.id, node])
);
const editor = {
getNodes: () => [...nodes.values()],
getConnections: (): unknown[] => [],
getNode: (id: string) => nodes.get(id),
addNode: async (node: BaseWorkflowNode) => {
nodes.set(node.id, node);
return true;
},
removeNode: async (id: string) => nodes.delete(id),
removeConnection: async () => true,
addConnection: async () => true,
};
const area = {
nodeViews: new Map(),
translate: async () => true,
};
return {
nodes,
editor: editor as unknown as Editor,
area: area as unknown as Area,
};
}
function controlValue(node: BaseWorkflowNode, key: string): unknown {
const control = node.controls[key];
return control && 'value' in control ? control.value : undefined;
}
function setControl(node: BaseWorkflowNode, key: string, value: unknown) {
const control = node.controls[key];
if (!control || !('value' in control)) throw new Error(`missing ${key}`);
control.value = value;
}
function storeTemplate(name: string, json: string) {
localStorage.setItem(TEMPLATES_KEY, `{"${name}":${json}}`);
}
function workflowJson(
nodes: { type: string; controls: Record<string, unknown> }[]
): string {
return JSON.stringify({
version: 1,
nodes: nodes.map((node, index) => ({
id: `n${index}`,
type: node.type,
position: { x: 0, y: 0 },
controls: node.controls,
})),
connections: [],
});
}
async function loadNodes(json: string): Promise<BaseWorkflowNode[]> {
storeTemplate('imported', json);
const { editor, area, nodes } = createFakeEditor();
expect(await loadWorkflow(editor, area, 'imported')).toBe(true);
return [...nodes.values()];
}
function findNode(nodes: BaseWorkflowNode[], type: string): BaseWorkflowNode {
const node = nodes.find((candidate) => candidate.nodeType === type);
if (!node) throw new Error(`missing ${type}`);
return node;
}
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('workflow import validation', () => {
it('replaces select values outside the option list with the default', async () => {
const nodes = await loadNodes(
workflowJson([
{
type: 'RedactNode',
controls: { redactMode: 'Area', text: 'secret' },
},
{ type: 'NUpNode', controls: { pagesPerSheet: 7 } },
{ type: 'RotateNode', controls: { angle: '45' } },
{ type: 'CompressNode', controls: { algorithm: 'photon' } },
])
);
const redact = findNode(nodes, 'RedactNode');
expect(controlValue(redact, 'redactMode')).toBe('text');
expect(controlValue(redact, 'text')).toBe('secret');
expect(controlValue(findNode(nodes, 'NUpNode'), 'pagesPerSheet')).toBe(4);
expect(controlValue(findNode(nodes, 'RotateNode'), 'angle')).toBe('90');
expect(controlValue(findNode(nodes, 'CompressNode'), 'algorithm')).toBe(
'photon'
);
});
it('keeps only known multi-select values', async () => {
const nodes = await loadNodes(
workflowJson([
{ type: 'OCRNode', controls: { language: 'eng+../../x+fra+eng' } },
{ type: 'OCRNode', controls: { language: 'zz+../evil' } },
])
);
const [mixed, unknown] = nodes;
expect(controlValue(mixed, 'language')).toBe('eng+fra');
expect(controlValue(unknown, 'language')).toBe('eng');
});
it('clamps out-of-range numbers and resets non-finite ones', async () => {
const json = workflowJson([
{
type: 'WatermarkNode',
controls: { opacity: 5000, fontSize: 1, angle: 0 },
},
{ type: 'PosterizeNode', controls: { rows: 0, cols: 0 } },
{ type: 'RedactNode', controls: { x0: -50, x1: 0 } },
])
.replace('"angle":0', '"angle":-1e999')
.replace('"cols":0', '"cols":1e999')
.replace('"x1":0', '"x1":1e999');
const nodes = await loadNodes(json);
const watermark = findNode(nodes, 'WatermarkNode');
expect(controlValue(watermark, 'opacity')).toBe(100);
expect(controlValue(watermark, 'fontSize')).toBe(10);
expect(controlValue(watermark, 'angle')).toBe(-45);
const posterize = findNode(nodes, 'PosterizeNode');
expect(controlValue(posterize, 'rows')).toBe(1);
expect(controlValue(posterize, 'cols')).toBe(2);
const redact = findNode(nodes, 'RedactNode');
expect(controlValue(redact, 'x0')).toBe(0);
expect(controlValue(redact, 'x1')).toBe(200);
const scanner = createNodeByType('ScannerEffectNode');
expect(scanner?.sanitizeControlValue('resolution', Number.NaN)).toBe(150);
});
it('validates booleans, colors and text length', async () => {
const nodes = await loadNodes(
workflowJson([
{ type: 'SanitizeNode', controls: { removeJavascript: 'nope' } },
{ type: 'BackgroundColorNode', controls: { color: 'red;x' } },
{
type: 'EditMetadataNode',
controls: { title: 'a'.repeat(MAX_TEXT_CONTROL_LENGTH + 500) },
},
])
);
expect(
controlValue(findNode(nodes, 'SanitizeNode'), 'removeJavascript')
).toBe('true');
expect(controlValue(findNode(nodes, 'BackgroundColorNode'), 'color')).toBe(
'#ffffff'
);
expect(
String(controlValue(findNode(nodes, 'EditMetadataNode'), 'title'))
).toHaveLength(MAX_TEXT_CONTROL_LENGTH);
});
it('skips inherited property names used as node types', async () => {
const nodes = await loadNodes(
workflowJson([
{ type: 'constructor', controls: {} },
{ type: 'toString', controls: {} },
{ type: '__proto__', controls: {} },
{ type: 'hasOwnProperty', controls: {} },
{ type: 'RotateNode', controls: { constructor: 'x', angle: '180' } },
])
);
expect(nodes).toHaveLength(1);
expect(nodes[0].nodeType).toBe('RotateNode');
expect(controlValue(nodes[0], 'angle')).toBe('180');
expect(createNodeByType('constructor')).toBeNull();
expect(createNodeByType('toString')).toBeNull();
});
it('covers every registered node control with a valid spec', () => {
for (const type of Object.keys(nodeRegistry)) {
const node = createNodeByType(type);
expect(node).not.toBeNull();
if (!node) continue;
for (const key of Object.keys(node.controls)) {
const initial = controlValue(node, key);
const spec = getControlSpec(type, key);
if (/password/i.test(key)) {
expect(spec?.kind, `${type}.${key}`).toBe('password');
continue;
}
if (typeof initial === 'number') {
expect(['number', 'select'], `${type}.${key}`).toContain(spec?.kind);
expect(
node.sanitizeControlValue(key, Number.POSITIVE_INFINITY),
`${type}.${key}`
).toBe(initial);
}
expect(node.sanitizeControlValue(key, initial), `${type}.${key}`).toBe(
initial
);
}
}
});
});
describe('workflow password controls', () => {
function createSecretNodes(): BaseWorkflowNode[] {
const encrypt = createNodeByType('EncryptNode');
const decrypt = createNodeByType('DecryptNode');
if (!encrypt || !decrypt) throw new Error('missing nodes');
setControl(encrypt, 'userPassword', 'user-secret-1');
setControl(encrypt, 'ownerPassword', 'owner-secret-2');
setControl(decrypt, 'password', 'decrypt-secret-3');
return [encrypt, decrypt];
}
const secrets = ['user-secret-1', 'owner-secret-2', 'decrypt-secret-3'];
it('never writes passwords into saved templates', () => {
const { editor, area } = createFakeEditor(createSecretNodes());
saveWorkflow(editor, area, 'secure');
const stored = localStorage.getItem(TEMPLATES_KEY) ?? '';
for (const secret of secrets) expect(stored).not.toContain(secret);
expect(stored).toContain('"userPassword":""');
});
it('never writes passwords into exported workflow JSON', async () => {
const blobs: Blob[] = [];
vi.spyOn(URL, 'createObjectURL').mockImplementation((blob) => {
blobs.push(blob as Blob);
return 'blob:workflow';
});
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
const nodes = createSecretNodes();
const { editor, area } = createFakeEditor(nodes);
exportWorkflow(editor, area);
expect(blobs).toHaveLength(1);
const exported = await blobs[0].text();
for (const secret of secrets) expect(exported).not.toContain(secret);
expect(controlValue(nodes[0], 'userPassword')).toBe('user-secret-1');
});
it('drops passwords from imported workflows and legacy templates', async () => {
storeTemplate(
'legacy',
workflowJson([
{
type: 'EncryptNode',
controls: {
userPassword: 'user-secret-1',
ownerPassword: 'owner-secret-2',
},
},
{ type: 'DecryptNode', controls: { password: 'decrypt-secret-3' } },
])
);
expect(getSavedTemplateNames()).toEqual(['legacy']);
const stored = localStorage.getItem(TEMPLATES_KEY) ?? '';
for (const secret of secrets) expect(stored).not.toContain(secret);
const legacyJson = workflowJson([
{ type: 'EncryptNode', controls: { userPassword: 'user-secret-1' } },
]);
const nodes = await loadNodes(legacyJson);
expect(controlValue(nodes[0], 'userPassword')).toBe('');
expect(
createNodeByType('DecryptNode')?.sanitizeControlValue(
'password',
'decrypt-secret-3'
)
).toBe('');
});
});