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
+8 -1
View File
@@ -39,7 +39,14 @@ const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '.
// oidc_client_secret is reserved too: it is AES-encrypted at rest and only
// writable through PUT /sso below — a generic upsert would store plaintext
// and break decryption (#798).
const RESERVED_SETTING_KEYS = ['setup_wizard_completed', 'setup_token'];
// Branding *path* keys (GHSA-665x) are server-computed by the logo-upload
// flow and feed a filesystem logo resolver; letting the general settings PUT
// set them to arbitrary strings makes them an input to path resolution.
// Reserve them here — the dedicated upload endpoints still write them.
const RESERVED_SETTING_KEYS = [
'setup_wizard_completed', 'setup_token',
'branding_logo_path', 'branding_logo_path_dark', 'branding_watermark_logo_path',
];
// EVERY oidc_* key is reserved (#798 phase 2): the client secret would be
// clobbered with plaintext, and the policy/mapping keys carry invariants
// (role targets exist, break-glass account present) that only the dedicated
+12 -1
View File
@@ -153,9 +153,20 @@ router.get('/resolve/:identifier', handleAsync(async (req, res) => {
}
const { event, matchType, shareToken } = result;
const linkVariants = await buildShareLinkVariants({ slug: event.slug, shareToken });
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
// The share_token is a bearer secret. Only return it (and the share
// links/URLs that embed it) when the caller already proved they hold it —
// i.e. they resolved via the token or the full share link. A bare *slug*
// lookup (slugs appear in gallery URLs and are guessable) must NOT hand
// back the secret, or an anonymous caller could turn a known slug into
// share-link access to a no-password gallery (GHSA-rh8r).
const callerHasToken = matchType !== 'slug';
if (!callerHasToken) {
return res.json({ slug: event.slug, matchType, requires_password: requiresPassword });
}
const linkVariants = await buildShareLinkVariants({ slug: event.slug, shareToken });
res.json({
slug: event.slug,
token: shareToken,