diff --git a/backend/Dockerfile b/backend/Dockerfile index 7192433d..1042a217 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -50,8 +50,13 @@ RUN npm install -g npm@10 # Unicode fallback; picpeak's own brand fonts (assets/fonts/, the same files # PDFKit + the web UI use) are registered with fontconfig further down so the # logo's text renders in its actual typeface, not a fallback. +# poppler-utils provides `pdftoppm`, used to rasterise inbound supplier-invoice +# PDFs to flat PNGs server-side so the admin UI NEVER renders a raw (possibly +# malicious) PDF. pdftoppm does not execute embedded JS or fetch remote +# resources, so it doubles as the SSRF/phone-home guard for untrusted inbound +# documents (see docs/accounting-inbound-invoices.md). RUN apk add --no-cache dumb-init postgresql-client ffmpeg su-exec \ - fontconfig ttf-dejavu ttf-liberation && \ + fontconfig ttf-dejavu ttf-liberation poppler-utils && \ fc-cache -f # Create non-root user diff --git a/backend/src/routes/adminExpenses.js b/backend/src/routes/adminExpenses.js index 18ad3214..4839f3fb 100644 --- a/backend/src/routes/adminExpenses.js +++ b/backend/src/routes/adminExpenses.js @@ -18,6 +18,7 @@ const { createReadStream } = require('fs'); const { assertPathInside } = require('../utils/safePath'); const { db } = require('../database/db'); const expenseService = require('../services/expenseService'); +const rasterizeService = require('../services/rasterizeService'); const expenseCategoriesService = require('../services/expenseCategoriesService'); const router = express.Router(); @@ -118,11 +119,10 @@ router.get('/inbound/:id', requirePermission('accounting.view'), return successResponse(res, { document: await expenseService.getInbound(parseInt(req.params.id, 10)) }); })); -// Stream the stored file for in-browser preview (PDF / image). Admin-only, -// path-containment guarded. NOTE: this serves the raw file inline — the -// locked design's hardened path (rasterise in a network-isolated worker, never -// serve raw) is a follow-up; acceptable here as the admin views their own -// uploaded documents. +// Stream the stored file. Images render inline (already flat raster); PDFs are +// served as a DOWNLOAD only and are NEVER rendered inline in the browser — +// inline PDF preview goes through the rasterised /page/:n images below so a +// malicious PDF can't execute JS or phone home in the admin's session. router.get('/inbound/:id/file', requirePermission('accounting.view'), [param('id').isInt({ min: 1 })], handleAsync(async (req, res) => { @@ -131,10 +131,34 @@ router.get('/inbound/:id/file', requirePermission('accounting.view'), .first('file_path', 'mime_type'); if (!row || !row.file_path) return res.status(404).json({ error: 'File not found', code: 'NO_FILE' }); const safe = assertPathInside(row.file_path, [path.join(getStoragePath(), 'business-docs')]); + const isPdf = (row.mime_type || '').includes('pdf'); res.setHeader('Content-Type', row.mime_type || 'application/octet-stream'); + res.setHeader('Content-Disposition', isPdf ? 'attachment' : 'inline'); + res.setHeader('X-Content-Type-Options', 'nosniff'); + if (!isPdf) res.setHeader('Content-Security-Policy', "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'"); + createReadStream(safe).pipe(res); + })); + +// Rasterised PDF page (PNG) — the ONLY way a PDF is shown in-browser. The raw +// PDF never reaches the client. CSP-locked + nosniff. +router.get('/inbound/:id/page/:n', requirePermission('accounting.view'), + [param('id').isInt({ min: 1 }), param('n').isInt({ min: 1 })], + handleAsync(async (req, res) => { + validateRequest(req); + const id = parseInt(req.params.id, 10); + const row = await db('inbound_documents').where({ id }).first('file_path', 'mime_type', 'page_count'); + if (!row || !row.file_path) return res.status(404).json({ error: 'File not found', code: 'NO_FILE' }); + if (!(row.mime_type || '').includes('pdf')) return res.status(415).json({ error: 'Not a PDF', code: 'NOT_PDF' }); + const maxPage = row.page_count || 1; + const page = Math.min(Math.max(1, parseInt(req.params.n, 10)), maxPage); + const srcPdf = assertPathInside(row.file_path, [path.join(getStoragePath(), 'business-docs')]); + const pngPath = await rasterizeService.getRenderedPagePath(id, srcPdf, page); + const safePng = assertPathInside(pngPath, [path.join(getStoragePath(), 'business-docs')]); + res.setHeader('Content-Type', 'image/png'); res.setHeader('Content-Disposition', 'inline'); res.setHeader('X-Content-Type-Options', 'nosniff'); - createReadStream(safe).pipe(res); + res.setHeader('Content-Security-Policy', "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'"); + createReadStream(safePng).pipe(res); })); router.patch('/inbound/:id', requirePermission('accounting.manage'), diff --git a/backend/src/services/rasterizeService.js b/backend/src/services/rasterizeService.js new file mode 100644 index 00000000..77a84e8d --- /dev/null +++ b/backend/src/services/rasterizeService.js @@ -0,0 +1,69 @@ +/** + * PDF rasteriser for inbound supplier-invoice previews. + * + * Renders a single PDF page to a flat PNG via poppler's `pdftoppm` (an OS + * package installed in the Docker image — NOT a Node PDF library, so it doesn't + * count against the pdfkit+pdf-lib "no third PDF lib" rule). The admin UI shows + * ONLY these rasterised images, never the raw PDF — pdftoppm executes no + * embedded JavaScript and fetches no remote resources, so a malicious inbound + * PDF can neither run code in the browser nor phone home (SSRF/exfil). + * + * Rendered pages are cached on disk under + * storage/business-docs/inbound/rendered//page-.png + * and regenerated on demand. + */ +const { execFile } = require('child_process'); +const path = require('path'); +const fs = require('fs'); +const fsp = require('fs').promises; +const { getStoragePath } = require('../config/storage'); +const { AppError } = require('../utils/errors'); +const logger = require('../utils/logger'); + +const RENDER_TIMEOUT_MS = 25000; +const RENDER_DPI = 150; + +function renderedDir(docId) { + return path.join(getStoragePath(), 'business-docs', 'inbound', 'rendered', String(docId)); +} + +function execFileAsync(cmd, args, opts) { + return new Promise((resolve, reject) => { + execFile(cmd, args, opts, (err, stdout, stderr) => { + if (err) return reject(err); + return resolve({ stdout, stderr }); + }); + }); +} + +/** + * Rasterise one page of `pdfPath` to a cached PNG; returns its absolute path. + * @throws AppError 503 when pdftoppm is unavailable, 500 on render failure. + */ +async function getRenderedPagePath(docId, pdfPath, pageNum) { + const dir = renderedDir(docId); + const outPng = path.join(dir, `page-${pageNum}.png`); + if (fs.existsSync(outPng)) return outPng; + + await fsp.mkdir(dir, { recursive: true }); + const outPrefix = path.join(dir, `page-${pageNum}`); // pdftoppm -singlefile appends .png + try { + await execFileAsync('pdftoppm', [ + '-png', '-singlefile', '-r', String(RENDER_DPI), + '-f', String(pageNum), '-l', String(pageNum), + pdfPath, outPrefix, + ], { timeout: RENDER_TIMEOUT_MS, maxBuffer: 4 * 1024 * 1024 }); + } catch (e) { + if (e && e.code === 'ENOENT') { + throw new AppError('PDF rasteriser (pdftoppm) is not installed', 503, 'RASTERIZER_UNAVAILABLE'); + } + logger.error?.(`rasterizeService: pdftoppm failed for ${pdfPath} p${pageNum}: ${e.message}`); + throw new AppError('Failed to render PDF page', 500, 'RENDER_FAILED'); + } + if (!fs.existsSync(outPng)) { + throw new AppError('Failed to render PDF page', 500, 'RENDER_FAILED'); + } + return outPng; +} + +module.exports = { getRenderedPagePath }; diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 86d5b614..9ad15ad9 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -3430,7 +3430,11 @@ "noAmount": "Betrag nicht erfasst", "rebillHint": "Erstellt eine bearbeitbare geplante Rechnung beim Kunden. MwSt-/Steuerbehandlung ist v1 — mit Treuhänder prüfen.", "previewLoading": "Vorschau wird geladen…", - "qrHint": "An der letzten Seite geöffnet — der Schweizer QR-Einzahlschein sitzt meist unten.", + "previewError": "Vorschau nicht verfügbar — Felder manuell erfassen.", + "qrHint": "Letzte Seite — der Schweizer QR-Einzahlschein sitzt meist unten.", + "prevPage": "Zurück", + "nextPage": "Weiter", + "pageOf": "Seite {{n}} / {{total}}", "status": { "unsorted": "Neu", "categorized": "Kategorisiert", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index a5a03b2c..dd7f6c5b 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -3430,7 +3430,11 @@ "noAmount": "amount not entered", "rebillHint": "Creates an editable scheduled invoice on the client. VAT/tax handling is v1 — verify with your Treuhänder.", "previewLoading": "Loading preview…", - "qrHint": "Opened at the last page — the Swiss QR-bill usually sits at the bottom.", + "previewError": "Preview unavailable — enter the fields manually.", + "qrHint": "Showing the last page — the Swiss QR-bill usually sits at the bottom.", + "prevPage": "Prev", + "nextPage": "Next", + "pageOf": "Page {{n}} / {{total}}", "status": { "unsorted": "New", "categorized": "Categorized", diff --git a/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx b/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx index 5088d2f9..70480901 100644 --- a/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx +++ b/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx @@ -57,24 +57,28 @@ const TriageModal: React.FC<{ const totalMinor = Number.isFinite(amountMajor) ? Math.round(amountMajor * 100) : null; - // Authenticated file preview: fetch as a blob (Bearer auth) and embed. - // PDFs open at the last page scrolled into the QR-bill area (no OCR — the - // admin reads the payment slip and types the fields). + // Authenticated preview. PDFs are shown as SERVER-RASTERISED page images + // (the raw PDF never reaches the browser); images stream directly. Start on + // the LAST page — the Swiss QR-bill payment part sits at its bottom (no OCR; + // the admin reads the slip and types the fields). const isPdf = (doc.mimeType || '').includes('pdf'); - const [fileUrl, setFileUrl] = useState(null); + const pageCount = doc.pageCount || 1; + const [page, setPage] = useState(pageCount); + const [imgUrl, setImgUrl] = useState(null); + const [previewError, setPreviewError] = useState(false); useEffect(() => { let url: string | null = null; let cancelled = false; - accountingService.getInboundFileBlob(doc.id) - .then((blob) => { if (!cancelled) { url = URL.createObjectURL(blob); setFileUrl(url); } }) - .catch(() => { if (!cancelled) setFileUrl(null); }); + setImgUrl(null); + setPreviewError(false); + const fetcher = isPdf + ? accountingService.getInboundPageBlob(doc.id, page) + : accountingService.getInboundFileBlob(doc.id); + fetcher + .then((blob) => { if (!cancelled) { url = URL.createObjectURL(blob); setImgUrl(url); } }) + .catch(() => { if (!cancelled) setPreviewError(true); }); return () => { cancelled = true; if (url) URL.revokeObjectURL(url); }; - }, [doc.id]); - // #page= jumps to the last page; view=FitH,300 fits the width and - // positions ~300pt from the bottom — the Swiss QR-bill payment part. - const previewSrc = fileUrl - ? (isPdf ? `${fileUrl}#page=${doc.pageCount || 1}&view=FitH,300` : fileUrl) - : null; + }, [doc.id, isPdf, page]); const save = useMutation({ mutationFn: async () => { @@ -134,20 +138,29 @@ const TriageModal: React.FC<{
{/* Document preview — PDFs open at the last page (QR-bill area). */}
- {previewSrc ? ( - isPdf ? ( -