fix(security): unauth share_token leak (HIGH) + restore path-traversal, logo file-read, branding path keys (#946)
* 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 <[email protected]>
This commit is contained in:
co-authored by
Paul Nothaft
parent
8cbb37310b
commit
9050affd8d
@@ -57,18 +57,34 @@ function generateCandidates(raw, storageRoot) {
|
||||
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 = [
|
||||
path.isAbsolute(value) ? value : null,
|
||||
// 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(process.cwd(), 'storage', stripped),
|
||||
path.join(process.cwd(), 'storage', 'uploads', 'logos', baseName),
|
||||
path.join(process.cwd(), 'storage', 'branding', baseName),
|
||||
].filter(Boolean);
|
||||
return [...new Set(candidates)];
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user