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:
Luca
2026-06-11 00:51:10 +02:00
parent 0c35ac43e6
commit e111522415
7 changed files with 157 additions and 32 deletions
+6 -1
View File
@@ -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
+30 -6
View File
@@ -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'),
+69
View File
@@ -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 };