The req.admin.id fix activated reinjectCurrentAdmin(); hardening its preservation
logic (found across Codex review rounds of #811):
- MFA hijack: reinject wrote back only password_hash/is_active/
must_change_password, leaving a crafted backup's two_factor_* on the
operator's row — it could strip or replace their second factor. The email-
matched row is now updated with the operator's full AUTH set (login identity,
password, and all two_factor_* columns). Relationship/audit FKs (role_id,
created_by) are deliberately NOT forced from the snapshot: on a cross-instance
restore those pre-restore ids may be absent from the backup and would dangle
the FK (SQLite rolls back at commit); the restored row keeps its own valid
values.
- Cross-instance restore rollback / FK safety: reinject matched only by email,
so a backup shipping a different admin with the default `admin` username hit
UNIQUE(username) and rolled the whole restore back; email and username could
even collide on two different rows. Reconciliation is now non-destructive:
the email-matching row is updated in place (id preserved → restored FKs like
events.created_by stay valid); any different row holding the operator's
username is RENAMED, not deleted (deletion would fire ON DELETE actions /
dangle references); only when no row has the operator's email is a fresh row
inserted, with created_by nulled and an explicit max(id)+1 id (batchInsert
left the Postgres identity sequence unadvanced, so a sequence-based insert
could collide).
- Stale session after restore: admin_users ids shift on restore, but the
operator's live JWT is bound only to decoded.id (IP logged not enforced; the
backup controls password_changed_at). The route now revokes the token (result
checked and logged) and clears the admin cookie; the client redirects to a
fresh login via a sessionInvalidated flag. Cookie clear is the unconditional
guarantee.
Adds SQLite-backed reinject regression tests (in-place login/MFA restore with id
and FK columns preserved, username-only rename, email+username on different rows,
clean insert with created_by nulled) and the frontend redirect on
sessionInvalidated.
Deferred (design decisions / pre-existing, need a Postgres test env — see PR
discussion): global "invalidate all pre-restore sessions" cutoff; preserving the
operator's ROLE semantics across an RBAC-table replace; and resyncing Postgres
identity sequences after any restore (batchInsert leaves them behind max(id) —
pre-existing, affects every restored table).
The chunked video upload stored req.body.filename unmodified and later built
the merged path as path.join(tempDir, uploadMeta.filename). path.join does not
neutralise '../', so a filename like '../../uploads/logos/evil.svg' escaped the
temp dir on merge and overwrote arbitrary files. Requires admin with
photos.upload.
Fix: path.basename() the client filename in initializeUpload() and reject
names that collapse to nothing. Adds a regression test.
node-stream-zip's extract(null, root) writes each entry to path.join(root,
entry.name) without neutralising '../', so a crafted archive entry named
'../../uploads/logos/evil.svg' escaped the target dir and overwrote arbitrary
files (logos, .env, route files → RCE on source deploys). Requires admin with
archives.restore.
Adds assertZipEntriesWithin() to utils/safePath.js — a lexical containment
check run on the entry list BEFORE extract() — and guards both extract sinks:
adminArchives.js (the reported route) and picpeakImportService.js (the sibling
.picpeak import, same sink). Adds unit tests for traversal, absolute-path, and
sibling-prefix entries.
POST /auth/gallery/share-login validated only the 128-bit share token and then
minted a full type:'gallery' access token regardless of require_password —
computing requiresPassword at the end only to echo it, never enforce it. Anyone
holding a gallery's share link could read and download every photo in a
password-protected gallery via a direct API call, no password needed.
Fix: compute requiresPassword before minting; for a password-protected gallery
return { requires_password: true } with NO token and NO cookie. The client then
goes through /gallery/verify, which does bcrypt.compare the password. The public
(no-password) auto-login path is unchanged. The frontend already falls through
to the password prompt when share-login returns no token/event.
Adds route regression test covering the bypass, the public path, and bad tokens.
adminAuth populates req.admin, not req.user, so currentAdminId was always
undefined in the /api/admin/picpeak/import handler. reinjectCurrentAdmin()
then had no account to preserve and the admin_users table was fully replaced
by the uploaded backup — a crafted .picpeak let any admin with backup.restore
take over every admin account (critical). One-line fix: pass req.admin.id.
Closes GHSA-qxfx-4493-4v8f and its duplicate GHSA-pjp6-jcrj-3cr5.
The frontend image kept shipping vulnerable OS packages (nginx 1.28.3-r1,
curl/libcurl 8.19.0, c-ares 1.34.6) despite the apk upgrade line, for two
independent reasons:
1. The runtime stage's apk upgrade layer was cached indefinitely — the
CACHEBUST build-arg CI passes (github.run_number) was only declared in
the builder stage, and ARGs don't cross stage boundaries. Both
Dockerfiles now redeclare CACHEBUST in the runtime stage and consume it
in the apk RUN, so every build re-runs the upgrade and picks up current
Alpine security updates.
2. nginx itself can never upgrade via apk on the nginx.org-based image:
the bundled nginx-module-* packages pin the exact nginx version, so
Alpine's patched 1.28.3-r4 is unreachable (verified empirically —
apk add --upgrade nginx is a silent no-op). nginx fixes must come via
the base tag, so bump to nginx:1.30-alpine (current stable, 1.30.4 on
Alpine 3.24, same nginx.org conf.d layout — drop-in).
Verified: local image build scans clean with Trivy (0 OS findings, was 21);
container serves /health, SPA fallback, and BRAND_TITLE envsubst as non-root
nginx user.
Closes code-scanning alerts 371-374, 376-392 (nginx HTTP/2 & module CVEs,
curl CVE-2026-5773/-6276 + 6 medium, c-ares CVE-2026-33630).
Triggers the correct stable release from the stable branch (manifest
3.44.0 -> 3.45.0). Same fix as #774 (which fixes it on main for future
promotes); merging this to stable is what re-runs release-please
correctly for the promote that mis-fired as v2.7.0.
Same one-liner as #772 — adds stable to tests.yml push/pull_request
filters so the required backend/frontend checks report on this PR
instead of hanging on 'Expected — Waiting for status to be reported'.
Merge main (v3.83.0-beta.0) into stable to cut the next stable release.
Conflicts resolved toward main (the promoted code); stable release-control
files (manifest, CHANGELOG) restored separately.
Resolves 6 file conflicts arising from main carrying 7 weeks of
stable-channel work (security backports, release-please cuts, README
rewrite #281) that hadn't been forward-merged into beta.
Resolution per file:
- backend/package.json + package-lock.json — kept beta's version.
Beta is the superset; it intentionally drops `handlebars` (PR #367
removed the runtime require; the dep was the source of 2 criticals
+ 8 highs). Security-pinned versions (axios 1.15.2, nodemailer ^8,
i18next-http-backend ^3.0.2, multer ^2.0.2, tar >=7.5.13) already
match across both branches — no security regression.
- frontend/package.json + package-lock.json — kept beta's version.
Superset of main (adds marked, @types/node, i18next-cli, memfs,
i18n CLI scripts). Same security versions on both sides.
- README.md — kept main's version. PR #281 was an explicit cleanup
("shorter, cleaner, less AI-sounding"); beta had grown the file by
326 lines ad-hoc during the freeze. Preserving the rewrite.
- CHANGELOG.md — kept main's version. Release-please regenerates from
conventional commits on its next stable cut, so beta's accumulated
entries will roll into the new v3.55.0 release block automatically.
Auto-merged files carrying main's session-invalidation fix (#245)
flowed cleanly into beta's versions — sessionTimeout.js, adminAuth.js,
and the test files all merged without conflict, meaning beta had
already absorbed equivalent changes by independent paths.
CI on the underlying merge state was green on PR #568 prior to this
resolution; will re-run automatically on push.
Closes the open Trivy code-scanning alerts for app-side dependencies.
The npm-bundled CVEs in /usr/local/lib/node_modules/npm (picomatch,
brace-expansion, ip-address inside the Node image itself) are deferred
to a separate Node-base-image PR — they're build-environment-side and
need their own compatibility testing.
| Package | From | To | CVEs cleared |
|---|---|---|---|
| axios (backend + frontend) | 1.14.0 | 1.15.2 | CVE-2026-42264 (HIGH), CVE-2026-42043 (HIGH), CVE-2026-42035 (HIGH), CVE-2026-42033 (HIGH), CVE-2026-42044, CVE-2026-42042, CVE-2026-42041, CVE-2026-42040, CVE-2026-42039, CVE-2026-42038, CVE-2026-42037, CVE-2026-42036, CVE-2026-42034, CVE-2026-40175, CVE-2025-62718 |
| nodemailer (backend) | ^7.0.13 | ^8.0.5 | GHSA-vvjj-xcjg-gr5g, GHSA-c7w3-x93f-qmm8 |
| i18next-http-backend (frontend) | ^3.0.2 | ^3.0.5 | CVE-2026-41691 |
| uuid (backend) | ^11.1.0 | ^11.1.1 | CVE-2026-41907 |
| postcss (frontend, devDep) | ^8.4.21 | ^8.5.10 | CVE-2026-41305 |
For transitives whose direct parents haven't released a version that
picks up the patched range, pinned via npm overrides:
| Package | Min | CVE |
|---|---|---|
| follow-redirects (backend + frontend) | >=1.16.0 | GHSA-r4q5-vmmm-2653 |
| fast-xml-parser (backend) | >=5.7.0 | CVE-2026-41650 |
| @tootallnate/once (backend) | >=3.0.1 | CVE-2026-3449 |
| ip-address (backend) | >=10.1.1 | CVE-2026-42338 |
PR #268 originally pinned axios to 1.14.0 to avoid a supply-chain
attack on a specific compromised version range. The 1.15.x series
are post-incident upstream releases — clean. Confirmed with the
maintainer before bumping.
* `npx tsc --noEmit` (frontend) — clean
* `npx vite build` (frontend) — clean (~4s, existing bundle-size
warning, not new)
* Backend module-load smoke test — all critical modules load
(`auth`, `adminAuth` middleware, `emailProcessor`, `recaptcha`,
`storage`) with the new axios + nodemailer
* Lockfile re-verification — every targeted CVE now resolves to
the patched version range
* npm-bundled CVEs inside `/usr/local/lib/node_modules/npm/` —
picomatch CVE-2026-33671 (HIGH), CVE-2026-33672, brace-expansion
CVE-2026-33750, ip-address (npm-internal) CVE-2026-42338. These
live in the Node base image and require a Node base image bump
with its own compatibility testing — separate PR.
Targeting `beta` so the bumps go through the normal release-please
flow before promotion to `main`.
Stable release promoting the entire `beta` channel to `main`. Brings
~300 commits of features, fixes, and infrastructure improvements that
have been baked on the beta channel since v2.6.5.
## Major themes since v2.6.5
* Multi-administrator support with RBAC (super admin / admin / editor)
* Async upload pipeline (background worker pool for sharp/ffmpeg/EXIF/
watermark/webhooks; bytes-on-wire returns 202)
* Self-hosted webfonts (filesystem-driven scanner; replaces Google Fonts
CDN; GDPR-compliant)
* 8-token CI palette + force color mode (full theming across admin and
public site, with WCAG-safe contrast helpers)
* Native multi-arch Docker images (Apple Silicon + ARM64 Linux native)
* Native S3 storage backend (S3 + S3-compatible providers)
* Comprehensive video support (MP4/WebM/MOV upload, stream, play)
* Outbound webhooks for event/photo lifecycle (HMAC-signed)
* Gallery layout overhaul (decoupled header style, banner option,
theme-aware skeletons, lazy-loaded folder tree picker)
* Multilingual email templates (EN/DE/NL/PT/RU translations table)
* Bulk operations (delete with password gate, archive)
* Photo dimensions backfill (true masonry layout)
* Customer client access (review area before guest share)
* Image security (devtools detection, watermarking, right-click,
secure thumbnails)
## Notable bug fixes from beta
* `/auth/session` symmetry — three rounds of fixes (#350, #355, #363,
#398) for the admin-login redirect-loop family
* Email template renderer: handle {{#if}} conditionals, fix CSS leak in
plain-text fallback, gate publish-from-draft password placeholder,
gate external_url in public response
* Caller/template variable drift across gallery_created,
expiration_warning, archive_complete, gallery_expired
* Full-URL gallery_link in all email types (was path-only in 3 sites)
* ffmpeg/ffprobe via apk for Alpine compatibility (was glibc-bundled)
* Admin events search and counters not bounded to first 100 (#346)
## Conflict resolution notes
* `README.md` — kept main's leaner v2.6.5 rewrite (#281); added a
Contributors section adapted from PR #393.
* `DEPLOYMENT_GUIDE.md` — beta version (more recent, includes External
Media docs already backported to main).
* `CHANGELOG.md` — new 3.42.1 entry leads, beta's 3.x history follows,
main's 2.x entries appended below a divider so the historical chain
is preserved.
* `package.json` (backend + frontend) — beta's structure with version
bumped from `3.42.1-beta.0` → `3.42.1`.
* `package-lock.json` (backend + frontend) — regenerated via
`npm install --package-lock-only`.
* `.release-please-manifest.json` — bumped from `2.6.5` → `3.42.1` so
the next release-please run on main starts from the correct base.
## Pre-flight checks
* Frontend `tsc --noEmit` — clean
* Frontend `vite build` — clean (~3.5s, 2.6 MB main chunk; existing
warning about chunking, not new)
* Backend `npm test` — pre-existing failures in 6 integration suites
(DB-fixture-dependent, not regressions)
* Frontend `vitest` — pre-existing failures in
ThemeCustomizerEnhanced.test.tsx (missing QueryClientProvider after
PR #390 added useQuery; not a regression of this merge)
The pre-existing test failures are tracked as separate follow-ups and
do not block this release promotion.
Add the missing "External Media Library" chapter to DEPLOYMENT_GUIDE.md
that was referenced in the TOC but never written. Covers configuration,
Docker volume mounting, folder structure, usage workflow, limitations,
and troubleshooting.
Closes#270
Axios versions 1.14.1 and 0.30.4 were compromised on March 31, 2026
with a RAT dropper attributed to North Korean threat actor. Pin to
exact 1.14.0 (latest safe release) to prevent resolution to compromised
versions. See https://github.com/axios/axios/issues/10604
- Set password_changed_at when changing password via adminAuth route so
existing JWT tokens are rejected by the auth middleware check
- Enforce session timeout on first request with unseen tokens by checking
token iat against configured timeout (prevents bypass after server restart)
- Convert camelCase roleId/isActive to snake_case role_id/is_active in
frontend updateUser service (fixes silent role update failures)
Resolves GHSA-rqg3-47p5-vgwg
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.