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

+155
View File
@@ -0,0 +1,155 @@
import fs from 'node:fs';
import path from 'node:path';
import { describe, it, expect } from 'vitest';
import {
buildSecurityHeaders,
DOCS_INLINE_SCRIPT_HASHES,
} from '../../scripts/generate-security-headers.mjs';
import { WasmProvider } from '../js/utils/wasm-provider';
const ROOT = path.resolve(__dirname, '../..');
const read = (file: string) => fs.readFileSync(path.join(ROOT, file), 'utf8');
const defaults = buildSecurityHeaders({});
function directive(policy: string, name: string): string[] {
const found = policy
.split(';')
.map((d) => d.trim().split(/\s+/))
.find(([n]) => n === name);
return found ? found.slice(1) : [];
}
function allowedBy(sources: string[], url: string): boolean {
return sources.some((source) =>
source.endsWith('/')
? url.startsWith(source)
: new URL(url).origin === source
);
}
function headerRuleCsp(file: string, source: string): string | undefined {
const config = JSON.parse(read(file)) as {
headers: Array<{
source: string;
headers: Array<{ key: string; value: string }>;
}>;
};
return config.headers
.find((rule) => rule.source === source)
?.headers.find((h) => h.key === 'Content-Security-Policy')?.value;
}
describe('CSP script-src', () => {
const scriptSrc = directive(defaults.csp, 'script-src');
it('allows jsDelivr only through version-pinned package prefixes', () => {
const jsdelivr = scriptSrc.filter((s) => s.includes('cdn.jsdelivr.net'));
expect(jsdelivr.length).toBeGreaterThan(0);
for (const source of jsdelivr) {
expect(source.startsWith('https://cdn.jsdelivr.net/npm/'), source).toBe(
true
);
expect(source.endsWith('/'), source).toBe(true);
expect(source).toMatch(/@v?\d+\.\d+\.\d+\//);
}
});
it('covers every script the app loads from its default CDN URLs', () => {
const { pymupdf, ghostscript, cpdf } = WasmProvider.getAllProviders();
const tesseract = JSON.parse(
read('node_modules/tesseract.js/package.json')
) as { version: string; dependencies: Record<string, string> };
const core = tesseract.dependencies['tesseract.js-core'].substring(1);
const urls = [
`${pymupdf}dist/index.js`,
`${pymupdf}assets/pyodide.js`,
`${pymupdf}assets/pyodide.asm.js`,
`${ghostscript}gs.js`,
`${cpdf}coherentpdf.browser.min.js`,
`https://cdn.jsdelivr.net/npm/tesseract.js@v${tesseract.version}/dist/worker.min.js`,
`https://cdn.jsdelivr.net/npm/tesseract.js-core@v${core}/tesseract-core-simd-lstm.wasm.js`,
];
for (const url of urls) {
expect(allowedBy(scriptSrc, url), url).toBe(true);
}
});
it('rejects arbitrary jsDelivr packages and GitHub repos', () => {
for (const url of [
'https://cdn.jsdelivr.net/gh/attacker/repo@main/x.js',
'https://cdn.jsdelivr.net/npm/evil-package@1.0.0/x.js',
'https://cdn.jsdelivr.net/npm/coherentpdf@2.5.6/dist/coherentpdf.browser.min.js',
'https://cdn.jsdelivr.net/combine/npm/evil@1',
]) {
expect(allowedBy(scriptSrc, url), url).toBe(false);
}
});
it('keeps self-hosted VITE_* URLs working', () => {
const custom = buildSecurityHeaders({
VITE_WASM_PYMUPDF_URL: 'https://internal.example.com/wasm/pymupdf/',
VITE_WASM_GS_URL:
'https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm@0.1.1/assets',
VITE_WASM_CPDF_URL: 'https://cdn.jsdelivr.net/npm/x@1.0.0/a;b,c/',
VITE_TESSERACT_WORKER_URL:
'https://cdn.jsdelivr.net/npm/tesseract.js@v7.0.0/dist/worker.min.js',
VITE_TESSERACT_CORE_URL: 'https://ocr.example.com/core',
VITE_TESSERACT_LANG_URL: 'https://ocr.example.com/lang',
});
expect(custom.scriptSources).toEqual([
'https://internal.example.com',
'https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm@0.1.1/assets/',
'https://cdn.jsdelivr.net/npm/x@1.0.0/a%3Bb%2Cc/',
'https://cdn.jsdelivr.net/npm/tesseract.js@v7.0.0/dist/',
'https://ocr.example.com',
]);
expect(directive(custom.csp, 'connect-src')).toEqual(
expect.arrayContaining([
'https://internal.example.com',
'https://cdn.jsdelivr.net',
'https://ocr.example.com',
])
);
});
it('only allows the pinned docs inline scripts', () => {
const sources = directive(defaults.docsCsp, 'script-src');
expect(sources).toEqual([
"'self'",
...DOCS_INLINE_SCRIPT_HASHES.map((hash) => `'${hash}'`),
]);
expect(sources).not.toContain("'unsafe-inline'");
});
});
describe('deployment configs match the generator', () => {
it('vercel.json', () => {
expect(headerRuleCsp('vercel.json', '/(.*)')).toBe(defaults.csp);
expect(headerRuleCsp('vercel.json', '/docs')).toBe(defaults.docsCsp);
expect(headerRuleCsp('vercel.json', '/docs/:path*')).toBe(defaults.docsCsp);
});
it('serve.json', () => {
expect(headerRuleCsp('serve.json', '**/*')).toBe(defaults.csp);
expect(headerRuleCsp('serve.json', 'docs/**')).toBe(defaults.docsCsp);
});
it('public/_headers for Netlify and Cloudflare Pages', () => {
const file = read('public/_headers');
expect(file).toBe(defaults.staticHeaders);
expect(file).toContain(
`/docs/*\n ! Content-Security-Policy\n Content-Security-Policy: ${defaults.docsCsp}\n`
);
for (const line of file.split('\n')) {
expect(line.length).toBeLessThanOrEqual(2000);
}
});
it('nginx docs config and .htaccess', () => {
expect(read('security-headers-docs.conf')).toBe(defaults.nginxDocs);
expect(read('.htaccess')).toContain(
`Header always set Content-Security-Policy "${defaults.csp}"`
);
});
});
+186
View File
@@ -0,0 +1,186 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type {
PendingRedactionChange,
PendingRedactionItem,
RedactionPluginEvent,
RedactionPluginLite,
} from '../js/types/edit-pdf-redaction-type.js';
const { redactDocumentData } = vi.hoisted(() => ({
redactDocumentData: vi.fn(
async (pdf: Uint8Array, terms: readonly string[] = []) =>
Uint8Array.from([...pdf, terms.length])
),
}));
vi.mock('../js/utils/redact-document-data.js', () => ({
redactDocumentData,
}));
vi.mock('../js/utils/disabled-tools.js', () => ({
getEditorDisabledCategories: (): string[] => [],
}));
const { trackAppliedRedactions, redactionScrubTerms, scrubRedactedText } =
await import('../js/logic/edit-pdf-page.js');
function mockRedactionPlugin() {
const pendingListeners = new Set<(event: PendingRedactionChange) => void>();
const eventListeners = new Set<(event: RedactionPluginEvent) => void>();
const plugin: RedactionPluginLite = {
getState: () => ({ pendingCount: 0 }),
commitAllPending: () => ({ toPromise: async () => true }),
onPendingChange: (listener) => {
pendingListeners.add(listener);
return () => pendingListeners.delete(listener);
},
onRedactionEvent: (listener) => {
eventListeners.add(listener);
return () => eventListeners.delete(listener);
},
};
const pending = new Map<string, PendingRedactionItem[]>();
const publish = (documentId: string) => {
const items = pending.get(documentId) ?? [];
for (const listener of pendingListeners) {
listener({ documentId, pending: items.length ? { 0: items } : {} });
}
};
const emit = (event: RedactionPluginEvent) => {
for (const listener of eventListeners) listener(event);
};
return {
plugin,
mark(documentId: string, item: PendingRedactionItem) {
pending.set(documentId, [...(pending.get(documentId) ?? []), item]);
publish(documentId);
emit({ type: 'add', documentId });
},
unmark(documentId: string, id: string) {
pending.set(
documentId,
(pending.get(documentId) ?? []).filter((item) => item.id !== id)
);
publish(documentId);
emit({ type: 'remove', documentId, id });
},
clearAll(documentId: string) {
pending.set(documentId, []);
publish(documentId);
emit({ type: 'clear', documentId });
},
apply(documentId: string, ids?: string[]) {
pending.set(
documentId,
ids
? (pending.get(documentId) ?? []).filter(
(item) => !ids.includes(item.id)
)
: []
);
publish(documentId);
emit({ type: 'commit', documentId });
},
};
}
describe('trackAppliedRedactions', () => {
it('reports nothing when no redaction was applied', () => {
const viewer = mockRedactionPlugin();
const tracker = trackAppliedRedactions(viewer.plugin);
viewer.mark('doc', { id: 'a', kind: 'text', text: 'Jane Doe' });
expect(tracker.appliedTexts('doc')).toEqual([]);
});
it('keeps the text of applied text marks and skips removed marks', () => {
const viewer = mockRedactionPlugin();
const tracker = trackAppliedRedactions(viewer.plugin);
viewer.mark('doc', { id: 'a', kind: 'text', text: 'Jane Doe' });
viewer.mark('doc', { id: 'b', kind: 'text', text: 'Removed Mark' });
viewer.unmark('doc', 'b');
viewer.mark('doc', { id: 'c', kind: 'area' });
viewer.apply('doc');
expect(tracker.appliedTexts('doc')).toEqual(['Jane Doe', undefined]);
});
it('does not count marks removed with Clear all as applied', () => {
const viewer = mockRedactionPlugin();
const tracker = trackAppliedRedactions(viewer.plugin);
viewer.mark('doc', { id: 'a', kind: 'text', text: 'Applied Text' });
viewer.apply('doc');
viewer.mark('doc', { id: 'b', kind: 'text', text: 'Acme Corp' });
viewer.clearAll('doc');
expect(tracker.appliedTexts('doc')).toEqual(['Applied Text']);
});
it('counts a single applied mark while others stay pending', () => {
const viewer = mockRedactionPlugin();
const tracker = trackAppliedRedactions(viewer.plugin);
viewer.mark('doc', { id: 'a', kind: 'text', text: 'First' });
viewer.mark('doc', { id: 'b', kind: 'text', text: 'Second' });
viewer.apply('doc', ['a']);
expect(tracker.appliedTexts('doc')).toEqual(['First']);
viewer.apply('doc', ['b']);
expect(tracker.appliedTexts('doc')).toEqual(['First', 'Second']);
});
it('keeps applied marks separate per document and forgets closed ones', () => {
const viewer = mockRedactionPlugin();
const tracker = trackAppliedRedactions(viewer.plugin);
viewer.mark('one', { id: 'a', kind: 'text', text: 'Alpha' });
viewer.mark('two', { id: 'b', kind: 'area' });
viewer.apply('one');
expect(tracker.appliedTexts('one')).toEqual(['Alpha']);
expect(tracker.appliedTexts('two')).toEqual([]);
tracker.forget('one');
expect(tracker.appliedTexts('one')).toEqual([]);
});
it('treats a mark that comes back after a reload as pending again', () => {
const viewer = mockRedactionPlugin();
const tracker = trackAppliedRedactions(viewer.plugin);
const item: PendingRedactionItem = { id: 'a', kind: 'text', text: 'X' };
viewer.mark('doc', item);
viewer.apply('doc');
viewer.mark('doc', item);
expect(tracker.appliedTexts('doc')).toEqual([]);
});
});
describe('redactionScrubTerms', () => {
it('splits lines, trims, dedupes case-insensitively and sorts longest first', () => {
expect(
redactionScrubTerms([
'John',
undefined,
' John Smith \r\nAcme Corp',
'john',
'\n',
])
).toEqual(['John Smith', 'Acme Corp', 'John']);
});
it('returns no terms for area-only redactions', () => {
expect(redactionScrubTerms([undefined, undefined])).toEqual([]);
});
});
describe('scrubRedactedText', () => {
beforeEach(() => {
redactDocumentData.mockClear();
});
it('runs one termless pass when no text is known', async () => {
const out = await scrubRedactedText(Uint8Array.from([1]), []);
expect(redactDocumentData).toHaveBeenCalledTimes(1);
expect(redactDocumentData.mock.calls[0][1]).toEqual([]);
expect([...out]).toEqual([1, 0]);
});
it('removes every term in a single pass', async () => {
const out = await scrubRedactedText(Uint8Array.from([1]), ['abc', 'de']);
expect(redactDocumentData).toHaveBeenCalledTimes(1);
expect(redactDocumentData.mock.calls[0][1]).toEqual(['abc', 'de']);
expect([...out]).toEqual([1, 2]);
});
});
+212
View File
@@ -0,0 +1,212 @@
import { describe, it, expect, vi } from 'vitest';
import { readFileSync } from 'fs';
import { resolve } from 'path';
import {
PDFArray,
PDFDict,
PDFDocument,
PDFName,
PDFRawStream,
decodePDFRawStream,
} from 'pdf-lib';
vi.mock('../js/utils/helpers.js', async () => {
const { default: createModule } = await import('@neslinesli93/qpdf-wasm');
const wasmPath = resolve(
'node_modules/@neslinesli93/qpdf-wasm/dist/qpdf.wasm'
);
const options = {
locateFile: () => wasmPath,
wasmBinary: readFileSync(wasmPath),
print: () => {},
printErr: () => {},
};
const qpdf = await createModule(options);
return { initializeQpdf: async () => qpdf };
});
vi.mock('rete', () => ({
ClassicPreset: {
Input: class {},
Output: class {},
},
}));
vi.mock('@/js/workflow/sockets', () => ({ pdfSocket: {} }));
vi.mock('@/js/workflow/nodes/base-node', () => ({
BaseWorkflowNode: class {
addInput() {}
addOutput() {}
},
}));
const { flattenAnnotations, flattenFormsInDoc } =
await import('../js/utils/flatten-annotations.js');
const { removeUnreferencedObjects } =
await import('../js/utils/load-pdf-document.js');
const { sanitizePdf } = await import('../js/utils/sanitize.js');
const { FlattenNode } = await import('../js/workflow/nodes/flatten-node.js');
const SECRETS = [
'HIDDEN-VALUE',
'NOVIEW-VALUE',
'PASSWORD-SECRET',
'GROUP-SECRET',
];
async function buildFormDocument(): Promise<Uint8Array> {
const doc = await PDFDocument.create();
const page = doc.addPage([400, 400]);
const form = doc.getForm();
let y = 10;
const addField = (name: string, value: string, flags?: number) => {
const field = form.createTextField(name);
field.setText(value);
field.addToPage(page, { x: 10, y, width: 200, height: 20 });
y += 30;
if (flags !== undefined) {
const widgets = field.acroField.getWidgets();
widgets[widgets.length - 1].setFlags(flags);
}
return field;
};
addField('visible', 'VISIBLE-VALUE');
addField('hiddenField', 'HIDDEN-VALUE', 2);
addField('noViewField', 'NOVIEW-VALUE', 32 | 4);
addField('group.secret', 'GROUP-SECRET', 2);
const password = addField('pw', 'PASSWORD-SECRET');
password.enablePassword();
password.acroField.getWidgets()[0].dict.delete(PDFName.of('AP'));
const multi = addField('multi', 'MULTI-VALUE');
multi.addToPage(page, { x: 250, y: 10, width: 120, height: 20 });
multi.acroField.getWidgets()[1].setFlags(2);
const ctx = doc.context;
const appearance = (text: string) =>
ctx.register(
ctx.flateStream(`BT /Helv 10 Tf (${text}) Tj ET`, {
Type: 'XObject',
Subtype: 'Form',
BBox: [0, 0, 100, 20],
})
);
const annots = page.node.Annots() as PDFArray;
annots.push(
ctx.register(
ctx.obj({
Type: 'Annot',
Subtype: 'Square',
Rect: [10, 300, 110, 320],
F: 32 | 4,
AP: { N: appearance('NOVIEW-ANNOT') },
})
)
);
annots.push(
ctx.register(
ctx.obj({
Type: 'Annot',
Subtype: 'Square',
Rect: [10, 330, 110, 350],
AP: { N: appearance('SHOWN-ANNOT') },
})
)
);
return doc.save();
}
function hex(text: string): string {
return Buffer.from(text).toString('hex');
}
async function inspect(bytes: Uint8Array) {
const doc = await PDFDocument.load(bytes, { updateMetadata: false });
const streams: string[] = [];
for (const [, object] of doc.context.enumerateIndirectObjects()) {
if (object instanceof PDFRawStream) {
streams.push(
Buffer.from(decodePDFRawStream(object).decode()).toString('latin1')
);
}
}
const text = [Buffer.from(bytes).toString('latin1'), ...streams]
.join('\n')
.toLowerCase();
const count = (value: string) =>
text.split(value.toLowerCase()).length -
1 +
text.split(hex(value)).length -
1;
return { doc, count };
}
async function expectHiddenFieldsGone(bytes: Uint8Array) {
const { doc, count } = await inspect(bytes);
for (const secret of SECRETS) expect(count(secret)).toBe(0);
expect(count('VISIBLE-VALUE')).toBe(1);
expect(count('MULTI-VALUE')).toBe(1);
const acroForm = doc.catalog.lookup(PDFName.of('AcroForm'));
const fields =
acroForm instanceof PDFDict
? acroForm.lookup(PDFName.of('Fields'), PDFArray)
: undefined;
expect(fields?.size() ?? 0).toBe(0);
return count;
}
describe('flattenFormsInDoc', () => {
it('does not draw hidden, no-view or password fields', async () => {
const input = await buildFormDocument();
const before = await inspect(input);
for (const secret of SECRETS)
expect(before.count(secret)).toBeGreaterThan(0);
const doc = await PDFDocument.load(input);
flattenFormsInDoc(doc);
flattenAnnotations(doc);
const bytes = await removeUnreferencedObjects(await doc.save());
const count = await expectHiddenFieldsGone(bytes);
expect(count('NOVIEW-ANNOT')).toBe(0);
expect(count('SHOWN-ANNOT')).toBe(1);
});
it('is used by the sanitize flatten option', async () => {
const { bytes } = await sanitizePdf(await buildFormDocument(), {
flattenForms: true,
removeMetadata: false,
removeAnnotations: false,
removeJavascript: false,
removeEmbeddedFiles: false,
removeLayers: false,
removeLinks: false,
removeStructureTree: false,
removeMarkInfo: false,
removeFonts: false,
});
await expectHiddenFieldsGone(bytes);
});
it('is used by the workflow flatten node', async () => {
const node = new FlattenNode();
const result = await node.data({
pdf: [
{
type: 'pdf',
document: await PDFDocument.create(),
bytes: await buildFormDocument(),
filename: 'form.pdf',
},
],
});
const output = result.pdf;
if (output.type !== 'pdf') throw new Error('Expected a single PDF');
const count = await expectHiddenFieldsGone(output.bytes);
expect(count('NOVIEW-ANNOT')).toBe(0);
});
});
+57 -1
View File
@@ -184,7 +184,7 @@ describe('redactDocumentData', () => {
SECRET.toLowerCase()
);
const output = await redactDocumentData(input, secret());
const output = await redactDocumentData(input, [secret()]);
const text = await allText(output);
expect(text.toLowerCase()).not.toContain(SECRET.toLowerCase());
@@ -200,6 +200,62 @@ describe('redactDocumentData', () => {
expect(result.getPages()[0].node.get(PDFName.of('Thumb'))).toBeUndefined();
});
it('keeps dates intact when the redacted text is short', async () => {
const doc = await PDFDocument.create();
doc.addPage();
const created = new Date(Date.UTC(2026, 0, 2, 3, 4, 5));
doc.setCreationDate(created);
doc.setModificationDate(created);
doc.setTitle('Invoice 20 of 2026');
const output = await redactDocumentData(await doc.save(), ['20']);
const result = await PDFDocument.load(output, { updateMetadata: false });
expect(result.getCreationDate()?.toISOString()).toBe(created.toISOString());
expect(Number.isNaN(result.getModificationDate()?.getTime())).toBe(false);
expect(result.getTitle()).toBe('Invoice of 26');
});
it('removes the text from signature fields but keeps the signature value', async () => {
const doc = await PDFDocument.create();
doc.addPage();
const signatureValue = PDFHexString.of('3082ABCDEF01');
const signature = doc.context.register(
doc.context.obj({
Type: 'Sig',
Name: PDFString.of(`Signed by ${SECRET}`),
Reason: PDFString.of(`Approved by ${SECRET}`),
Contents: signatureValue,
})
);
doc.catalog.set(PDFName.of('TestSig'), signature);
const output = await redactDocumentData(await doc.save(), [SECRET]);
const result = await PDFDocument.load(output, { updateMetadata: false });
const saved = result.catalog.lookup(PDFName.of('TestSig'), PDFDict);
expect(saved.lookup(PDFName.of('Name'), PDFHexString).decodeText()).toBe(
'Signed by '
);
expect(saved.lookup(PDFName.of('Reason'), PDFHexString).decodeText()).toBe(
'Approved by '
);
expect(
Array.from(saved.lookup(PDFName.of('Contents'), PDFHexString).asBytes())
).toEqual(Array.from(signatureValue.asBytes()));
});
it('removes several terms in one pass, longest match first', async () => {
const doc = await PDFDocument.create();
doc.addPage();
doc.setTitle('Report by John Smith for Acme and John');
doc.setSubject('acme corp');
const output = await redactDocumentData(await doc.save(), [
'John',
'John Smith',
'Acme',
]);
const result = await PDFDocument.load(output, { updateMetadata: false });
expect(result.getTitle()).toBe('Report by for and ');
expect(result.getSubject()).toBe(' corp');
});
it('only drops thumbnails and leftovers when no search text is given', async () => {
const input = await buildDocument();
const output = await redactDocumentData(input);
+662
View File
@@ -0,0 +1,662 @@
import { describe, it, expect, vi } from 'vitest';
import { readFileSync } from 'fs';
import { resolve } from 'path';
import {
PDFArray,
PDFDict,
PDFDocument,
PDFHexString,
PDFName,
PDFObject,
PDFRawStream,
PDFRef,
PDFString,
decodePDFRawStream,
} from 'pdf-lib';
vi.mock('../js/utils/helpers.js', async () => {
const { default: createModule } = await import('@neslinesli93/qpdf-wasm');
const wasmPath = resolve(
'node_modules/@neslinesli93/qpdf-wasm/dist/qpdf.wasm'
);
const options = {
locateFile: () => wasmPath,
wasmBinary: readFileSync(wasmPath),
print: () => {},
printErr: () => {},
};
const qpdf = await createModule(options);
return { initializeQpdf: async () => qpdf };
});
const { sanitizePdf, defaultSanitizeOptions } =
await import('../js/utils/sanitize.js');
const { parseContentOperations } =
await import('../js/utils/pdf-content-tokens.js');
const NONE = {
flattenForms: false,
removeMetadata: false,
removeAnnotations: false,
removeJavascript: false,
removeEmbeddedFiles: false,
removeLayers: false,
removeLinks: false,
removeStructureTree: false,
removeMarkInfo: false,
removeFonts: false,
};
const latin1 = (bytes: Uint8Array) => Buffer.from(bytes).toString('latin1');
function collectText(value: PDFObject | undefined, out: string[]) {
if (value instanceof PDFString || value instanceof PDFHexString) {
out.push(value.decodeText());
} else if (value instanceof PDFDict) {
for (const [key, v] of value.entries()) {
out.push(key.decodeText());
collectText(v, out);
}
} else if (value instanceof PDFArray) {
for (const v of value.asArray()) collectText(v, out);
}
}
async function allText(bytes: Uint8Array): Promise<string> {
const doc = await PDFDocument.load(bytes, { updateMetadata: false });
const out: string[] = [latin1(bytes)];
for (const [, object] of doc.context.enumerateIndirectObjects()) {
if (object instanceof PDFRawStream) {
collectText(object.dict, out);
out.push(latin1(decodePDFRawStream(object).decode()));
} else {
collectText(object, out);
}
}
return out.join('\n');
}
async function pageContent(doc: PDFDocument): Promise<string> {
const contents = doc.getPages()[0].node.Contents();
const streams =
contents instanceof PDFArray ? contents.asArray() : [contents];
return streams
.map((item) => doc.context.lookup(item))
.map((stream) =>
stream instanceof PDFRawStream
? latin1(decodePDFRawStream(stream).decode())
: ''
)
.join('\n');
}
function lookupDict(doc: PDFDocument, value: PDFObject | undefined) {
const result = doc.context.lookup(value);
return result instanceof PDFDict ? result : undefined;
}
async function buildActionDocument(): Promise<Uint8Array> {
const doc = await PDFDocument.create();
const page = doc.addPage([300, 300]);
const ctx = doc.context;
const outlines = ctx.nextRef();
const first = ctx.nextRef();
const second = ctx.nextRef();
ctx.assign(
first,
ctx.obj({
Title: PDFString.of('Chapter one'),
Parent: outlines,
Next: second,
A: ctx.register(
ctx.obj({
S: 'JavaScript',
JS: PDFString.of("app.alert('outline-js')"),
})
),
})
);
ctx.assign(
second,
ctx.obj({
Title: PDFString.of('Chapter two'),
Parent: outlines,
Prev: first,
A: ctx.obj({
S: 'Launch',
F: PDFString.of('launch-calc.exe'),
}),
})
);
ctx.assign(
outlines,
ctx.obj({ Type: 'Outlines', First: first, Last: second, Count: 2 })
);
doc.catalog.set(PDFName.of('Outlines'), outlines);
doc.catalog.set(
PDFName.of('OpenAction'),
ctx.obj([page.ref, PDFName.of('Fit')])
);
const nextJs = ctx.register(
ctx.obj({ S: 'JavaScript', JS: PDFString.of("app.alert('next-js')") })
);
const link = ctx.register(
ctx.obj({
Type: 'Annot',
Subtype: 'Link',
Rect: [10, 10, 100, 30],
A: ctx.obj({
S: 'URI',
URI: PDFString.of('https://example.com/keep'),
Next: nextJs,
}),
})
);
const chained = ctx.register(
ctx.obj({
Type: 'Annot',
Subtype: 'Link',
Rect: [10, 40, 100, 60],
A: ctx.obj({
S: 'SubmitForm',
F: PDFString.of('https://submit-url.example'),
Next: ctx.obj({
S: 'URI',
URI: PDFString.of('https://example.com/after-submit'),
}),
}),
})
);
const importData = ctx.register(
ctx.obj({
Type: 'Annot',
Subtype: 'Link',
Rect: [10, 70, 100, 90],
A: ctx.obj({ S: 'ImportData', F: PDFString.of('import-data.fdf') }),
})
);
const appearance = ctx.register(
ctx.flateStream('0 0 1 rg 0 0 80 20 re f', {
Type: 'XObject',
Subtype: 'Form',
BBox: [0, 0, 80, 20],
})
);
const parentField = ctx.nextRef();
const childField = ctx.register(
ctx.obj({
Type: 'Annot',
Subtype: 'Widget',
FT: 'Tx',
T: PDFString.of('child'),
Parent: parentField,
Rect: [10, 100, 90, 120],
P: page.ref,
AP: { N: appearance },
AA: {
K: ctx.obj({
S: 'JavaScript',
JS: PDFString.of("app.alert('nested-aa-js')"),
}),
},
})
);
ctx.assign(
parentField,
ctx.obj({ T: PDFString.of('parent'), Kids: [childField] })
);
const xfa = ctx.register(
ctx.flateStream('<xdp:xdp><script>xfa-script</script></xdp:xdp>')
);
doc.catalog.set(
PDFName.of('AcroForm'),
ctx.obj({ Fields: [parentField], XFA: xfa })
);
page.node.set(
PDFName.of('Annots'),
ctx.obj([link, chained, importData, childField])
);
return doc.save();
}
async function buildMetadataDocument(): Promise<Uint8Array> {
const doc = await PDFDocument.create();
const page = doc.addPage([300, 300]);
const ctx = doc.context;
const xmp = (text: string) =>
ctx.register(
ctx.flateStream(`<x:xmpmeta>${text}</x:xmpmeta>`, {
Type: 'Metadata',
Subtype: 'XML',
})
);
const image = ctx.register(
ctx.stream(new Uint8Array([0, 128, 255, 64]), {
Type: 'XObject',
Subtype: 'Image',
Width: 2,
Height: 2,
ColorSpace: 'DeviceGray',
BitsPerComponent: 8,
Metadata: xmp('image-xmp-secret'),
})
);
const form = ctx.register(
ctx.flateStream('0 0 1 rg 0 0 10 10 re f', {
Type: 'XObject',
Subtype: 'Form',
BBox: [0, 0, 10, 10],
Metadata: xmp('form-xmp-secret'),
PieceInfo: { App: { Private: PDFString.of('form-piece-secret') } },
})
);
page.node.set(
PDFName.of('Resources'),
ctx.obj({ XObject: { Im1: image, Fm1: form } })
);
page.node.set(
PDFName.of('Contents'),
ctx.register(ctx.flateStream('q 100 0 0 100 0 0 cm /Im1 Do Q q /Fm1 Do Q'))
);
page.node.set(PDFName.of('Metadata'), xmp('page-xmp-secret'));
page.node.set(
PDFName.of('PieceInfo'),
ctx.obj({ App: { Private: PDFString.of('page-piece-secret') } })
);
page.node.set(
PDFName.of('Thumb'),
ctx.register(
ctx.flateStream('thumb-secret-pixels', {
Width: 1,
Height: 1,
ColorSpace: 'DeviceGray',
BitsPerComponent: 8,
})
)
);
doc.catalog.set(
PDFName.of('PieceInfo'),
ctx.obj({ App: { Private: PDFString.of('catalog-piece-secret') } })
);
doc.catalog.set(PDFName.of('Metadata'), xmp('catalog-xmp-secret'));
return doc.save();
}
const PAGE_CONTENT = [
'q',
'/OC /MCon BDC 0 0 1 rg 10 10 50 50 re f',
'BT /F1 12 Tf 20 200 Td (ON-LAYER-TEXT) Tj ET',
'BI /W 4 /H 1 /BPC 8 /CS /G ID \x20EI\x80 EI',
'EMC',
'/OC /MCoff BDC 1 0 0 rg 100 100 m 200 200 l S',
'q 0 0 10 10 re W n Q',
'BT /F1 12 Tf 20 220 Td (OFF-LAYER-TEXT EMC (inner) \\) tail) Tj',
'<4f46462d484558> Tj % EMC in a comment',
'/Span <</ActualText (OFF-ACTUAL)>> BDC (x) Tj EMC ET',
'BI /W 2 /H 1 /BPC 8 /CS /G ID \x10\xff EI',
'/ImOff Do',
'EMC',
'/OC /MCmd BDC BT /F1 12 Tf 20 240 Td (OCMD-HIDDEN-TEXT) Tj ET EMC',
'/OC /MCve BDC BT /F1 12 Tf 20 260 Td (VE-HIDDEN-TEXT) Tj ET EMC',
'/FmOff Do',
'/FmOn Do',
'/ImOff Do',
'Q',
].join('\n');
async function buildLayerDocument(content = PAGE_CONTENT): Promise<Uint8Array> {
const doc = await PDFDocument.create();
const page = doc.addPage([300, 300]);
const ctx = doc.context;
const on = ctx.register(ctx.obj({ Type: 'OCG', Name: PDFString.of('On') }));
const off = ctx.register(ctx.obj({ Type: 'OCG', Name: PDFString.of('Off') }));
const ocmd = ctx.register(
ctx.obj({ Type: 'OCMD', OCGs: [on, off], P: 'AllOn' })
);
const ocmdVe = ctx.register(
ctx.obj({
Type: 'OCMD',
OCGs: [on],
VE: [PDFName.of('And'), on, [PDFName.of('Not'), on]],
})
);
doc.catalog.set(
PDFName.of('OCProperties'),
ctx.obj({ OCGs: [on, off], D: { Order: [on, off], ON: [on], OFF: [off] } })
);
const font = ctx.register(
ctx.obj({ Type: 'Font', Subtype: 'Type1', BaseFont: 'Helvetica' })
);
const formOff = ctx.register(
ctx.flateStream('BT /F1 12 Tf (FORM-OFF-TEXT) Tj ET', {
Type: 'XObject',
Subtype: 'Form',
BBox: [0, 0, 300, 300],
OC: off,
Resources: { Font: { F1: font } },
})
);
const formOn = ctx.register(
ctx.flateStream(
'BT /F1 12 Tf (FORM-ON-TEXT) Tj ET /OC /P0 BDC BT /F1 12 Tf (FORM-NESTED-OFF) Tj ET EMC',
{
Type: 'XObject',
Subtype: 'Form',
BBox: [0, 0, 300, 300],
OC: on,
Resources: { Font: { F1: font }, Properties: { P0: off } },
}
)
);
const imageOff = ctx.register(
ctx.stream('IMAGE-OFF-PIXELS', {
Type: 'XObject',
Subtype: 'Image',
Width: 4,
Height: 4,
ColorSpace: 'DeviceGray',
BitsPerComponent: 8,
OC: off,
})
);
page.node.set(
PDFName.of('Resources'),
ctx.obj({
Font: { F1: font },
Properties: { MCon: on, MCoff: off, MCmd: ocmd, MCve: ocmdVe },
XObject: { FmOff: formOff, FmOn: formOn, ImOff: imageOff },
})
);
page.node.set(PDFName.of('Contents'), ctx.register(ctx.flateStream(content)));
const square = (text: string, oc: PDFRef) =>
ctx.register(
ctx.obj({
Type: 'Annot',
Subtype: 'Square',
Rect: [10, 10, 50, 50],
Contents: PDFString.of(text),
OC: oc,
})
);
page.node.set(
PDFName.of('Annots'),
ctx.obj([square('annot-off-layer', off), square('annot-on-layer', on)])
);
return doc.save();
}
describe('sanitize remove JavaScript', () => {
it('removes every reachable script, launch, submit and XFA but keeps links', async () => {
const input = await buildActionDocument();
const before = await allText(input);
for (const secret of [
'outline-js',
'launch-calc.exe',
'next-js',
'nested-aa-js',
'xfa-script',
'submit-url',
'import-data.fdf',
]) {
expect(before).toContain(secret);
}
const { bytes } = await sanitizePdf(input, {
...NONE,
removeJavascript: true,
});
const after = await allText(bytes);
for (const secret of [
'outline-js',
'launch-calc.exe',
'next-js',
'nested-aa-js',
'xfa-script',
'submit-url',
'import-data.fdf',
'JavaScript',
'XFA',
]) {
expect(after).not.toContain(secret);
}
expect(after).toContain('https://example.com/keep');
expect(after).toContain('https://example.com/after-submit');
expect(after).toContain('Chapter one');
expect(after).toContain('Chapter two');
const doc = await PDFDocument.load(bytes, { updateMetadata: false });
const annots = doc.getPages()[0].node.Annots()!;
const linkAction = lookupDict(
doc,
lookupDict(doc, annots.get(0))?.get(PDFName.of('A'))
);
expect(linkAction?.get(PDFName.of('S'))).toBe(PDFName.of('URI'));
expect(linkAction?.has(PDFName.of('Next'))).toBe(false);
const promoted = lookupDict(
doc,
lookupDict(doc, annots.get(1))?.get(PDFName.of('A'))
);
expect(promoted?.get(PDFName.of('S'))).toBe(PDFName.of('URI'));
expect(doc.catalog.get(PDFName.of('OpenAction'))).toBeInstanceOf(PDFArray);
});
});
describe('sanitize remove metadata', () => {
it('drops page, XObject and catalog metadata, piece info and thumbnails', async () => {
const input = await buildMetadataDocument();
const secrets = [
'image-xmp-secret',
'form-xmp-secret',
'form-piece-secret',
'page-xmp-secret',
'page-piece-secret',
'thumb-secret-pixels',
'catalog-piece-secret',
'catalog-xmp-secret',
];
const before = await allText(input);
for (const secret of secrets) expect(before).toContain(secret);
const { bytes } = await sanitizePdf(input, {
...NONE,
removeMetadata: true,
});
const after = await allText(bytes);
for (const secret of secrets) expect(after).not.toContain(secret);
const doc = await PDFDocument.load(bytes, { updateMetadata: false });
const xObjects = doc
.getPages()[0]
.node.Resources()
?.lookup(PDFName.of('XObject'), PDFDict);
expect(xObjects?.has(PDFName.of('Im1'))).toBe(true);
expect(xObjects?.has(PDFName.of('Fm1'))).toBe(true);
});
});
describe('sanitize remove layers', () => {
it('removes content in hidden layers and keeps visible layers', async () => {
const input = await buildLayerDocument();
const { bytes } = await sanitizePdf(input, {
...NONE,
removeLayers: true,
});
const doc = await PDFDocument.load(bytes, { updateMetadata: false });
expect(doc.catalog.has(PDFName.of('OCProperties'))).toBe(false);
const content = await pageContent(doc);
expect(content).toContain('(ON-LAYER-TEXT) Tj');
expect(content).toContain('0 0 1 rg 10 10 50 50 re f');
expect(content).toContain('BI /W 4 /H 1 /BPC 8 /CS /G ID \x20EI\x80 EI');
expect(content).toContain('/FmOn Do');
expect(content).toMatch(/0 0 10 10 re\s+W\s+n/);
for (const hidden of [
'OFF-LAYER-TEXT',
'inner',
'tail',
'4f46462d484558',
'OFF-ACTUAL',
'OCMD-HIDDEN-TEXT',
'VE-HIDDEN-TEXT',
'200 200 l',
'/FmOff',
'/ImOff',
'\x10\xff',
]) {
expect(content).not.toContain(hidden);
}
expect(content.match(/\bq\b/g)?.length).toBe(
content.match(/\bQ\b/g)?.length
);
const text = await allText(bytes);
expect(text).toContain('FORM-ON-TEXT');
expect(text).toContain('annot-on-layer');
for (const hidden of [
'FORM-OFF-TEXT',
'FORM-NESTED-OFF',
'IMAGE-OFF-PIXELS',
'annot-off-layer',
'OCMD',
]) {
expect(text).not.toContain(hidden);
}
expect(doc.getPages()[0].node.Annots()?.size()).toBe(1);
});
it('removes hidden layers with the default options', async () => {
const { bytes } = await sanitizePdf(
await buildLayerDocument(),
defaultSanitizeOptions
);
const doc = await PDFDocument.load(bytes, { updateMetadata: false });
expect(doc.catalog.has(PDFName.of('OCProperties'))).toBe(false);
const text = await allText(bytes);
expect(text).toContain('ON-LAYER-TEXT');
expect(text).toContain('FORM-ON-TEXT');
for (const hidden of [
'OFF-LAYER-TEXT',
'OCMD-HIDDEN-TEXT',
'FORM-OFF-TEXT',
'FORM-NESTED-OFF',
'IMAGE-OFF-PIXELS',
]) {
expect(text).not.toContain(hidden);
}
});
it('keeps the layers when hidden content cannot be parsed safely', async () => {
const input = await buildLayerDocument(
'BT /F1 12 Tf /OC /MCoff BDC (UNPARSABLE-OFF Tj EMC ET'
);
const { bytes, skipped } = await sanitizePdf(input, {
...NONE,
removeLayers: true,
});
expect(skipped).toEqual(['remove layers']);
const doc = await PDFDocument.load(bytes, { updateMetadata: false });
expect(doc.catalog.has(PDFName.of('OCProperties'))).toBe(true);
const properties = doc
.getPages()[0]
.node.Resources()
?.lookup(PDFName.of('Properties'), PDFDict);
expect(properties?.has(PDFName.of('MCoff'))).toBe(true);
});
});
describe('sanitize remove layers edge cases', () => {
it('uses page resources for forms without their own and ignores missing content streams', async () => {
const doc = await PDFDocument.create();
const page = doc.addPage([300, 300]);
const ctx = doc.context;
const off = ctx.register(
ctx.obj({ Type: 'OCG', Name: PDFString.of('Off') })
);
doc.catalog.set(
PDFName.of('OCProperties'),
ctx.obj({ OCGs: [off], D: { OFF: [off] } })
);
const font = ctx.register(
ctx.obj({ Type: 'Font', Subtype: 'Type1', BaseFont: 'Helvetica' })
);
const bareForm = ctx.register(
ctx.flateStream(
'BT /F1 12 Tf (FORM-VISIBLE-TEXT) Tj ET /OC /MCoff BDC BT /F1 12 Tf (INHERITED-OFF-TEXT) Tj ET EMC',
{ Type: 'XObject', Subtype: 'Form', BBox: [0, 0, 300, 300] }
)
);
page.node.set(
PDFName.of('Resources'),
ctx.obj({
Font: { F1: font },
Properties: { MCoff: off },
XObject: { FmBare: bareForm },
})
);
page.node.set(
PDFName.of('Contents'),
ctx.obj([ctx.register(ctx.flateStream('/FmBare Do')), ctx.nextRef()])
);
const { bytes, skipped } = await sanitizePdf(await doc.save(), {
...NONE,
removeLayers: true,
});
expect(skipped).toEqual([]);
const text = await allText(bytes);
expect(text).not.toContain('INHERITED-OFF-TEXT');
expect(text).toContain('FORM-VISIBLE-TEXT');
const result = await PDFDocument.load(bytes, { updateMetadata: false });
expect(result.catalog.has(PDFName.of('OCProperties'))).toBe(false);
});
});
describe('content stream tokenizer', () => {
it('handles strings, comments, dictionaries and inline images', () => {
const source =
'/OC /L1 BDC (a\\) EMC (b)) Tj <45 4d 43> Tj % EMC\n' +
'[(x) -20 (EMC)] TJ /P <</MCID 3 /Alt (EMC)>> BDC EMC ' +
'BI /W 4 /H 1 /CS /G /BPC 8 ID \x00EI\x7f EI EMC ' +
'BI /W 4 /H 1 /CS /G /BPC 8 /F /AHx ID 00ff00ff> EI EMC';
const bytes = new Uint8Array([...source].map((c) => c.charCodeAt(0)));
const ops = parseContentOperations(bytes);
expect(ops.map((op) => op.operator)).toEqual([
'BDC',
'Tj',
'Tj',
'TJ',
'BDC',
'EMC',
'BI',
'EMC',
'BI',
'EMC',
]);
expect(ops[4].operands[1].kind).toBe('dict');
});
it('rejects content it cannot tokenize', () => {
const encode = (text: string) =>
new Uint8Array([...text].map((c) => c.charCodeAt(0)));
expect(() => parseContentOperations(encode('(open Tj'))).toThrow();
expect(() => parseContentOperations(encode('<</A 1 BDC'))).toThrow();
expect(() => parseContentOperations(encode('BI /W 1 ID \x00'))).toThrow();
expect(() => parseContentOperations(encode('<zz> Tj'))).toThrow();
expect(() =>
parseContentOperations(encode('BI /F /Fl ID x EI\x00\x01 EI'))
).toThrow();
expect(() =>
parseContentOperations(encode('BI /W 9 /H 1 /CS /G /BPC 8 ID ab EI'))
).toThrow();
});
});
+87
View File
@@ -0,0 +1,87 @@
import fs from 'node:fs';
import path from 'node:path';
import { describe, it, expect } from 'vitest';
import { serializeJsonLd } from '../../scripts/json-ld.mjs';
import { sanitizeTranslationHtml } from '../js/utils/translation-html';
const LOCALES_DIR = path.resolve(__dirname, '../../public/locales');
const PAYLOAD =
'Merge</script><script src=https://cdn.jsdelivr.net/gh/a/b/c.js></script>';
function localeStrings(): string[] {
const out: string[] = [];
const visit = (value: unknown) => {
if (typeof value === 'string') out.push(value);
else if (value && typeof value === 'object')
Object.values(value).forEach(visit);
};
for (const lang of fs.readdirSync(LOCALES_DIR)) {
const dir = path.join(LOCALES_DIR, lang);
if (!fs.statSync(dir).isDirectory()) continue;
for (const file of fs.readdirSync(dir).filter((f) => f.endsWith('.json'))) {
visit(JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8')));
}
}
return out;
}
function parse(html: string): Document {
return new DOMParser().parseFromString(html, 'text/html');
}
function scriptsAfterRoundTrip(jsonLd: string): HTMLScriptElement[] {
const doc = parse('<!doctype html><html><head></head><body></body></html>');
const script = doc.createElement('script');
script.setAttribute('type', 'application/ld+json');
script.textContent = jsonLd;
doc.body.appendChild(script);
return Array.from(
parse(doc.documentElement.outerHTML).querySelectorAll('script')
);
}
describe('JSON-LD serialization', () => {
it('raw JSON.stringify lets a translated name break out of the script', () => {
expect(
scriptsAfterRoundTrip(JSON.stringify({ name: PAYLOAD })).length
).toBeGreaterThan(1);
});
it('serializeJsonLd keeps the payload inside one ld+json script', () => {
const data = { name: `${PAYLOAD}\u2028\u2029<!--` };
const scripts = scriptsAfterRoundTrip(serializeJsonLd(data));
expect(scripts).toHaveLength(1);
expect(scripts[0].textContent).not.toMatch(/[<\u2028\u2029]/);
expect(JSON.parse(scripts[0].textContent || '')).toEqual(data);
});
});
describe('sanitizeTranslationHtml', () => {
it('keeps the allowed formatting tags', () => {
expect(
sanitizeTranslationHtml('a<br>b <strong>c</strong> <em>d</em>')
).toBe('a<br>b <strong>c</strong> <em>d</em>');
});
it('strips scripts, event handlers, links and attributes', () => {
expect(sanitizeTranslationHtml(PAYLOAD)).toBe('Merge');
expect(sanitizeTranslationHtml('<img src=x onerror=alert(1)>x')).toBe('x');
expect(
sanitizeTranslationHtml('<a href="javascript:alert(1)">go</a>')
).toBe('go');
expect(
sanitizeTranslationHtml('<strong onclick="alert(1)" class="x">y</strong>')
).toBe('<strong>y</strong>');
expect(
sanitizeTranslationHtml('<iframe srcdoc="<script>alert(1)</script>">')
).toBe('');
});
it('leaves every shipped locale string intact', () => {
const withMarkup = localeStrings().filter((s) => s.includes('<'));
expect(withMarkup.length).toBeGreaterThan(0);
for (const value of withMarkup) {
expect(sanitizeTranslationHtml(value)).toBe(parse(value).body.innerHTML);
}
});
});
@@ -0,0 +1,221 @@
import { describe, it, expect } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import forge from 'node-forge';
import { signPdf, parsePemFiles } from '@/js/logic/digital-sign-pdf';
import {
countSignatures,
extractSignatures,
validatePdfSignatures,
} from '@/js/logic/validate-signature-pdf';
import type { CertificateData } from '@/types';
const RANGE_PLACEHOLDER = '{{RANGE}}';
const RANGE_WIDTH = 64;
const HEX_LENGTH = 8192;
const SIG_HEAD =
'%PDF-1.7\n1 0 obj\n<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /adbe.pkcs7.detached ';
const SIG_TAIL =
' /M (D:20260101120000Z) >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n';
const keys = forge.pki.rsa.generateKeyPair(1024);
const cert = forge.pki.createCertificate();
cert.publicKey = keys.publicKey;
cert.serialNumber = '01';
cert.validity.notBefore = new Date(Date.now() - 86400000);
cert.validity.notAfter = new Date(Date.now() + 86400000 * 365);
cert.setSubject([{ name: 'commonName', value: 'Test Signer' }]);
cert.setIssuer([{ name: 'commonName', value: 'Test Signer' }]);
cert.sign(keys.privateKey, forge.md.sha256.create());
const certificateData: CertificateData = parsePemFiles(
forge.pki.certificateToPem(cert),
forge.pki.privateKeyToPem(keys.privateKey)
);
const samplePdf = new Uint8Array(
fs.readFileSync(path.resolve(__dirname, './fixtures/sample.pdf'))
);
function signedCmsHex(content: string): string {
const p7 = forge.pkcs7.createSignedData();
p7.content = forge.util.createBuffer(content);
p7.addCertificate(cert);
p7.addSigner({
key: keys.privateKey,
certificate: cert,
digestAlgorithm: forge.pki.oids.sha256,
authenticatedAttributes: [
{ type: forge.pki.oids.contentType, value: forge.pki.oids.data },
{ type: forge.pki.oids.messageDigest },
],
});
p7.sign({ detached: true });
return forge.util.bytesToHex(forge.asn1.toDer(p7.toAsn1()).getBytes());
}
function buildSignedPdf(layout: {
prefix?: string;
head: string;
gap: (hex: string) => string;
tail: string;
}): Uint8Array {
const prefix = layout.prefix ?? '';
const headLength =
layout.head.length - RANGE_PLACEHOLDER.length + RANGE_WIDTH;
const gapLength = layout.gap('0'.repeat(HEX_LENGTH)).length;
const byteRange = [
prefix.length,
headLength,
prefix.length + headLength + gapLength,
layout.tail.length,
];
const head = layout.head.replace(
RANGE_PLACEHOLDER,
`/ByteRange [${byteRange.join(' ')}]`.padEnd(RANGE_WIDTH)
);
const hex = signedCmsHex(head + layout.tail).padEnd(HEX_LENGTH, '0');
return new TextEncoder().encode(
prefix + head + layout.gap(hex) + layout.tail
);
}
function appendIncrementalUpdate(pdfBytes: Uint8Array): Uint8Array {
const update = new TextEncoder().encode(
'\n2 0 obj\n<< /Type /Annot /Subtype /Text /Contents (Added later) >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n'
);
const updated = new Uint8Array(pdfBytes.length + update.length);
updated.set(pdfBytes, 0);
updated.set(update, pdfBytes.length);
return updated;
}
describe('validatePdfSignatures extraction and coverage', () => {
it('validates a signature made by the app signer with full coverage', async () => {
const signed = await signPdf(samplePdf, certificateData, {
signatureInfo: {
reason: 'Approved (final) é',
location: '東京',
contactInfo: 'signer@example.com',
},
});
const results = await validatePdfSignatures(signed);
expect(countSignatures(signed)).toBe(1);
expect(results).toHaveLength(1);
expect(results[0].cryptoVerified).toBe(true);
expect(results[0].coverageStatus).toBe('full');
expect(results[0].isValid).toBe(true);
expect(results[0].errorMessage).toBeUndefined();
expect(results[0].signerName).toBe('Test Signer');
expect(results[0].reason).toBe('Approved (final) é');
expect(results[0].location).toBe('東京');
expect(results[0].contactInfo).toBe('signer@example.com');
expect(results[0].signatureDate).toBeInstanceOf(Date);
});
it('validates a hand-assembled signature whose gap is exactly its /Contents', async () => {
const pdfBytes = buildSignedPdf({
head: `${SIG_HEAD}${RANGE_PLACEHOLDER} /Prop_Build << /App << /Name /Builder >> >> /Name (Real Signer) /Contents `,
gap: (hex) => `<${hex}>`,
tail: SIG_TAIL,
});
const [result] = await validatePdfSignatures(pdfBytes);
expect(result.isValid).toBe(true);
expect(result.coverageStatus).toBe('full');
expect(result.signatureDate).toBeInstanceOf(Date);
expect(extractSignatures(pdfBytes)[0].name).toBe('Real Signer');
});
it('reports an intact signature as partial when an incremental update follows', async () => {
const signed = await signPdf(samplePdf, certificateData, {
signatureInfo: { reason: 'Original' },
});
const [result] = await validatePdfSignatures(
appendIncrementalUpdate(signed)
);
expect(result.cryptoVerified).toBe(true);
expect(result.cryptoVerificationStatus).toBe('verified');
expect(result.coverageStatus).toBe('partial');
expect(result.isValid).toBe(false);
expect(result.errorMessage).toBeUndefined();
expect(result.reason).toBe('Original');
});
it('keeps the earlier signature partial and the latest one full in a multi-signed file', async () => {
const first = await signPdf(samplePdf, certificateData, {
signatureInfo: { reason: 'First' },
});
const second = await signPdf(first, certificateData, {
signatureInfo: { reason: 'Second' },
});
const results = await validatePdfSignatures(second);
expect(countSignatures(second)).toBe(2);
expect(results.map((r) => r.reason)).toEqual(['First', 'Second']);
expect(results.map((r) => r.coverageStatus)).toEqual(['partial', 'full']);
expect(results.map((r) => r.cryptoVerified)).toEqual([true, true]);
expect(results.map((r) => r.errorMessage)).toEqual([undefined, undefined]);
expect(results[1].isValid).toBe(true);
});
it('rejects a decoy /Contents whose gap holds a different signature', async () => {
const pdfBytes = buildSignedPdf({
head: `${SIG_HEAD}/Contents <> ${RANGE_PLACEHOLDER} /Decoy /Contents `,
gap: (hex) => `<${hex}>`,
tail: SIG_TAIL,
});
const results = await validatePdfSignatures(pdfBytes);
expect(results).toHaveLength(1);
expect(results[0].isValid).toBe(false);
expect(results[0].cryptoVerified).toBe(false);
expect(results[0].coverageStatus).toBe('unknown');
expect(results[0].errorMessage).toMatch(
/not the \/Contents of this signature/
);
});
it('rejects a ByteRange that does not start at byte 0', async () => {
const pdfBytes = buildSignedPdf({
prefix:
'%PDF-1.7\n9 0 obj\n<< /Type /Catalog /Unsigned true >>\nendobj\n',
head: `${SIG_HEAD}${RANGE_PLACEHOLDER} /Contents `,
gap: (hex) => `<${hex}>`,
tail: SIG_TAIL,
});
const results = await validatePdfSignatures(pdfBytes);
expect(results).toHaveLength(1);
expect(results[0].isValid).toBe(false);
expect(results[0].cryptoVerified).toBe(false);
expect(results[0].errorMessage).toMatch(
/does not start at the beginning of the file/
);
});
it('rejects a gap that carries unsigned bytes after the hex signature', async () => {
const pdfBytes = buildSignedPdf({
head: `${SIG_HEAD}${RANGE_PLACEHOLDER} /Contents `,
gap: (hex) => `<${hex}> /Reason (Approved by the CEO)`,
tail: SIG_TAIL,
});
const results = await validatePdfSignatures(pdfBytes);
expect(results).toHaveLength(1);
expect(results[0].isValid).toBe(false);
expect(results[0].cryptoVerified).toBe(false);
expect(results[0].errorMessage).toMatch(
/not exactly a hex-encoded signature/
);
});
});
@@ -0,0 +1,306 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { createNodeByType, nodeRegistry } from '@/js/workflow/nodes/registry';
import type { BaseWorkflowNode } from '@/js/workflow/nodes/base-node';
import {
getControlSpec,
MAX_TEXT_CONTROL_LENGTH,
} from '@/js/workflow/nodes/control-specs';
import {
exportWorkflow,
getSavedTemplateNames,
loadWorkflow,
saveWorkflow,
} from '@/js/workflow/serialization';
vi.mock('heic2any', () => ({ default: vi.fn() }));
type Editor = Parameters<typeof loadWorkflow>[0];
type Area = Parameters<typeof loadWorkflow>[1];
const TEMPLATES_KEY = 'bento-pdf-workflow-templates';
function createFakeEditor(initial: BaseWorkflowNode[] = []) {
const nodes = new Map<string, BaseWorkflowNode>(
initial.map((node) => [node.id, node])
);
const editor = {
getNodes: () => [...nodes.values()],
getConnections: (): unknown[] => [],
getNode: (id: string) => nodes.get(id),
addNode: async (node: BaseWorkflowNode) => {
nodes.set(node.id, node);
return true;
},
removeNode: async (id: string) => nodes.delete(id),
removeConnection: async () => true,
addConnection: async () => true,
};
const area = {
nodeViews: new Map(),
translate: async () => true,
};
return {
nodes,
editor: editor as unknown as Editor,
area: area as unknown as Area,
};
}
function controlValue(node: BaseWorkflowNode, key: string): unknown {
const control = node.controls[key];
return control && 'value' in control ? control.value : undefined;
}
function setControl(node: BaseWorkflowNode, key: string, value: unknown) {
const control = node.controls[key];
if (!control || !('value' in control)) throw new Error(`missing ${key}`);
control.value = value;
}
function storeTemplate(name: string, json: string) {
localStorage.setItem(TEMPLATES_KEY, `{"${name}":${json}}`);
}
function workflowJson(
nodes: { type: string; controls: Record<string, unknown> }[]
): string {
return JSON.stringify({
version: 1,
nodes: nodes.map((node, index) => ({
id: `n${index}`,
type: node.type,
position: { x: 0, y: 0 },
controls: node.controls,
})),
connections: [],
});
}
async function loadNodes(json: string): Promise<BaseWorkflowNode[]> {
storeTemplate('imported', json);
const { editor, area, nodes } = createFakeEditor();
expect(await loadWorkflow(editor, area, 'imported')).toBe(true);
return [...nodes.values()];
}
function findNode(nodes: BaseWorkflowNode[], type: string): BaseWorkflowNode {
const node = nodes.find((candidate) => candidate.nodeType === type);
if (!node) throw new Error(`missing ${type}`);
return node;
}
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('workflow import validation', () => {
it('replaces select values outside the option list with the default', async () => {
const nodes = await loadNodes(
workflowJson([
{
type: 'RedactNode',
controls: { redactMode: 'Area', text: 'secret' },
},
{ type: 'NUpNode', controls: { pagesPerSheet: 7 } },
{ type: 'RotateNode', controls: { angle: '45' } },
{ type: 'CompressNode', controls: { algorithm: 'photon' } },
])
);
const redact = findNode(nodes, 'RedactNode');
expect(controlValue(redact, 'redactMode')).toBe('text');
expect(controlValue(redact, 'text')).toBe('secret');
expect(controlValue(findNode(nodes, 'NUpNode'), 'pagesPerSheet')).toBe(4);
expect(controlValue(findNode(nodes, 'RotateNode'), 'angle')).toBe('90');
expect(controlValue(findNode(nodes, 'CompressNode'), 'algorithm')).toBe(
'photon'
);
});
it('keeps only known multi-select values', async () => {
const nodes = await loadNodes(
workflowJson([
{ type: 'OCRNode', controls: { language: 'eng+../../x+fra+eng' } },
{ type: 'OCRNode', controls: { language: 'zz+../evil' } },
])
);
const [mixed, unknown] = nodes;
expect(controlValue(mixed, 'language')).toBe('eng+fra');
expect(controlValue(unknown, 'language')).toBe('eng');
});
it('clamps out-of-range numbers and resets non-finite ones', async () => {
const json = workflowJson([
{
type: 'WatermarkNode',
controls: { opacity: 5000, fontSize: 1, angle: 0 },
},
{ type: 'PosterizeNode', controls: { rows: 0, cols: 0 } },
{ type: 'RedactNode', controls: { x0: -50, x1: 0 } },
])
.replace('"angle":0', '"angle":-1e999')
.replace('"cols":0', '"cols":1e999')
.replace('"x1":0', '"x1":1e999');
const nodes = await loadNodes(json);
const watermark = findNode(nodes, 'WatermarkNode');
expect(controlValue(watermark, 'opacity')).toBe(100);
expect(controlValue(watermark, 'fontSize')).toBe(10);
expect(controlValue(watermark, 'angle')).toBe(-45);
const posterize = findNode(nodes, 'PosterizeNode');
expect(controlValue(posterize, 'rows')).toBe(1);
expect(controlValue(posterize, 'cols')).toBe(2);
const redact = findNode(nodes, 'RedactNode');
expect(controlValue(redact, 'x0')).toBe(0);
expect(controlValue(redact, 'x1')).toBe(200);
const scanner = createNodeByType('ScannerEffectNode');
expect(scanner?.sanitizeControlValue('resolution', Number.NaN)).toBe(150);
});
it('validates booleans, colors and text length', async () => {
const nodes = await loadNodes(
workflowJson([
{ type: 'SanitizeNode', controls: { removeJavascript: 'nope' } },
{ type: 'BackgroundColorNode', controls: { color: 'red;x' } },
{
type: 'EditMetadataNode',
controls: { title: 'a'.repeat(MAX_TEXT_CONTROL_LENGTH + 500) },
},
])
);
expect(
controlValue(findNode(nodes, 'SanitizeNode'), 'removeJavascript')
).toBe('true');
expect(controlValue(findNode(nodes, 'BackgroundColorNode'), 'color')).toBe(
'#ffffff'
);
expect(
String(controlValue(findNode(nodes, 'EditMetadataNode'), 'title'))
).toHaveLength(MAX_TEXT_CONTROL_LENGTH);
});
it('skips inherited property names used as node types', async () => {
const nodes = await loadNodes(
workflowJson([
{ type: 'constructor', controls: {} },
{ type: 'toString', controls: {} },
{ type: '__proto__', controls: {} },
{ type: 'hasOwnProperty', controls: {} },
{ type: 'RotateNode', controls: { constructor: 'x', angle: '180' } },
])
);
expect(nodes).toHaveLength(1);
expect(nodes[0].nodeType).toBe('RotateNode');
expect(controlValue(nodes[0], 'angle')).toBe('180');
expect(createNodeByType('constructor')).toBeNull();
expect(createNodeByType('toString')).toBeNull();
});
it('covers every registered node control with a valid spec', () => {
for (const type of Object.keys(nodeRegistry)) {
const node = createNodeByType(type);
expect(node).not.toBeNull();
if (!node) continue;
for (const key of Object.keys(node.controls)) {
const initial = controlValue(node, key);
const spec = getControlSpec(type, key);
if (/password/i.test(key)) {
expect(spec?.kind, `${type}.${key}`).toBe('password');
continue;
}
if (typeof initial === 'number') {
expect(['number', 'select'], `${type}.${key}`).toContain(spec?.kind);
expect(
node.sanitizeControlValue(key, Number.POSITIVE_INFINITY),
`${type}.${key}`
).toBe(initial);
}
expect(node.sanitizeControlValue(key, initial), `${type}.${key}`).toBe(
initial
);
}
}
});
});
describe('workflow password controls', () => {
function createSecretNodes(): BaseWorkflowNode[] {
const encrypt = createNodeByType('EncryptNode');
const decrypt = createNodeByType('DecryptNode');
if (!encrypt || !decrypt) throw new Error('missing nodes');
setControl(encrypt, 'userPassword', 'user-secret-1');
setControl(encrypt, 'ownerPassword', 'owner-secret-2');
setControl(decrypt, 'password', 'decrypt-secret-3');
return [encrypt, decrypt];
}
const secrets = ['user-secret-1', 'owner-secret-2', 'decrypt-secret-3'];
it('never writes passwords into saved templates', () => {
const { editor, area } = createFakeEditor(createSecretNodes());
saveWorkflow(editor, area, 'secure');
const stored = localStorage.getItem(TEMPLATES_KEY) ?? '';
for (const secret of secrets) expect(stored).not.toContain(secret);
expect(stored).toContain('"userPassword":""');
});
it('never writes passwords into exported workflow JSON', async () => {
const blobs: Blob[] = [];
vi.spyOn(URL, 'createObjectURL').mockImplementation((blob) => {
blobs.push(blob as Blob);
return 'blob:workflow';
});
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
const nodes = createSecretNodes();
const { editor, area } = createFakeEditor(nodes);
exportWorkflow(editor, area);
expect(blobs).toHaveLength(1);
const exported = await blobs[0].text();
for (const secret of secrets) expect(exported).not.toContain(secret);
expect(controlValue(nodes[0], 'userPassword')).toBe('user-secret-1');
});
it('drops passwords from imported workflows and legacy templates', async () => {
storeTemplate(
'legacy',
workflowJson([
{
type: 'EncryptNode',
controls: {
userPassword: 'user-secret-1',
ownerPassword: 'owner-secret-2',
},
},
{ type: 'DecryptNode', controls: { password: 'decrypt-secret-3' } },
])
);
expect(getSavedTemplateNames()).toEqual(['legacy']);
const stored = localStorage.getItem(TEMPLATES_KEY) ?? '';
for (const secret of secrets) expect(stored).not.toContain(secret);
const legacyJson = workflowJson([
{ type: 'EncryptNode', controls: { userPassword: 'user-secret-1' } },
]);
const nodes = await loadNodes(legacyJson);
expect(controlValue(nodes[0], 'userPassword')).toBe('');
expect(
createNodeByType('DecryptNode')?.sanitizeControlValue(
'password',
'decrypt-secret-3'
)
).toBe('');
});
});