diff --git a/e2e/csp.spec.ts b/e2e/csp.spec.ts new file mode 100644 index 0000000..0ee3ef1 --- /dev/null +++ b/e2e/csp.spec.ts @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { test, expect } from '@playwright/test' + +test('renders a PDF under a CSP without unsafe-eval', async ({ page }) => { + const evalErrors: string[] = [] + + await page.route('http://localhost:5173/', async (route) => { + const response = await route.fetch() + await route.fulfill({ + response, + headers: { + ...response.headers(), + 'content-security-policy': [ + "default-src 'self'", + "script-src 'self'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob:", + "connect-src 'self' https://mozilla.github.io ws://localhost:5173", + "worker-src 'self' blob:", + "font-src 'self' data:", + ].join('; '), + }, + }) + }) + + page.on('console', (message) => { + const text = message.text() + if (text.includes('unsafe-eval') || text.includes('call to eval() blocked by CSP')) { + evalErrors.push(text) + } + }) + + page.on('pageerror', (error) => { + if (error.message.includes('eval')) { + evalErrors.push(error.message) + } + }) + + await page.goto('/') + await page.getByRole('button', { name: 'Load sample PDF' }).click() + await expect(page.locator('canvas').first()).toBeVisible() + + expect(evalErrors).toEqual([]) +}) diff --git a/src/utils/asyncReader.ts b/src/utils/asyncReader.ts index 9be409e..f2cb059 100644 --- a/src/utils/asyncReader.ts +++ b/src/utils/asyncReader.ts @@ -52,6 +52,14 @@ async function getSharedWorker(pdfjs: PdfjsModule): Promise { return sharedWorker } +function getSafePdfjsOptions(options: Record) { + return { + ...options, + isEvalSupported: false, + enableScripting: false, + } +} + export function setWorkerPath(path: string) { workerSrcOverride = path sharedWorker = null @@ -97,19 +105,20 @@ export async function readAsPDF( ): Promise { const pdfjs = await loadPdfjs() const worker = await getSharedWorker(pdfjs) + const pdfjsOptions = getSafePdfjsOptions(options) const isArrayBuffer = file instanceof ArrayBuffer const isView = ArrayBuffer.isView(file) const isBlob = typeof Blob !== 'undefined' && file instanceof Blob if (file && typeof file === 'object' && !isArrayBuffer && !isView && !isBlob) { - return pdfjs.getDocument({ ...(file as Record), ...options, worker }).promise + return pdfjs.getDocument({ ...(file as Record), ...pdfjsOptions, worker }).promise } if (typeof file === 'string') { - return pdfjs.getDocument({ url: file, ...options, worker }).promise + return pdfjs.getDocument({ url: file, ...pdfjsOptions, worker }).promise } if (isBlob) { const data = await readAsArrayBuffer(file as Blob) - return pdfjs.getDocument({ data, ...options, worker }).promise + return pdfjs.getDocument({ data, ...pdfjsOptions, worker }).promise } const data = isArrayBuffer ? (file as ArrayBuffer) @@ -119,5 +128,5 @@ export async function readAsPDF( (file as ArrayBufferView).byteLength ) - return pdfjs.getDocument({ data, ...options, worker }).promise + return pdfjs.getDocument({ data, ...pdfjsOptions, worker }).promise } diff --git a/tests/utils/asyncReader.spec.ts b/tests/utils/asyncReader.spec.ts new file mode 100644 index 0000000..d92d3f5 --- /dev/null +++ b/tests/utils/asyncReader.spec.ts @@ -0,0 +1,71 @@ +// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import * as pdfjs from 'pdfjs-dist' + +vi.mock('pdfjs-dist', () => { + const GlobalWorkerOptions = { + workerSrc: '/pdf.worker.min.mjs', + } + const PDFWorker = class PDFWorker {} + const getDocument = vi.fn(() => ({ + promise: Promise.resolve({}), + })) + const mockedModule = { + GlobalWorkerOptions, + PDFWorker, + getDocument, + } + + return { + ...mockedModule, + default: mockedModule, + } +}) + +vi.mock('pdfjs-dist/legacy/build/pdf.worker.min.mjs?url', () => ({ + default: '/pdf.worker.min.mjs', +})) + +import { readAsPDF } from '../../src/utils/asyncReader' + +describe('readAsPDF', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('disables eval and PDF scripting by default', async () => { + await readAsPDF('https://example.com/sample.pdf') + + expect(pdfjs.getDocument).toHaveBeenCalledWith(expect.objectContaining({ + url: 'https://example.com/sample.pdf', + isEvalSupported: false, + enableScripting: false, + })) + }) + + it('does not allow callers to re-enable eval or PDF scripting', async () => { + await readAsPDF('https://example.com/sample.pdf', { + isEvalSupported: true, + enableScripting: true, + }) + + expect(pdfjs.getDocument).toHaveBeenCalledWith(expect.objectContaining({ + isEvalSupported: false, + enableScripting: false, + })) + }) + + it('applies the safe defaults when loading binary PDF data', async () => { + const data = new Uint8Array([1, 2, 3]) + + await readAsPDF(data) + + expect(pdfjs.getDocument).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.any(Uint8Array), + isEvalSupported: false, + enableScripting: false, + })) + }) +}) diff --git a/vitest.config.ts b/vitest.config.mts similarity index 100% rename from vitest.config.ts rename to vitest.config.mts