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
+34 -1
View File
@@ -12,6 +12,15 @@ const backupManifest = require('./backupManifest');
const S3StorageAdapter = require('./storage/s3Storage');
const { queueEmail } = require('./emailProcessor');
const { formatBoolean } = require('../utils/dbCompat');
// A manifest is attacker-influenceable (hand-crafted backup). Reject any
// entry path that would resolve OUTSIDE its intended base directory
// (traversal / absolute path) before any fs write. The target may not exist
// yet, so resolve rather than realpath (GHSA-fm58).
function pathEscapes(baseDir, candidate) {
const rel = path.relative(path.resolve(baseDir), path.resolve(candidate));
return !rel || rel === '..' || rel.startsWith('..' + path.sep) || path.isAbsolute(rel);
}
const { formatBytes } = require('../utils/formatBytes');
const os = require('os');
@@ -771,7 +780,14 @@ class RestoreService {
for (const file of filesToDownload) {
const s3Key = path.posix.join(prefix, file.path);
const localFilePath = path.join(localPath, file.path);
// Containment guard (GHSA-fm58): reject a manifest path that would
// write outside the download staging dir.
if (pathEscapes(localPath, localFilePath)) {
this.log('error', `Refusing unsafe manifest path on download: ${file.path}`);
continue;
}
await fs.mkdir(path.dirname(localFilePath), { recursive: true });
try {
@@ -1204,6 +1220,14 @@ END $$;`
const sourcePath = path.join(backupPath, file.path);
const targetPath = path.join(storagePath, file.path);
// Containment guard (GHSA-fm58): a crafted manifest path like
// `../../etc/cron.d/x` would otherwise escape the storage root and
// overwrite arbitrary files. Skip any entry that escapes.
if (pathEscapes(backupPath, sourcePath) || pathEscapes(storagePath, targetPath)) {
errors.push(`Refusing unsafe manifest path: ${file.path}`);
continue;
}
// Check if source file exists
try {
await fs.access(sourcePath);
@@ -1375,6 +1399,15 @@ END $$;`
for (const file of filesToVerify) {
const filePath = path.join(storagePath, file.path);
// Same containment guard as performFilesRestore: a traversal
// manifest entry (e.g. `../../etc/passwd`) was skipped during the
// restore, so it must not be fs.access'd/hashed here either —
// otherwise an existing outside file makes the skipped entry look
// "verified" (and we'd read an arbitrary file off disk).
if (pathEscapes(storagePath, filePath)) {
verification.errors.push(`Refusing unsafe manifest path on verification: ${file.path}`);
continue;
}
try {
await fs.access(filePath);
+10 -1
View File
@@ -159,7 +159,16 @@ const resolveShareIdentifier = async (identifier) => {
return { event, matchType: 'link', shareToken: getEventShareToken(event) };
}
event = await baseQuery.clone().where('share_link', 'like', `%/${trimmed}`).first();
// GHSA-rh8r hardening: `trimmed` is attacker-controlled, so escape LIKE
// wildcards (`%`, `_`, and the escape char itself) before embedding it.
// Otherwise an anonymous `/resolve/________…________` (32 underscores)
// matches ANY share_link via single-char wildcards, resolves as
// matchType 'link_partial', and the /resolve route hands back the
// gallery's bearer token — reopening the very hole the token-withholding
// fix closed. Explicit ESCAPE clause because SQLite has no default LIKE
// escape character (Postgres defaults to backslash, but we set it for both).
const likeTail = `%/${trimmed.replace(/[\\%_]/g, (c) => `\\${c}`)}`;
event = await baseQuery.clone().whereRaw('share_link LIKE ? ESCAPE \'\\\'', [likeTail]).first();
if (event) {
return { event, matchType: 'link_partial', shareToken: getEventShareToken(event) };
}