feat(accounting): rasterise inbound PDFs server-side (never serve raw to browser)
Security hardening for inbound supplier-invoice previews. The admin UI no
longer renders raw PDFs — a malicious inbound PDF could otherwise run embedded
JS or phone home in the admin's session. Instead PDFs are rasterised to flat
PNGs server-side and only those images are shown.
- backend: new rasterizeService shells out to poppler `pdftoppm` (added to the
Docker image via apk poppler-utils — an OS package, NOT a Node PDF lib, so it
respects the pdfkit+pdf-lib "no third PDF lib" rule). pdftoppm executes no JS
and fetches no remote resources, so it doubles as the SSRF/phone-home guard.
Rendered pages cached under storage/business-docs/inbound/rendered/<id>/.
- GET /inbound/:id/page/:n streams the rasterised PNG (CSP default-src 'none'
+ nosniff). GET /inbound/:id/file now serves PDFs as a DOWNLOAD only
(Content-Disposition: attachment) — never inline; images still inline.
- frontend: triage preview switched from a raw-PDF <iframe> to rasterised page
images (getInboundPageBlob), defaulting to the LAST page (QR-bill) with
prev/next nav for multi-page PDFs; images stream as before.
- i18n: previewError / prevPage / nextPage / pageOf (EN + DE).
REQUIRES A BACKEND IMAGE REBUILD (Dockerfile adds poppler-utils) — a plain
`docker compose pull` of a stale image won't have pdftoppm; the route then
returns 503 RASTERIZER_UNAVAILABLE and the UI shows "preview unavailable".
Verified: node -c, a pdfkit->pdftoppm rasterise smoke test (renders + caches),
en/de JSON valid, npm run build green.
This commit is contained in:
+6
-1
@@ -50,8 +50,13 @@ RUN npm install -g npm@10
|
|||||||
# Unicode fallback; picpeak's own brand fonts (assets/fonts/, the same files
|
# 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
|
# PDFKit + the web UI use) are registered with fontconfig further down so the
|
||||||
# logo's text renders in its actual typeface, not a fallback.
|
# 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 \
|
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
|
fc-cache -f
|
||||||
|
|
||||||
# Create non-root user
|
# Create non-root user
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const { createReadStream } = require('fs');
|
|||||||
const { assertPathInside } = require('../utils/safePath');
|
const { assertPathInside } = require('../utils/safePath');
|
||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
const expenseService = require('../services/expenseService');
|
const expenseService = require('../services/expenseService');
|
||||||
|
const rasterizeService = require('../services/rasterizeService');
|
||||||
const expenseCategoriesService = require('../services/expenseCategoriesService');
|
const expenseCategoriesService = require('../services/expenseCategoriesService');
|
||||||
|
|
||||||
const router = express.Router();
|
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)) });
|
return successResponse(res, { document: await expenseService.getInbound(parseInt(req.params.id, 10)) });
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Stream the stored file for in-browser preview (PDF / image). Admin-only,
|
// Stream the stored file. Images render inline (already flat raster); PDFs are
|
||||||
// path-containment guarded. NOTE: this serves the raw file inline — the
|
// served as a DOWNLOAD only and are NEVER rendered inline in the browser —
|
||||||
// locked design's hardened path (rasterise in a network-isolated worker, never
|
// inline PDF preview goes through the rasterised /page/:n images below so a
|
||||||
// serve raw) is a follow-up; acceptable here as the admin views their own
|
// malicious PDF can't execute JS or phone home in the admin's session.
|
||||||
// uploaded documents.
|
|
||||||
router.get('/inbound/:id/file', requirePermission('accounting.view'),
|
router.get('/inbound/:id/file', requirePermission('accounting.view'),
|
||||||
[param('id').isInt({ min: 1 })],
|
[param('id').isInt({ min: 1 })],
|
||||||
handleAsync(async (req, res) => {
|
handleAsync(async (req, res) => {
|
||||||
@@ -131,10 +131,34 @@ router.get('/inbound/:id/file', requirePermission('accounting.view'),
|
|||||||
.first('file_path', 'mime_type');
|
.first('file_path', 'mime_type');
|
||||||
if (!row || !row.file_path) return res.status(404).json({ error: 'File not found', code: 'NO_FILE' });
|
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 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-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('Content-Disposition', 'inline');
|
||||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
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'),
|
router.patch('/inbound/:id', requirePermission('accounting.manage'),
|
||||||
|
|||||||
@@ -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/<docId>/page-<n>.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 };
|
||||||
@@ -3430,7 +3430,11 @@
|
|||||||
"noAmount": "Betrag nicht erfasst",
|
"noAmount": "Betrag nicht erfasst",
|
||||||
"rebillHint": "Erstellt eine bearbeitbare geplante Rechnung beim Kunden. MwSt-/Steuerbehandlung ist v1 — mit Treuhänder prüfen.",
|
"rebillHint": "Erstellt eine bearbeitbare geplante Rechnung beim Kunden. MwSt-/Steuerbehandlung ist v1 — mit Treuhänder prüfen.",
|
||||||
"previewLoading": "Vorschau wird geladen…",
|
"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": {
|
"status": {
|
||||||
"unsorted": "Neu",
|
"unsorted": "Neu",
|
||||||
"categorized": "Kategorisiert",
|
"categorized": "Kategorisiert",
|
||||||
|
|||||||
@@ -3430,7 +3430,11 @@
|
|||||||
"noAmount": "amount not entered",
|
"noAmount": "amount not entered",
|
||||||
"rebillHint": "Creates an editable scheduled invoice on the client. VAT/tax handling is v1 — verify with your Treuhänder.",
|
"rebillHint": "Creates an editable scheduled invoice on the client. VAT/tax handling is v1 — verify with your Treuhänder.",
|
||||||
"previewLoading": "Loading preview…",
|
"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": {
|
"status": {
|
||||||
"unsorted": "New",
|
"unsorted": "New",
|
||||||
"categorized": "Categorized",
|
"categorized": "Categorized",
|
||||||
|
|||||||
@@ -57,24 +57,28 @@ const TriageModal: React.FC<{
|
|||||||
|
|
||||||
const totalMinor = Number.isFinite(amountMajor) ? Math.round(amountMajor * 100) : null;
|
const totalMinor = Number.isFinite(amountMajor) ? Math.round(amountMajor * 100) : null;
|
||||||
|
|
||||||
// Authenticated file preview: fetch as a blob (Bearer auth) and embed.
|
// Authenticated preview. PDFs are shown as SERVER-RASTERISED page images
|
||||||
// PDFs open at the last page scrolled into the QR-bill area (no OCR — the
|
// (the raw PDF never reaches the browser); images stream directly. Start on
|
||||||
// admin reads the payment slip and types the fields).
|
// 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 isPdf = (doc.mimeType || '').includes('pdf');
|
||||||
const [fileUrl, setFileUrl] = useState<string | null>(null);
|
const pageCount = doc.pageCount || 1;
|
||||||
|
const [page, setPage] = useState(pageCount);
|
||||||
|
const [imgUrl, setImgUrl] = useState<string | null>(null);
|
||||||
|
const [previewError, setPreviewError] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let url: string | null = null;
|
let url: string | null = null;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
accountingService.getInboundFileBlob(doc.id)
|
setImgUrl(null);
|
||||||
.then((blob) => { if (!cancelled) { url = URL.createObjectURL(blob); setFileUrl(url); } })
|
setPreviewError(false);
|
||||||
.catch(() => { if (!cancelled) setFileUrl(null); });
|
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); };
|
return () => { cancelled = true; if (url) URL.revokeObjectURL(url); };
|
||||||
}, [doc.id]);
|
}, [doc.id, isPdf, page]);
|
||||||
// #page=<last> 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;
|
|
||||||
|
|
||||||
const save = useMutation({
|
const save = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
@@ -134,20 +138,29 @@ const TriageModal: React.FC<{
|
|||||||
<div className="px-5 py-4 grid grid-cols-1 lg:grid-cols-2 gap-5">
|
<div className="px-5 py-4 grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||||
{/* Document preview — PDFs open at the last page (QR-bill area). */}
|
{/* Document preview — PDFs open at the last page (QR-bill area). */}
|
||||||
<div className="order-2 lg:order-1">
|
<div className="order-2 lg:order-1">
|
||||||
{previewSrc ? (
|
<div className="overflow-auto rounded-md border border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800" style={{ maxHeight: '60vh' }}>
|
||||||
isPdf ? (
|
{previewError ? (
|
||||||
<iframe title="document preview" src={previewSrc} className="w-full h-[60vh] rounded-md border border-neutral-200 dark:border-neutral-700" />
|
<div className="flex h-[60vh] items-center justify-center px-3 text-center text-sm text-neutral-500">
|
||||||
|
{t('accounting.inbox.previewError', 'Preview unavailable — enter the fields manually.')}
|
||||||
|
</div>
|
||||||
|
) : imgUrl ? (
|
||||||
|
<img src={imgUrl} alt="document page" className="w-full h-auto" />
|
||||||
) : (
|
) : (
|
||||||
<img src={previewSrc} alt="document" className="w-full max-h-[60vh] object-contain rounded-md border border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800" />
|
<div className="flex h-[60vh] items-center justify-center text-sm text-neutral-500">
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<div className="flex h-[60vh] items-center justify-center rounded-md border border-dashed border-neutral-300 dark:border-neutral-700 text-sm text-neutral-500">
|
|
||||||
{t('accounting.inbox.previewLoading', 'Loading preview…')}
|
{t('accounting.inbox.previewLoading', 'Loading preview…')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
{isPdf && pageCount > 1 && (
|
||||||
|
<div className="mt-2 flex items-center justify-center gap-3 text-sm">
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1}>{t('accounting.inbox.prevPage', 'Prev')}</Button>
|
||||||
|
<span className="text-neutral-600 dark:text-neutral-400">{t('accounting.inbox.pageOf', 'Page {{n}} / {{total}}', { n: page, total: pageCount })}</span>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setPage((p) => Math.min(pageCount, p + 1))} disabled={page >= pageCount}>{t('accounting.inbox.nextPage', 'Next')}</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{isPdf && (
|
{isPdf && (
|
||||||
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
|
<p className="mt-1 text-center text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
{t('accounting.inbox.qrHint', 'Opened at the last page — the Swiss QR-bill usually sits at the bottom.')}
|
{t('accounting.inbox.qrHint', 'Showing the last page — the Swiss QR-bill usually sits at the bottom.')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -120,6 +120,12 @@ export const accountingService = {
|
|||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Rasterised PDF page (PNG) — the raw PDF is never sent to the browser.
|
||||||
|
async getInboundPageBlob(id: number, page: number): Promise<Blob> {
|
||||||
|
const { data } = await api.get(`/admin/expenses/inbound/${id}/page/${page}`, { responseType: 'blob' });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
async updateInbound(id: number, fields: Partial<Pick<InboundDocument,
|
async updateInbound(id: number, fields: Partial<Pick<InboundDocument,
|
||||||
'supplierName' | 'invoiceNumber' | 'invoiceDate' | 'dueDate' | 'currency' |
|
'supplierName' | 'invoiceNumber' | 'invoiceDate' | 'dueDate' | 'currency' |
|
||||||
'netAmountMinor' | 'vatAmountMinor' | 'totalAmountMinor' | 'iban' | 'paymentReference'>>): Promise<InboundDocument> {
|
'netAmountMinor' | 'vatAmountMinor' | 'totalAmountMinor' | 'iban' | 'paymentReference'>>): Promise<InboundDocument> {
|
||||||
|
|||||||
Reference in New Issue
Block a user