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

This commit is contained in:
alam00000 committed 2026-10-19 14:45:00 +05:30
1 parent b0a186839a
commit edd959f345
62 files changed
+4813 -926

No files matched your search

+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: [] });
}