Trivy flags the backend image on two sanitize-html advisories, both
fixed upstream:
- CVE-2026-63670 (fixed 2.17.6): a literal solidus after a raw-text end
tag (`</textarea/>`) is treated as text by htmlparser2 and re-emitted
unescaped, so disallowed markup passes when textarea or xmp is in
allowedTags.
- CVE-2026-84371 (fixed 2.17.7): an SVG SMIL animation whose
attributeName selects href lets the sibling values/from/to/by
attributes carry URLs past the scheme policy.
2.17.5 -> 2.17.7, exact pin as before. The new version brings its own
htmlparser2 12 / domhandler 6 / domutils 4 / dom-serializer 3 /
entities 8 tree under node_modules/sanitize-html; nothing else in the
lock moves.
That tree is ESM-only, so the backend now needs unflagged require(esm):
Node 20.19+ or 22.12+. The image is node:22-alpine and CI runs 22, but
engines.node still admitted 22.0-22.11, where require('sanitize-html')
throws ERR_REQUIRE_ESM at startup (publicSiteService loads it during
initialisation). engines is now ^20.19.0 || >=22.12.0 and the native
setup script's Node check enforces the same range instead of accepting
any 22.x. On the supported versions the sanitiser behaves identically
to 2.17.5 on the tracker and newsletter fixtures.
Jest 29's CommonJS registry cannot evaluate ESM either, so every suite
importing a route or service that uses the sanitiser would fail at
import. jest.config.js now maps `sanitize-html` to jest.sanitizeHtml.js,
which hands that one module to Node's real loader via
process.getBuiltinModule('module') — a plain require('module') inside
Jest is Jest's wrapper and returns an empty object for this package.
Verified against a real 2.17.7 install: the sanitiser suites and a
settings route suite pass; without the mapper they fail with "Cannot
use import statement outside a module".
Stable twin of #1083, scoped to what exists on this branch.
docker-build.yml — set ignore-unfixed on both Trivy steps. Stable has
the backend and frontend legs only (no aio, no ml), so two steps here
against four on main. Base-image CVEs with no released fix are not
actionable: the Dockerfiles already run `apt-get upgrade -y` behind a
CACHEBUST, so a fix lands in the next build automatically. Reporting
them buries anything someone can actually act on.
backend — deepmerge-ts <8.0.0 has a stack-exhaustion advisory
(CVE-2026-40345, high) reached via mailparser -> html-to-text, which
pins ^7.1.5 so npm cannot get there alone. Stable carries the same
mailparser ^3.9.9 and the same 3-high exposure as main. Not reachable
in our code: html-to-text only feeds deepmerge-ts its options object,
never parsed email content. npm audit on this branch goes 3 high -> 0.
The ml/Dockerfile half of #1083 has no counterpart here — the face
sidecar does not exist on stable, so there is nothing to drift.
Verified on stable itself rather than assuming main's results carry:
npm audit 3 high -> 0, html-to-text exercised end-to-end through
simpleParser, and jest at 1577 passed. The 5 failing suites (20 tests)
fail identically on clean origin/stable with these changes stashed.
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
Backport of #987. stable carried the same vulnerable versions.
brace-expansion 5.0.8 -> 5.0.9 CVE-2026-69152 (high)
ip-address 10.2.0 -> 10.4.0 CVE-2026-69192 (high), CVE-2026-54272,
CVE-2026-69198 (medium) — SSRF and
trust-boundary bypasses
postcss 8.5.18 -> 8.5.23 CVE-2026-69153 (medium)
Lockfile holds exactly one entry per package, all at or above the fixed
version; the image installs via npm ci --omit=dev.
* fix(uploads): prevent cross-photo contamination from filename collisions and non-atomic writes (#931)
* test: pin the suffixed photo filename format in the NFD pipeline suite (#931)
* chore(deps): promote p-limit to a direct dependency for the watermark limiter (#931)
* test: make the suffix-uniqueness check deterministic-in-practice (#931)
* fix(uploads): widen the anti-collision suffix to 48 bits (#931)
* fix(uploads): hide staging files from list() + share one watermark limiter process-wide (#931)
* fix(uploads): reclaim orphaned staging files + revalidate watermark settings in queued jobs (#931)
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* fix(security): close the 5 open Trivy alerts — dep bumps + drop npm from the runtime image
Backend deps:
- postcss 8.5.10 -> 8.5.18 (CVE-2026-45623, GHSA-r28c-9q8g-f849; the pin
exists to force sanitize-html's transitive copy onto a fixed version)
- tar pin/override >=7.5.16 -> >=7.5.21, resolves 7.5.22
(GHSA-r292-9mhp-454m)
Runtime image:
- Remove the npm CLI from the final stage instead of upgrading it: npm's
bundled node_modules ship tar 7.5.19 and brace-expansion 5.0.7 (no npm
release bundles the fixed versions — checked 11.18.0 and 12.0.1), and
npm never runs in production. wait-for-db.sh now invokes the migration
runners via node directly. This ends the recurring npm-bundled-CVE
alert class; the previous 'npm install -g npm@11' line was itself a
patch for the last batch. (stable)
* fix(restore): run post-restore migrations via node — the image ships no npm
restoreService still shelled out to 'npm run migrate:safe' after a
restore; with npm removed from the runtime image that would ENOENT into
the non-fatal catch, silently leaving a restored older backup on a
schema behind the running code until the next container restart. Invoke
migrations/run-migrations-safe.js through node directly, matching
wait-for-db.sh. The PR #596 source-contract test now pins the new
invocation. (stable)
* fix(backup): make backup settings actually apply (#871) (stable)
- Wire the What-to-Backup toggles into the walker: honor
backup_include_thumbnails / backup_include_photos (opt-out,
default ON) and accept the UI's backup_include_archives spelling
for the archived gate (the engine expected _archived, so the
Archives checkbox silently never worked).
- Fix the 167.6 TB dashboard size: file_size_bytes is a bigint that
node-postgres returns as a string, and the S3 path concatenated it
onto the byte counter; coerce to Number at the source.
- Compute the real next scheduled run (cron-parser) and return it as
nextBackup; the UI read a field the API never sent and rendered a
hardcoded 'Not scheduled'. A named schedule label now beats the
stray default cron the UI always sent, which silently turned
weekly schedules into daily 03:00 runs.
- Never back up filesystem noise (.nfs* silly-renames, .DS_Store,
Thumbs.db) and honor backup_exclude_patterns in the walker
(previously rsync-only).
- Remove the compression/encryption toggles from the configuration
UI: no backend implementation exists, and collecting an encryption
passphrase while uploading plaintext is a false promise.
* fix(backup): close the review gaps in the settings wiring (stable)
- The UI's backup_include_archives now beats the migration-seeded
backup_include_archived: every install has the singular key seeded
true, so the alias-only-when-absent lookup made unchecking Archives
a no-op.
- rsync destinations now receive the de-selected What-to-Backup paths
and the noise filters as anchored --exclude args; previously rsync
synced the whole storage root and the walker's selection only shaped
the manifest, which then misreported what was actually transferred.
- Escape regex metacharacters in the walker's glob matcher: '.nfs*'
compiled to /^.nfs.*$/ whose leading dot matched any character, so
files like anfs-photo.jpg were silently dropped from backups.
- The Backup Coverage report now uses the same gate as the walker
(new 'skipped-by-setting' status) instead of re-implementing it
without the opt-out toggles and the archives alias.
* fix(backup): make the coverage diagnostics agree with the walker
- The coverage table shows the alias-aware flag value the gate actually
used, instead of the seeded backup_include_archived shadowed by the
UI's plural key (true next to a 'Gated off' badge).
- skipped-by-setting paths are now counted in the coverage summary
(backend, TS contract, summary card, EN/DE locales) so the totals
reconcile again when Photos or Thumbnails is unchecked.
- The form's thumbnail default now matches the backend's never-saved
fallback (include): the checkbox no longer shows 'off' while
thumbnails are being backed up, and saving an unrelated setting no
longer flips the backup scope. (stable)
* fix(backup): keep custom crons, exclude disabled rows from rsync, normalize flag display
- Saving a named schedule no longer wipes the stored custom cron: the
backend already prefers the label, so the cron field stays inert for
named schedules and is preserved for switching back to Custom. A
custom schedule now validates the 5-field expression before saving
(the backend silently fell back to daily 02:00 on a blank value).
- resolveExcludedBackupPaths now also returns rows disabled via
include_in_default, so rsync excludes them; the enabled-only loader
hid them and rsync transferred their contents anyway.
- The coverage table normalizes flag values like the walker does —
Boolean('false') displayed true beside a gated-off badge. (stable)
* fix(security): bump backend deps to close all open Trivy code-scanning alerts (stable)
- axios 1.16.0 -> 1.18.1 (GHSA-gcfj-64vw-6mp9 high + 10 medium advisories)
- sharp 0.34.3 -> 0.35.3 (GHSA-f88m-g3jw-g9cj, inherited libvips CVEs)
- mailparser 3.9.9 -> 3.9.14 (pulls linkify-it 5.0.2, CVE-2026-59887)
- brace-expansion override >=5.0.6 -> >=5.0.7 (CVE-2026-13149)
- body-parser 1.20.4 -> 1.20.6 via lockfile refresh (CVE-2026-12590)
* fix(images): migrate removed sharp failOnError option and enforce Node >=20.9 (stable)
sharp 0.35 drops the deprecated failOnError constructor option, so
recoverably corrupt images would start failing upload validation and
thumbnail generation; use the failOn: 'none' equivalent instead.
sharp 0.35 also requires Node >=20.9: declare it in engines and make
picpeak-setup.sh compare the full version instead of only the major,
so native installs on Node 20.3-20.8 upgrade instead of breaking.
* fix(setup): align the Node floor with the whole dependency tree and gate native updates (stable)
html-to-text@10 needs Node >=20.19 and the glob/minimatch family excludes
Node 21, so declare engines as ^20.19.0 || >=22 and enforce the same range
in picpeak-setup.sh. Also run install_nodejs at the start of
update_native_installation so existing native installs on an old Node get
upgraded before the service is stopped, instead of restarting broken.
* fix(setup): make the update-path Node gate actually work (stable)
--update dispatches before detect_os, so install_nodejs saw an empty
PACKAGE_MANAGER, matched no install branch, and reported success on the
old runtime. Detect the OS on demand and re-verify the installed version
afterwards, failing loudly (before the service is stopped) when the
runtime still misses the engines range, e.g. a Node 21 that package
managers refuse to downgrade.
Backend for #738. Real TOTP 2FA for admin accounts, all roles incl.
super_admin (closes#735).
- mfaService: otplib TOTP; AES-256-GCM encryption of the secret at rest
(key derived from MFA_ENCRYPTION_KEY or JWT_SECRET); bcrypt-hashed,
single-use recovery codes; otpauth URI + QR.
- Migration 151: adds two_factor_recovery_codes + two_factor_enrolled_at
(secret/enabled columns already existed from legacy 016).
- Enrollment endpoints (behind adminAuth, per-user): GET /mfa/status,
POST /mfa/{setup,enable,disable,recovery-codes}. Disable/regenerate
require a current code so a hijacked session can't strip 2FA.
- Login challenge: /admin/login returns {mfaRequired, mfaToken} (no
session) when 2FA is on; /admin/login/mfa exchanges a TOTP or recovery
code for the session. Lockout counter is NOT reset until the second
factor passes, so MFA brute-force is rate-limited too.
- CLI break-glass: scripts/reset-admin-mfa.js --email <e> | --all --yes,
audit-logged, matches reset-admin-password.js convention.
- Docs + optional MFA_ENCRYPTION_KEY env.
Verified end-to-end on a live backend: enroll (super_admin), challenge,
TOTP + single-use recovery login, disable, and CLI reset.