mirror of
https://github.com/alam00000/bentopdf.git
synced 2026-11-08 16:57:14 +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
@@ -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}"`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user