fix(security): contain logo, favicon and PDF-logo unlinks to their upload directories

Settings > Branding persisted logo_url / favicon_url verbatim and on clear
unlinked path.join(storage, url) behind a startsWith('/uploads/logos/')
check, which '..' segments pass. The business-profile PDF logo did the same
behind a /pdf-logo-\d+\./ marker test, and used absolute values as given.
Either let a settings.edit or settings.banking holder delete any file the
process can reach.

Both now resolve through helpers in utils/safePath that only ever name a
flat leaf inside the fixed directory. The /favicon.ico streamer is narrowed
the same way: it contained to the whole uploads/ root, which also holds
signed contracts and transfer files.
This commit is contained in:
Paul Nothaft
2026-09-03 10:44:55 +02:00
parent 0ca0e4a922
commit 3e46530072
5 changed files with 124 additions and 22 deletions
+42
View File
@@ -171,8 +171,50 @@ function assertZipEntriesWithin(entries, extractRoot) {
}
}
/**
* Resolve a stored `/uploads/<kind>/<file>` URL to the file it names inside
* that upload directory, or null when the value is not one of ours.
*
* Only the basename is trusted: the URL comes from an admin-writable
* setting, and `path.join(storage, url)` after a `startsWith('/uploads/…')`
* check still collapses `..` segments, so it could name any file the process
* can delete. Restricting to a flat leaf inside the fixed directory is the
* whole control -- the upload routes only ever write flat filenames there.
*
* @param {string} url stored value, e.g. "/uploads/logos/logo-1.png"
* @param {string} kind "logos" | "favicons"
* @param {string} storageRoot the root the writer used (callers differ)
*/
function uploadedAssetPath(url, kind, storageRoot) {
if (!url || typeof url !== 'string') return null;
const prefix = `/uploads/${kind}/`;
if (!url.startsWith(prefix)) return null;
const leaf = url.slice(prefix.length);
if (!leaf || leaf === '.' || leaf === '..' || path.basename(leaf) !== leaf) return null;
return path.join(storageRoot, 'uploads', kind, leaf);
}
/**
* Resolve business_profile.logo_path to the file the PDF-logo upload route
* wrote, or null. logo_path is a free-text field on the profile PUT (an
* admin may point it at a file managed elsewhere), so it must never be
* unlinked as given: a `/pdf-logo-\d+\./` marker test plus path.join let
* `pdf-logo-1./../../../<anything>` -- or any absolute path containing the
* marker -- delete arbitrary files. Only a flat `pdf-logo-<n>.<ext>` leaf
* inside uploads/logos is ever named.
*/
function uploadedPdfLogoPath(logoPath, storageRoot) {
if (!logoPath || typeof logoPath !== 'string') return null;
const normalized = logoPath.replace(/^\/+/, '');
const match = /^uploads\/logos\/(pdf-logo-\d+\.[A-Za-z0-9]+)$/.exec(normalized);
if (!match) return null;
return path.join(storageRoot, 'uploads', 'logos', match[1]);
}
module.exports = {
assertPathInside,
assertContractPdfPath,
assertZipEntriesWithin,
uploadedAssetPath,
uploadedPdfLogoPath,
};