9050affd8d
* fix(security): stop unauth share_token leak + block restore path-traversal, logo-path file read, branding path keys * test: update resolveLogoFile for the c7x5 containment (reject outside-storage absolute paths, keep inside) * fix(security): codex round-1 — escape LIKE wildcards in share-link resolve, keep in-storage absolute logos, guard restore verification - shareLinkService: escape %/_ in the link_partial LIKE fallback so an anonymous /resolve/____… wildcard can't match an arbitrary share_link and leak its bearer token (reopened GHSA-rh8r). Explicit ESCAPE for SQLite. - resolveLogoFile: re-add the raw absolute candidate but keep it subject to the storage-root containment filter (GHSA-c7x5) so legit in-storage absolute logos resolve while /etc/passwd stays rejected. - restoreService: apply the same pathEscapes guard in post-restore verification so a skipped traversal entry isn't fs.access'd/hashed. --------- Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
219 lines
8.9 KiB
JavaScript
219 lines
8.9 KiB
JavaScript
/**
|
|
* resolveLogoFile — resolve a logo to an absolute existing file path.
|
|
*
|
|
* The CRM PDFs accept a logo from three different sources and each
|
|
* source may store the path in a different shape (absolute multer
|
|
* path, relative URL, bare filename, etc.). Rather than have the PDF
|
|
* renderer guess, we build an exhaustive candidate list HERE, return
|
|
* the first existing PNG/JPEG file, and log everything we tried when
|
|
* we come up empty.
|
|
*
|
|
* Why this lives in utils:
|
|
* - both invoiceService and quoteService need the same resolution
|
|
* - the renderer (pdfService) should NOT touch the DB or the
|
|
* filesystem-discovery rules; it just calls doc.image(path)
|
|
*
|
|
* Sources we accept (in priority order):
|
|
* 1. business_profile.logo_path — explicit per-CRM logo
|
|
* 2. app_settings.branding_logo_path — absolute multer path from
|
|
* Settings → Branding (preferred — already absolute)
|
|
* 3. app_settings.branding_logo_url — URL path from the same
|
|
* branding upload (fallback for older installs)
|
|
*
|
|
* For each source we try multiple candidate disk paths:
|
|
* - The raw value as an absolute path (if absolute)
|
|
* - STORAGE_PATH joined with the value (stripped of leading "/")
|
|
* - STORAGE_PATH/uploads/logos/<basename>
|
|
* - STORAGE_PATH/branding/<basename>
|
|
* - CWD/storage joined with the value (last-ditch for older
|
|
* docker-compose configs that didn't set STORAGE_PATH)
|
|
*
|
|
* Format filter:
|
|
* PDFKit only natively decodes PNG + JPEG. SVG/WebP/GIF/TIFF files
|
|
* are silently skipped here (with a warn log) so the rest of the
|
|
* PDF still renders rather than crashing on an unsupported format.
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
const { getStoragePath } = require('../config/storage');
|
|
const { getAppSetting } = require('./appSettings');
|
|
const logger = require('./logger');
|
|
|
|
// Bump this whenever the SVG→PNG rendering environment changes in a way that
|
|
// changes output, to invalidate previously-cached rasterisations.
|
|
// v2 (2026-06): backend image now ships fonts (fontconfig + brand fonts),
|
|
// so SVG logos with live <text> render their text instead of tofu boxes.
|
|
const RASTER_VERSION = 'v2-fonts';
|
|
|
|
const SUPPORTED_EXT = /\.(png|jpe?g)$/i;
|
|
// Formats PDFKit can't embed directly but `sharp` can rasterise into
|
|
// PNG for us. We transparently convert + cache.
|
|
const CONVERTIBLE_EXT = /\.(svg|webp|gif|tif|tiff|avif|heif|heic)$/i;
|
|
|
|
function generateCandidates(raw, storageRoot) {
|
|
const value = String(raw || '').trim();
|
|
if (!value) return [];
|
|
const stripped = value.replace(/^\/+/, '');
|
|
const baseName = path.basename(value);
|
|
const cwdStorage = path.join(process.cwd(), 'storage');
|
|
// Build candidate set; dedup at the end so we don't stat the same
|
|
// file twice when the inputs overlap.
|
|
const candidates = [
|
|
// Keep the raw absolute value as a candidate so a legitimate multer path
|
|
// (branding_logo_path is stored absolute) or an absolute logo inside a
|
|
// non-standard storage subdir still resolves. The containment filter
|
|
// below is what enforces safety — it drops this candidate when it points
|
|
// outside the storage roots, so `/etc/passwd` is still rejected.
|
|
...(path.isAbsolute(value) ? [value] : []),
|
|
path.join(storageRoot, stripped),
|
|
path.join(storageRoot, 'uploads', 'logos', baseName),
|
|
path.join(storageRoot, 'branding', baseName),
|
|
path.join(cwdStorage, stripped),
|
|
path.join(cwdStorage, 'uploads', 'logos', baseName),
|
|
path.join(cwdStorage, 'branding', baseName),
|
|
];
|
|
// GHSA-c7x5: only read logo files INSIDE the storage roots. An admin-set
|
|
// logo_path of `/etc/passwd` was previously rasterised into a PDF; the
|
|
// filter below drops any candidate (including the raw absolute one and any
|
|
// `..`-escaping stripped path) that resolves outside the roots. baseName-
|
|
// based candidates are inherently contained.
|
|
const roots = [path.resolve(storageRoot), path.resolve(cwdStorage)];
|
|
const contained = candidates.filter((c) => {
|
|
const r = path.resolve(c);
|
|
return roots.some((root) => r === root || r.startsWith(root + path.sep));
|
|
});
|
|
return [...new Set(contained)];
|
|
}
|
|
|
|
function pickExisting(candidates) {
|
|
for (const c of candidates) {
|
|
try {
|
|
if (fs.existsSync(c) && fs.statSync(c).isFile()) return c;
|
|
} catch (_) { /* ignore */ }
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Rasterise a non-PNG/JPEG source into PNG so PDFKit can embed it.
|
|
* The output is cached under STORAGE_PATH/cache/logo-png/, keyed by
|
|
* the source path + mtime + size — re-uploading the SVG invalidates
|
|
* the cache automatically without us having to clean up old entries.
|
|
*
|
|
* Returns the absolute cached PNG path on success, or null when
|
|
* `sharp` fails (corrupt SVG, unsupported feature inside the SVG,
|
|
* etc.). The caller logs + falls back to the name-only branch.
|
|
*/
|
|
async function rasteriseToPng(sourcePath, storageRoot) {
|
|
let sharp;
|
|
try {
|
|
sharp = require('sharp');
|
|
} catch (err) {
|
|
logger.warn('PDF logo rasterisation skipped — sharp not installed', { err: err.message });
|
|
return null;
|
|
}
|
|
try {
|
|
const stat = fs.statSync(sourcePath);
|
|
const cacheDir = path.join(storageRoot, 'cache', 'logo-png');
|
|
fs.mkdirSync(cacheDir, { recursive: true });
|
|
// Content-addressed cache: sha1(renderer version + src path + mtime ns
|
|
// + size). Including mtime means re-uploading the source invalidates the
|
|
// cache entry naturally. RASTER_VERSION is bumped whenever the rendering
|
|
// environment changes in a way that affects output (e.g. installing fonts
|
|
// so SVG <text> stops rendering as tofu) — bumping it invalidates every
|
|
// previously-cached PNG without having to clear the cache dir by hand.
|
|
const key = crypto.createHash('sha1')
|
|
.update(`${RASTER_VERSION}|${sourcePath}|${stat.mtimeMs}|${stat.size}`)
|
|
.digest('hex');
|
|
const cachedPath = path.join(cacheDir, `${key}.png`);
|
|
if (fs.existsSync(cachedPath)) {
|
|
return cachedPath;
|
|
}
|
|
// density: 384 gives a crisp render even when the SVG embeds at
|
|
// a small intrinsic size (PDFKit's `fit` will downscale, never
|
|
// upscale). 512px wide is more than enough for a letterhead
|
|
// logo.
|
|
await sharp(sourcePath, { density: 384 })
|
|
.resize({ width: 512, withoutEnlargement: false })
|
|
.png()
|
|
.toFile(cachedPath);
|
|
logger.info('PDF logo rasterised to PNG', { source: sourcePath, cached: cachedPath });
|
|
return cachedPath;
|
|
} catch (err) {
|
|
logger.warn('PDF logo rasterisation failed', {
|
|
source: sourcePath, err: err.message,
|
|
});
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {object} profile the business_profile row (or null)
|
|
* @returns {Promise<string|null>} absolute path to a usable PNG/JPEG, or null
|
|
*/
|
|
async function resolveLogoFile(profile) {
|
|
const storageRoot = getStoragePath();
|
|
const raws = [];
|
|
const profileLogoPath = (profile?.logo_path || '').toString().trim();
|
|
if (profileLogoPath) raws.push({ source: 'business_profile.logo_path', value: profileLogoPath });
|
|
|
|
try {
|
|
const brandingDisk = await getAppSetting('branding_logo_path');
|
|
if (brandingDisk && typeof brandingDisk === 'string' && brandingDisk.trim()) {
|
|
raws.push({ source: 'branding_logo_path', value: brandingDisk.trim() });
|
|
}
|
|
} catch (_) { /* ignore */ }
|
|
|
|
try {
|
|
const brandingUrl = await getAppSetting('branding_logo_url');
|
|
if (brandingUrl && typeof brandingUrl === 'string' && brandingUrl.trim()) {
|
|
raws.push({ source: 'branding_logo_url', value: brandingUrl.trim() });
|
|
}
|
|
} catch (_) { /* ignore */ }
|
|
|
|
if (raws.length === 0) return null;
|
|
|
|
for (const { source, value } of raws) {
|
|
const candidates = generateCandidates(value, storageRoot);
|
|
const found = pickExisting(candidates);
|
|
if (!found) continue;
|
|
if (SUPPORTED_EXT.test(found)) {
|
|
logger.info('Resolved PDF logo', { source, configured: value, resolved: found });
|
|
return found;
|
|
}
|
|
if (CONVERTIBLE_EXT.test(found)) {
|
|
// SVG / WebP / GIF / TIFF / AVIF — rasterise to PNG so PDFKit
|
|
// can embed it. Cached under STORAGE_PATH/cache/logo-png/ so
|
|
// repeated PDF renders don't re-encode the same source.
|
|
const rasterised = await rasteriseToPng(found, storageRoot);
|
|
if (rasterised) {
|
|
logger.info('Resolved PDF logo via rasterisation', {
|
|
source, configured: value, original: found, resolved: rasterised,
|
|
});
|
|
return rasterised;
|
|
}
|
|
logger.warn('PDF logo rasterisation produced no file; trying next source', {
|
|
source, configured: value, found,
|
|
});
|
|
continue;
|
|
}
|
|
// Unknown extension — try anyway, PDFKit may still accept it.
|
|
logger.warn('PDF logo has unusual extension; attempting to render as-is', {
|
|
source, configured: value, found,
|
|
});
|
|
return found;
|
|
}
|
|
|
|
logger.warn('PDF logo not found on disk after trying all sources', {
|
|
storageRoot,
|
|
sources: raws.map(({ source, value }) => ({
|
|
source, value, candidates: generateCandidates(value, storageRoot),
|
|
})),
|
|
});
|
|
return null;
|
|
}
|
|
|
|
module.exports = { resolveLogoFile };
|