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:
Paul Nothaft
2026-08-02 08:37:48 +02:00
committed by GitHub
co-authored by Paul Nothaft
parent 8cbb37310b
commit 9050affd8d
7 changed files with 187 additions and 12 deletions
+22 -6
View File
@@ -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) {