mirror of
https://github.com/alam00000/bentopdf.git
synced 2026-11-10 01:14:17 +01:00
fix(security): harden sanitize, flatten, redaction, signature validation, CSP, proxies and CI
This commit is contained in:
1 parent
b0a186839a
commit
edd959f345
62 files changed
+4813
-926
No files matched your search
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 })
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {};
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user