Compare commits

...

15 Commits

Author SHA1 Message Date
Paul Nothaft 93d4ae68f4 chore(stable): release 3.45.15 (#1017)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-10 20:52:00 +02:00
Paul Nothaft 2bdb1204fe fix(slideshow): stop "no crop" fit letterboxing a pre-cropped frame (stable) (#1015) (#1019)
Backport of #1018 to the curated channel; the reporter on #1015 is running v3.45.14.

The slideshow resolved its image as preview_url || hero_url || url. preview_url is only emitted when lightbox_preview_enabled is on (default false), so a default install fell through to hero_url — the 1920x1080 fit:'cover' centre crop built for gallery header banners. object-fit: contain then letterboxed an already-cropped 16:9 frame.

Emits slideshow_url (same aspect-preserved preview tier) unconditionally for image photos; the show prefers it and never falls back to hero_url. preview_url stays gated so the lightbox opt-in is unchanged.
2026-08-10 13:32:24 +02:00
Paul Nothaft cee0a380a6 fix(deps): bump nanoid and js-yaml out of two HIGH advisories (stable) (#1014)
Backport of #1013 to the curated channel. Both are production dependencies of the backend image (npm ci --omit=dev):

- nanoid 3.3.16 -> 3.3.18 (CVE-2026-67213, infinite loop in customAlphabet)
- js-yaml 4.3.0 -> 4.3.1 (GHSA-5p4m-2wfm-xmqj, quadratic CPU in !!omap resolution)

Stable reported no open alerts only because its last Trivy scan ran on 2026-08-04 with v3.45.14, before either advisory was published — the vulnerable versions were present in the lockfile regardless.

Lockfile-only; the existing ^ ranges already permitted both fixes.
2026-08-10 11:00:03 +02:00
Paul Nothaft c01d8d8d2e chore(stable): release 3.45.14 (#990)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-04 21:26:28 +02:00
Paul Nothaft bf9bd76278 fix(security): vet the destination project when linking a deal (stable) (#992)
Backport of #991. stable carried the identical code path and the same missing
guards.

A scoped admin could point a quote or contract at a project they do not own —
the quote/contract create+update paths pass a body-supplied projectId with no
ownership check, and linkDealToProject's lineage guard is skipped when the deal
has no event yet. On an ownerless project this escalated to a read once the
quote converted to an event.

Vetted at the service choke point, ahead of both the null-deal early return and
the customer check. 404 PROJECT_NOT_FOUND throughout. super_admin unaffected.
2026-08-04 16:36:28 +02:00
Paul Nothaft 0fe5792a7d fix(deps): bump ip-address, brace-expansion and postcss for open CVEs (stable) (#988)
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.
2026-08-04 14:36:10 +02:00
Paul Nothaft 3f7364be8e chore(stable): release 3.45.13 (#972)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-03 21:26:03 +02:00
Paul Nothaft 2d0e6ab2dc fix(projects): stop the cockpit offering email controls the API rejects (stable) (#977)
Closes #969 on stable. Backport of #976.

The cockpit's email feed rendered preview/resend/cancel/retry/send-now for every mail regardless of role or permission, producing 404s (CRM document mail has no event_id; project ownership does not imply event ownership) and 403s (preview needs events.view, the write actions need email.send).

getProjectOverview now stamps each email with an authoritative canAct, mirroring filterOwnedEventIds; created_by is selected only for that check and stripped before the response. A missing canAct reads as false.
2026-08-03 14:49:04 +02:00
Paul Nothaft cc49f6997a fix(auth): fail closed when the adminAuth roles join errors (stable) (#975)
Closes #968 on stable. Backport of #974.

The roles-join fallback in adminAuth fabricated role_name='super_admin' on ANY database error, so a transient fault silently granted super_admin for its duration. Gate it on isMissingRolesSchema(), moved to utils/dbErrors.js and shared with apiTokenAuth, with the predicate tightened to trust SQLSTATE 42P01/42703 on Postgres and exact driver phrasing on SQLite.
2026-08-03 14:48:33 +02:00
Paul Nothaft fecc18cbc8 fix(security): enforce project ownership on project + project-email routes (stable) (#966)
* fix(security): enforce project ownership (GHSA-wrg5, GHSA-93x4)

Project routes authorized on generic events.view / events.edit with NO
ownership check, so an editor-like admin could enumerate, read, update and
aggregate projects belonging to other admins' events. The project email
endpoints keyed on an email_queue id alone — any admin with events.view /
email.send could preview, resend, cancel or retry ANY queued mail by walking
ids.

The earlier 'needs a migration, deferred' assessment was wrong in one
direction and right in another: ownership IS derivable transitively via
events.project_id -> events.created_by, but only for projects that already
have a linked event. A brand-new EMPTY project has no derivable owner, which
is exactly where the create -> attach flow starts. So migration 167 adds
projects.created_by (backfilled from the single linked event owner, skipping
ambiguous multi-owner projects) and createProject finally persists the adminId
it was already being passed.

- ownedProjectIds(): union of the stored owner and the transitive path, so
  pre-167 rows and new empty projects both resolve. Reads created_by
  defensively so an instance that hasn't run 167 falls back to the transitive
  rule instead of throwing.
- requireProjectOwnership on detail/update/attach-event/attach-quote/
  attach-contract/overview; list filtered by an id allowlist (empty array
  means 'owns nothing' and must return no rows, hence null-vs-[] care).
- POST /:id/events also validates the INCOMING eventId — owning the project
  is not enough, or an editor could pull a foreign event in and read its
  rolled-up documents via /:id/overview.
- Queued-email routes scoped via email_queue.event_id. CRM document mail has
  event_id NULL and no ownable parent here, so a scoped caller is denied
  rather than guessed into access. 404 (not 403) so it isn't an id oracle.

Note: adminEmail.js:315/332 let any email.view/edit admin archive or delete
any email_queue row — the same class, pre-existing and outside these two
advisories. Left untouched and reported rather than silently widened.

* fix(security): codex round 2 — make the stored project owner authoritative (GHSA-wrg5)

The first predicate union'd 'any linked event I can see' with the stored
owner, which opened two holes:

- A project owned by admin B containing ONE legacy ownerless event became
  readable by every admin — and /:id/overview aggregates B's other events,
  invoices and emails, so a single legacy event exposed the whole project.
- Migration 167 deliberately leaves multi-owner (ambiguous) projects NULL
  rather than guessing an owner. A NULL owner was then treated as
  'everyone's', so exactly those mixed projects became globally accessible.

Now: the stored created_by wins outright, and a project without a usable
stored owner only derives access when EVERY linked event is accessible (and at
least one exists). A created_by pointing at a hard-deleted admin degrades to
'no usable owner' so the project falls back to its events instead of being
locked away — no ON DELETE SET NULL migration needed. A project with neither a
usable owner nor linked events stays super_admin-only: failing closed beats
failing open, and a super_admin can reassign it.

Also returns a knex SUBQUERY rather than a materialised id list, so a large
project count can't hit the driver's bind-parameter limit.

* fix(security): codex round 3 — enforce deal-lineage ownership on project attach (GHSA-wrg5)

requireProjectOwnership vets only the DESTINATION project, while attaching a
quote or contract cascades through linkDealToProject — which re-points every
event the deal produced into that project. An editor could therefore create an
empty project of their own, attach another admin's quote, and pull that admin's
events (plus the invoices, emails and gallery that roll up with them) into a
project they own and can read via /:id/overview. The single-customer guard did
not stand in the way: an unassigned project ADOPTS the deal's customer rather
than rejecting it.

linkDealToProject now refuses to move lineage events the actor cannot own, and
assignDocument cascades BEFORE stamping the document so a refused attach leaves
nothing half-applied (the old order committed the foreign document into the
caller's project and only then declined the cascade). The quote/contract
create+update paths, which reach the same cascade with an arbitrary project_id,
thread their adminId through as well; isSuperAdmin() resolves the role for them
and fails closed when it cannot.

Events are the only ownership signal a deal carries — quotes and contracts have
no created_by in this schema — so a lineage that produced no event still cannot
be attributed. That is a property of the CRM model, noted in the code.

Claude-Session: https://claude.ai/code/session_01F211U4dDbEj4zXiyKbi9me
(cherry picked from commit 688e318850db1b5f4ea2a4ae3c0fcf0fc137620d)

* docs(security): drop the stale ownership JSDoc left by the rebase (GHSA-wrg5)

Rebasing onto stable (which had gained scopeEventsQuery from #963) replayed the
round-1 doc block above round-2's replacement, leaving a comment that describes
the ORIGINAL union rule — "a project is the caller's when … it has at least one
linked event they own" — directly above the code that deliberately no longer
does that. That union is the hole round 2 closed; a comment asserting it is
worse than none.

Claude-Session: https://claude.ai/code/session_01F211U4dDbEj4zXiyKbi9me

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-02 21:24:41 +02:00
Paul Nothaft 7f27e6771f fix(security): escape brand tokens, block tracker redirects, trim logo diagnostic (stable) (#967)
* fix(security): escape brand tokens, block tracker redirects, trim logo diagnostic (GHSA-j347, mw76, 29vm)

GHSA-j347 — buildCachedPayload sanitizes the operator's HTML and THEN runs
applyBrandTokens over the result with a plain String.replace, so any markup in
a token value reached the public origin unfiltered. The default templates
interpolate tokens into text AND into quoted attributes
(<img src="{{brand_logo_url}}" alt="{{company_name}} logo">,
href="mailto:{{support_email}}"), so a value could close the attribute and
inject. Token values are now HTML-escaped on substitution, mirroring
galleryOgService's escapeHtml. sanitizeBrandUrl's case-sensitive literal
'javascript:' check (which 'JavaScript:' walked straight past) is replaced by
an http/https scheme allowlist; relative logo paths are unaffected.

Writer is settings.edit (super_admin only) and the CSP blocks inline script,
so this is defence-in-depth — but sanitize-then-substitute is a real ordering
bug regardless.

GHSA-mw76 — the SSRF decline STANDS: self-hosted operators legitimately point
analytics at private addresses, so connection-time IP blocking would break real
deployments. Fixed only the narrow leak: undici strips
Authorization/Cookie/Proxy-Authorization/Host across a cross-origin redirect,
but umamiAdapter sends a CUSTOM x-umami-api-key header, which would be replayed
verbatim to the redirect target. Both adapters now use redirect: 'error'.

GHSA-29vm — the logo diagnostic echoed absolute storage roots, process.cwd()
and absolute candidate paths. It now reports candidates relative to
<STORAGE>/<CWD_STORAGE>, which answers the same 'which candidate existed'
question. It also still advertised the raw-absolute candidate that GHSA-c7x5
removed from resolveLogoFile, so it was misreporting what the resolver tries —
aligned with the real candidate list.

publicSiteService.test.js expectation updated: an '&' in a company name is now
emitted as '&amp;'. Renders identically; the raw payload string differs.

* fix(security): codex round 2 — stop the remaining logo-path disclosure, mirror the resolver (GHSA-29vm)

- sources[].value was still echoed verbatim. branding_logo_path is stored
  ABSOLUTE by multer, so relativising only resolvedTo and the candidate paths
  left the filesystem layout going out anyway. It is now relativised too.
- Round 1 dropped the raw-absolute candidate on the grounds that GHSA-c7x5
  removed it from resolveLogoFile — but the c7x5 follow-up RE-ADDED it (kept,
  subject to the containment filter, so a legitimate multer path still
  resolves). The diagnostic therefore reported every candidate as missing for
  a contained absolute logo while resolvedTo named the file. It now mirrors the
  resolver, containment filter included.

One deliberate cosmetic divergence, commented in place: for an absolute value
the resolver also tries path.join(root, value-minus-leading-slash), which can
never exist and would re-embed the absolute path this endpoint must stop
echoing. Omitted; every candidate that can actually match is still shown.

* fix(security): codex round 3 — mirror the resolver for root-relative logo paths (GHSA-29vm)

The logo diagnostic skipped the `<STORAGE>/<value>` candidates whenever
path.isAbsolute(value) was true. That test cannot distinguish a multer disk
path from a root-relative URL such as `/custom/logo.png`, and for the URL form
resolveLogoFile.generateCandidates() does try `<STORAGE>/custom/logo.png` and
can resolve it — so the endpoint reported "no source candidate exists" about a
logo that renders fine, and collapsed the configured value to its basename.

The stripped joins are now built unconditionally, exactly as the resolver does.
Disclosure stays closed: every candidate still passes the containment filter and
redact() rewrites survivors to `<STORAGE>/…`, never an absolute host path.

Claude-Session: https://claude.ai/code/session_01F211U4dDbEj4zXiyKbi9me
(cherry picked from commit 093480a753ff3d4b6ed48dd9f1108f975c8e0d47)

* fix(security): gate the logo stripped-joins on containment, not isAbsolute (GHSA-29vm)

The previous commit dropped the isAbsolute() gate entirely and regressed
logoDiagnostic's own disclosure assertion: for a genuine multer disk path,
path.join(root, value-minus-leading-slash) yields
`<STORAGE>/tmp/…/storage/custom/logo.png`, and redact() only rewrites the
LEADING root — so the inner absolute path went straight back into the payload.

The right discriminator is not "is this absolute" (which cannot separate a disk
path from a root-relative URL) but "does the value already resolve inside a
storage root". If it does, it is a real disk path, the raw candidate already
covers it, and the stripped join is the double-prefixed junk that can never
exist. If it does not — the `/custom/logo.png` URL form — the stripped join is
exactly what resolveLogoFile resolves, and is shown.

Covered by a new case asserting both halves: the candidate appears for the URL
form, and the payload still contains neither the storage root nor cwd.

Claude-Session: https://claude.ai/code/session_01F211U4dDbEj4zXiyKbi9me
(cherry picked from commit c6b95d3cd1cb28e5c2828d29d4d63fadad981dcf)

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-02 21:18:55 +02:00
Paul Nothaft 4e99897313 fix(security): enforce event ownership on the v1 API surface (GHSA-9697) (stable) (#963)
* fix(security): enforce event ownership on the v1 API surface (GHSA-9697)

Migration 081 documents the intent — 'the token's effective permissions are
the intersection of the user's role permissions and the token's own scope
flags' — but it was never implemented.

- apiTokenAuth selected only id/username/email/role_id, so req.admin.roleName
  was undefined. Every ownership helper keys on roleName, so the v1 surface
  could not tell a super_admin from a demoted viewer. Now joins roles and
  emits the same req.admin shape adminAuth does, including the
  roles-table-missing upgrade fallback.
- No v1 route applied any ownership predicate: GET /events listed every event
  on the instance, and GET /events/:id/share-link returned ANY event's
  share_token — the gallery access credential, same class as GHSA-rh8r.
  List is now scoped via a new scopeEventsQuery helper; the three :id routes
  (detail, photo upload, share-link) use the existing requireEventOwnership.

Not a breaking change: tokens are minted by super_admins, who bypass
ownership. It closes the case where a token's owner is later demoted —
userManagementService never touches api_tokens, so the token outlived the
demotion with full read of every gallery's share token.

events.category.test.js stubbed apiTokenAuth without roleName; giving the
stub super_admin keeps requireEventOwnership from issuing a DB query and
desyncing that suite's sequenced dbMock.

* fix(security): codex round 2 — intersect v1 token scopes with role permissions (GHSA-9697)

Ownership scoping alone left half the documented control missing. Migration
081 defines a token's effective permissions as the INTERSECTION of the owner's
role permissions and the token's scope flags; requireApiScope only ever checked
the scope half. A token minted while its owner was super_admin therefore kept
write access after the owner was demoted to viewer — userManagementService
never touches api_tokens, so the token outlives the demotion, and ownership
scoping does not help because the demoted owner still owns their events.

Adds requirePermission to all six v1 routes (events.create on create,
events.view on the reads, photos.upload on upload). It keys on req.admin.id,
which apiTokenAuth already populates.

The two existing v1 suites mock the database, so a real permission lookup
500s — they now mock the permissions middleware as pass-through, matching how
they already mock apiTokenAuth. Those suites cover route logic; the
intersection is pinned by the new v1TokenPermissions suite.

* fix(security): codex round 3 — fail closed on the roles-join fallback (GHSA-9697)

The round-2 fix loaded the token owner's role so the v1 ownership checks could
tell a super_admin from a demoted viewer, and mirrored adminAuth's
roles-table-missing fallback. That fallback assigns role_name = 'super_admin',
and the catch around it was unconditional — so ANY failure of the joined query
(connection reset, deadlock, statement timeout) elevated the token owner to
super_admin as long as the simpler fallback query then succeeded. A restricted
owner could ride that into listing, reading and share-tokening every event on
the instance, which is the exact hole GHSA-9697 closes.

The fallback is now reached only for an error that genuinely names a missing
roles table/column (PG 42P01/42703 or the SQLite/MySQL wording); anything else
propagates to the 500 handler.

Claude-Session: https://claude.ai/code/session_01F211U4dDbEj4zXiyKbi9me
(cherry picked from commit 53d1e5d1b3148a7f4067308b08fcdf8ddab0a39f)

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-02 21:18:52 +02:00
Paul Nothaft ccab9024d4 fix(security): bound inbound-mail resources, redact secrets from logs (stable) (#965)
* fix(security): bound inbound-mail resources, redact secrets from logs (GHSA-2qf9, pgmp, r794)

GHSA-2qf9 — emailIntakeService downloaded, parsed and persisted every message
with no size, attachment-count or attachment-byte limit, reachable
unauthenticated by anyone who can email the operator's mailbox:
- fetch the envelope with `size` (same cheap pass) and refuse an oversized
  message BEFORE downloading its source;
- cap attachment count and cumulative attachment bytes;
- limits env-overridable, defaults generous for real supplier invoices.

The teeth were in the dedup key. received_emails.message_id is varchar(512)
UNIQUE, and the failure path wrote `err-<uid>-<Date.now()>`, which can never
match the envelope-derived messageId the dedup pass compares against — so an
oversized (or overlong-Message-ID) mail was re-downloaded every poll forever,
and an OOM-kill/restart just resumed the loop. Size-skips are now recorded
under the REAL message id, and overlong ids collapse to a stable sha256 key
that always fits the column.

GHSA-pgmp / r794 — new sanitizeForLog() util (key-name deny-set, recursive,
cycle-safe) applied to the three request-body log sites in adminEvents/crud.js,
plus sanitizeValidationErrors() because express-validator's errors.array()
embeds the SUBMITTED value per field — a rejected plaintext password was still
logged. Scope is wider than filed: the update path also logged
client_password_hash and a LIVE client_share_token bearer credential.

Also: the one-time setup token was logged at warn AND printed to stdout on
every first boot, putting a live first-admin credential in combined.log,
security.log and `docker logs`. It is now written to the 0600 token file and
only surfaced when that write fails — the last-resort path it existed for. (stable)

* fix(security): codex round 3 — repair the first-run token recovery flow (GHSA-r794)

Two regressions from keeping the setup token out of the logs.

1. server.js decided whether to print the token by calling existsSync() on the
   candidate path. That answers a different question than "did the write
   succeed": a stale, read-only or directory-shaped SETUP_TOKEN reports as
   present, so the banner suppressed the live token and pointed the operator at
   content that is not it — leaving the current token only in combined.log
   under default production logging. setupService now records the path the
   write actually produced and exposes it via writtenSetupTokenFile().

2. The setup screen, its EN/DE strings, README, SIMPLE_SETUP and .env.example
   all still told first-time users to run
   `docker compose logs backend | grep -i "setup token"`. On the normal path
   that command now returns a path banner and no credential, so the documented
   browser-first onboarding could not be completed. They now point at
   `docker compose exec backend cat /app/data/SETUP_TOKEN`, with the log
   fallback described as what it is — the failure path.

Claude-Session: https://claude.ai/code/session_01F211U4dDbEj4zXiyKbi9me
(cherry picked from commit 9a54b6f0231c3285df4c4865eb846e63e1ed0dda)

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-02 21:18:49 +02:00
Paul Nothaft 11f9f584de fix(security): scope dashboard stats/analytics/activity to the caller's events (stable) (#964)
* fix(security): scope dashboard endpoints to the caller's events (GHSA-c2jj, gqx7, jhcf)

/dashboard/stats, /analytics and /activity are gated only by analytics.view,
which the editor role holds — but the events LIST restricts editors to their
own rows (adminEvents/crud.js: roleName === 'editor' -> created_by =
admin.id). So an editor saw instance-wide totals, and via /analytics
topGalleries other admins' gallery NAMES and SLUGS (the public gallery URL
component), for events invisible to them everywhere else.

- stats: all 10 aggregates scoped (events by id, photos/access_logs by
  event_id).
- analytics: all 8 series/aggregates scoped, including topGalleries. The
  external tracker device breakdown reports instance-wide data with no event
  filter, so a scoped caller falls through to the access_logs heuristic
  instead, which IS scoped.
- activity: feed scoped. activity_logs.event_id is nullable and the join is a
  leftJoin, so system-level rows (logins, settings changes) are deliberately
  excluded for a scoped caller — those are precisely the cross-admin actions
  the advisory is about.

Scoping keys on 'editor' to mirror the events list exactly, so the admin
role's dashboard is unchanged. filterOwnedEventIds uses the broader
'!== super_admin' rule; the two conventions disagree in this codebase and
matching the list is the no-regression choice.

* fix(security): codex round 2 — fix activity misattribution, scope via subquery (GHSA-jhcf, c2jj, gqx7)

- expenseService passed adminId as logActivity's THIRD positional parameter,
  which is eventId — so admin ids were being written into
  activity_logs.event_id. The /activity scoping filter trusts that column, and
  admin/event id sequences overlap, so a foreign admin's expense metadata could
  surface under an editor's event. All 11 calls now pass null for eventId and
  the admin as the actor, which is what they meant.
- Dashboard scoping now uses a SUBQUERY instead of pluck()+whereIn. An editor
  owning more events than the driver's bind-parameter limit (~999 SQLite,
  65535 Postgres) would have turned all three endpoints into 500s once each id
  became a placeholder; below the limit it still re-sent the full list for each
  of the ~10 aggregates per request.

Note: two billInboundNow() calls also end in ', adminId)' but have an unrelated
signature — verified untouched.

* fix(security): codex round 3 — correct legacy accounting activity rows (GHSA-jhcf)

expenseService called logActivity(type, metadata, adminId), but logActivity's
third positional parameter is eventId. Every expense / incoming-invoice entry
therefore stored the ACTING ADMIN'S ID in activity_logs.event_id.

Round 2 scoped the activity feed with
`WHERE activity_logs.event_id IN (SELECT id FROM events WHERE created_by = me)`,
which does nothing about the rows already on disk. Admin ids and event ids are
small integers from the same range, so on any upgraded instance an editor who
owns the event whose id happens to equal another admin's id is served that
admin's accounting activity, verbatim metadata included — GHSA-jhcf, still
live. Migration 168 re-attributes those rows (event_id holds exactly the actor
id that was lost) and then clears event_id so the scope predicate can no longer
match them. All ten activity types are emitted by expenseService and nothing
else, so no row with a genuine event_id is touched.

Also: the round-2 rewrite passed `{ type: 'admin', id: adminId }`
unconditionally, which stored actor_type='admin' with a null id for the
automated mailbox intake (emailIntakeService calls recordInboundDocument with
no adminId). adminActor() restores 'system' attribution for those.

Claude-Session: https://claude.ai/code/session_01F211U4dDbEj4zXiyKbi9me
(cherry picked from commit 459e9e42434defd0dc7b87246e4d894dd47dcc56)

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-02 21:18:46 +02:00
Paul Nothaft 3b88036fda fix(security): backup/restore hardening — public-dir DB dump, restore path allowlist, gunzip bound, manifest keying (stable) (#962)
* fix(security): stop caller-chosen database backup destination (GHSA-jw8m)

POST /api/admin/database-backup/backup forwarded req.body straight into
databaseBackupService.backup(), which merges options over config:
  const { destinationPath = '/backup/database', ... } = { ...config, ...options }

destinationPath is not a persistable setting — the /config allowlist only
accepts database_backup_* keys — so the request body was its only source.
The built-in `admin` role holds backup.create but neither settings.edit nor
backup.restore, so it could aim a full DB dump (admin bcrypt hashes, gallery
password hashes, encrypted SMTP creds) at the PUBLIC /uploads static mount
(server.js mounts it with no auth middleware) and then fetch it
unauthenticated. Filed low; it is a privilege escalation to unauthenticated
disclosure.

Forward only the real knobs, and only when present so absent keys can't
override config defaults via spread.

* fix(security): backup/restore hardening — restore path allowlist, gunzip bound, manifest checksum keying (GHSA-fw4c, h652, hgp8)

- adminRestore /validate + /start: constrain caller-supplied source and
  manifestPath to the operator-configured backup roots — the SAME set the
  restore wizard discovers from — so disaster recovery from a rescued mount
  still works, with RESTORE_ALLOWED_ROOTS as an escape hatch (GHSA-fw4c).
- restoreService.decompressFile: bound the EXPANDED size and abort the
  pipeline when exceeded; default 50 GB, RESTORE_MAX_DECOMPRESSED_BYTES
  overrides (GHSA-h652).
- backupManifest: BACKUP_MANIFEST_KEY upgrades new manifests to a keyed
  HMAC (GHSA-hgp8). Deliberately opt-in and verify-if-present — the key
  cannot live in the database because the database is inside the backup, so
  a mandatory HMAC would lock operators out of the exact disaster-recovery
  case this exists for.

Also fixes a pre-existing bug found while testing hgp8: the checksum passed
Object.keys().sort() as JSON.stringify's second argument, which is an array
REPLACER (a property allowlist applied at every depth), not a key sorter. All
nested keys — path, size, per-file checksum — were dropped before hashing, so
the file list sat outside the integrity check entirely and a manifest path
could be rewritten to ../../etc/passwd without disturbing the digest. Now
hashes a recursively-canonicalized copy, with the legacy serialization
accepted on validation so existing backups stay restorable.

* fix(security): codex round 2 — unbreak the restore wizard, share checksum verification, guard downgrades

- adminRestore: `source` is usually a SOURCE TYPE ('local'|'s3'|'upload'),
  not a path — restoreService branches on those literals. The containment
  check treated it as a path, so path.resolve('local') fell outside the
  backup roots and BOTH /validate and /start returned 400, blocking every
  normal restore. Type tokens are now excluded from the path check.
- backupManifest: extracted verifyManifestChecksum() as the single source of
  truth for the legacy/keyed fallbacks. restoreService.performPreRestoreValidation
  recomputed the digest itself with the default canonical+keyed settings,
  which rejected EVERY backup written before this batch. It now delegates.
- backupManifest: guard the algorithm downgrade — with a key configured, an
  attacker able to rewrite the backup store could strip checksum_algorithm,
  edit the manifest and recompute a plain SHA-256 that verified. Opt-in via
  BACKUP_MANIFEST_REQUIRE_KEYED so pre-key backups keep restoring by default.

* fix(security): codex round 3 — close two manifest-verification fail-opens (GHSA-hgp8)

verifyManifestChecksum returned valid for a manifest with no
verification.total_checksum at all, and restoreService only called it when
that field was present. Deleting the field was therefore a complete bypass of
the keying work: no digest check, no downgrade guard, no
BACKUP_MANIFEST_REQUIRE_KEYED. Every manifest this codebase writes stamps the
field, so an absent one now fails validation, and the call site invokes the
verifier unconditionally.

Second fail-open: the strict-mode rejection of an unkeyed manifest was gated on
`&& key`, so with BACKUP_MANIFEST_REQUIRE_KEYED=true and no BACKUP_MANIFEST_KEY
configured a plain SHA-256 manifest sailed through. Strict mode is a statement
about the operator's manifests, not about the host — it is exactly the fresh
disaster-recovery box that lacks the secret. The rejection no longer depends on
a key being present.

Claude-Session: https://claude.ai/code/session_01F211U4dDbEj4zXiyKbi9me
(cherry picked from commit 292a5b784acd7f47099aa234c1c2ea00050fca97)

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-02 21:18:43 +02:00
65 changed files with 4099 additions and 172 deletions
+3 -2
View File
@@ -56,8 +56,9 @@ DB_NAME=picpeak_prod
# Admin Account (initial setup) — OPTIONAL
# Leave these unset (default) to create your admin IN THE BROWSER on first run:
# open /admin and PicPeak shows a setup screen. The one-time setup token is
# printed to the backend logs (`docker compose logs backend | grep -i "setup token"`)
# and saved to data/SETUP_TOKEN.
# written to data/SETUP_TOKEN with mode 0600 — read it with
# `docker compose exec backend cat /app/data/SETUP_TOKEN`. It is NOT logged
# unless that write fails, so it never sits in `docker logs`.
# Set ADMIN_PASSWORD to auto-create the admin on first boot instead (legacy;
# credentials written to data/ADMIN_CREDENTIALS.txt).
#ADMIN_USERNAME=admin
+1 -1
View File
@@ -1 +1 @@
{".":"3.45.12"}
{".":"3.45.15"}
+30
View File
@@ -5,6 +5,36 @@ All notable changes to PicPeak will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.45.15](https://github.com/PicPeak/picpeak/compare/v3.45.14...v3.45.15) (2026-08-10)
### Bug Fixes
* **deps:** bump nanoid and js-yaml out of two HIGH advisories (stable) ([#1014](https://github.com/PicPeak/picpeak/issues/1014)) ([cee0a38](https://github.com/PicPeak/picpeak/commit/cee0a380a6faf2bb0a5c802057ba3140670840d2))
* **slideshow:** stop "no crop" fit letterboxing a pre-cropped frame (stable) ([#1015](https://github.com/PicPeak/picpeak/issues/1015)) ([#1019](https://github.com/PicPeak/picpeak/issues/1019)) ([2bdb120](https://github.com/PicPeak/picpeak/commit/2bdb1204fe61a9b6cd704b35ccfd39efa15ed118))
## [3.45.14](https://github.com/PicPeak/picpeak/compare/v3.45.13...v3.45.14) (2026-08-04)
### Bug Fixes
* **deps:** bump ip-address, brace-expansion and postcss for open CVEs (stable) ([#988](https://github.com/PicPeak/picpeak/issues/988)) ([0fe5792](https://github.com/PicPeak/picpeak/commit/0fe5792a7d30bd948d6430642ca0bec35ddc2ca6))
* **security:** vet the destination project when linking a deal (stable) ([#992](https://github.com/PicPeak/picpeak/issues/992)) ([bf9bd76](https://github.com/PicPeak/picpeak/commit/bf9bd762783a2a675f0a6fcd965addf0f47cec57))
## [3.45.13](https://github.com/PicPeak/picpeak/compare/v3.45.12...v3.45.13) (2026-08-03)
### Bug Fixes
* **auth:** fail closed when the adminAuth roles join errors (stable) ([#975](https://github.com/PicPeak/picpeak/issues/975)) ([cc49f69](https://github.com/PicPeak/picpeak/commit/cc49f6997ac54c3e25d5562721c446b7dac7f074))
* **projects:** stop the cockpit offering email controls the API rejects (stable) ([#977](https://github.com/PicPeak/picpeak/issues/977)) ([2d0e6ab](https://github.com/PicPeak/picpeak/commit/2d0e6ab2dca84cf74c6c6b5c40ecae5c5cde814c))
* **security:** backup/restore hardening — public-dir DB dump, restore path allowlist, gunzip bound, manifest keying (stable) ([#962](https://github.com/PicPeak/picpeak/issues/962)) ([3b88036](https://github.com/PicPeak/picpeak/commit/3b88036fda871b3a1ca2e933c39fa96e37950fe6))
* **security:** bound inbound-mail resources, redact secrets from logs (stable) ([#965](https://github.com/PicPeak/picpeak/issues/965)) ([ccab902](https://github.com/PicPeak/picpeak/commit/ccab9024d4ef2f556169bbca8c6bba4801afe3a0))
* **security:** enforce event ownership on the v1 API surface (GHSA-9697) (stable) ([#963](https://github.com/PicPeak/picpeak/issues/963)) ([4e99897](https://github.com/PicPeak/picpeak/commit/4e9989731390f9c067b048f4fa56ed3bf8ec472d))
* **security:** enforce project ownership on project + project-email routes (stable) ([#966](https://github.com/PicPeak/picpeak/issues/966)) ([fecc18c](https://github.com/PicPeak/picpeak/commit/fecc18cbc837507bf30dd7502786de4e067855a5))
* **security:** escape brand tokens, block tracker redirects, trim logo diagnostic (stable) ([#967](https://github.com/PicPeak/picpeak/issues/967)) ([7f27e67](https://github.com/PicPeak/picpeak/commit/7f27e6771f666a40ec0581dc7702be3a1de8330d))
* **security:** scope dashboard stats/analytics/activity to the caller's events (stable) ([#964](https://github.com/PicPeak/picpeak/issues/964)) ([11f9f58](https://github.com/PicPeak/picpeak/commit/11f9f584ded5f777a61dc2e1e637d478a61ac377))
## [3.45.12](https://github.com/PicPeak/picpeak/compare/v3.45.11...v3.45.12) (2026-08-02)
+7 -2
View File
@@ -111,10 +111,15 @@ docker compose up -d
On first start with no `ADMIN_PASSWORD` set, PicPeak has **no admin account yet** and greets you with an in-browser setup screen — no credentials in `.env`:
1. Open **http://localhost:3000/admin** — you'll be redirected to `/setup`.
2. Grab the **one-time setup token** from the backend logs (it's also saved to `data/SETUP_TOKEN`):
2. Read the **one-time setup token** from the 0600 file the backend writes it to
(it is deliberately *not* printed to the logs — that would leave a live
bootstrap credential in `docker logs`):
```bash
docker compose logs backend | grep -i "setup token"
docker compose exec backend cat /app/data/SETUP_TOKEN
```
It is bind-mounted, so `sudo cat data/SETUP_TOKEN` on the host works too. Only
if that file could not be written does the backend fall back to logging the
token (`docker compose logs backend | grep -i "setup token"`).
3. Paste the token, set your admin **email + password**, and you're in. The token is single-use, and the setup screen closes permanently once an admin exists.
> Prefer the old behaviour? Set `ADMIN_PASSWORD` in `.env` and PicPeak auto-creates the admin on first boot instead (credentials written to `data/ADMIN_CREDENTIALS.txt`).
+4 -2
View File
@@ -170,10 +170,12 @@ If you installed with `picpeak-setup.sh` and gave an `--admin-password`, your ad
If you started PicPeak **without** setting `ADMIN_PASSWORD` (e.g. a plain `docker compose up`), there's **no admin yet** and you create it in the browser:
1. Open `http://your-server:3000/admin` — you'll land on a setup screen.
2. Get the **one-time setup token** from the backend logs (also saved to `data/SETUP_TOKEN`):
2. Read the **one-time setup token** from the 0600 file the backend writes it to
(it is not logged — that would leave a live credential in `docker logs`):
```bash
docker compose logs backend | grep -i "setup token"
docker compose exec backend cat /app/data/SETUP_TOKEN
```
Only if that write fails does the backend log the token instead.
3. Paste it, set your admin email + password. The token is single-use and the screen closes once an admin exists.
## 🌐 Access Methods
@@ -0,0 +1,150 @@
/**
* Slideshow photo source (#1015).
*
* The bug: with `lightbox_preview_enabled` off (the default), /photos emitted
* `preview_url: null`, so the slideshow's `preview_url || hero_url || url`
* chain fell through to `hero_url` — a 1920x1080 `fit: 'cover'` centre crop
* meant for gallery header banners. With the "Black Bars (No crop)" fit the
* show then letterboxed an already-cropped frame: portrait photos lost their
* top and bottom and the setting looked broken.
*
* The contract pinned here: `slideshow_url` points at the aspect-preserved
* preview tier and is emitted for image photos REGARDLESS of the lightbox
* toggle, so the slideshow never has a reason to reach for `hero_url`.
* `preview_url` itself must stay gated — the lightbox opt-in is unchanged.
*/
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
process.env.JWT_SECRET = process.env.JWT_SECRET || 'slideshow-src-test-secret';
const SLUG = 'slideshow-source-event';
describe('Slideshow photo source (#1015)', () => {
let db;
let cleanup;
let app;
let eventId;
let imagePhotoId;
let videoPhotoId;
const galleryToken = () => jwt.sign(
{ eventId, eventSlug: SLUG, type: 'gallery' },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
const setLightboxPreview = async (on) => {
await db('app_settings').where({ setting_key: 'lightbox_preview_enabled' }).del();
await db('app_settings').insert({
setting_key: 'lightbox_preview_enabled',
setting_value: JSON.stringify(on),
setting_type: 'general',
updated_at: new Date().toISOString(),
});
};
const fetchPhotos = async () => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/photos`)
.set('Authorization', `Bearer ${galleryToken()}`)
.expect(200);
return res.body.photos;
};
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const inserted = await db('events').insert({
slug: SLUG,
event_type: 'wedding',
event_name: 'Slideshow Source Test',
event_date: '2026-08-01',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: `/gallery/${SLUG}/share`,
share_token: 'slideshow-source-share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
eventId = inserted[0]?.id ?? inserted[0];
const img = await db('photos').insert({
event_id: eventId,
filename: 'portrait.jpg',
path: 'events/slideshow-source/portrait.jpg',
type: 'individual',
mime_type: 'image/jpeg',
uploaded_at: new Date().toISOString(),
}).returning('id');
imagePhotoId = img[0]?.id ?? img[0];
const vid = await db('photos').insert({
event_id: eventId,
filename: 'clip.mp4',
path: 'events/slideshow-source/clip.mp4',
type: 'individual',
media_type: 'video',
mime_type: 'video/mp4',
uploaded_at: new Date().toISOString(),
}).returning('id');
videoPhotoId = vid[0]?.id ?? vid[0];
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/gallery', require('../../src/routes/gallery'));
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
it('emits slideshow_url for image photos even when lightbox previews are OFF', async () => {
await setLightboxPreview(false);
const photos = await fetchPhotos();
const image = photos.find((p) => p.id === imagePhotoId);
expect(image.slideshow_url).toBe(`/api/gallery/${SLUG}/preview/${imagePhotoId}`);
// The regression: this is what used to be null, pushing the show to hero.
expect(image.preview_url).toBeNull();
});
it('leaves preview_url gated so the lightbox opt-in is unchanged', async () => {
await setLightboxPreview(true);
const photos = await fetchPhotos();
const image = photos.find((p) => p.id === imagePhotoId);
expect(image.preview_url).toBe(`/api/gallery/${SLUG}/preview/${imagePhotoId}`);
expect(image.slideshow_url).toBe(image.preview_url);
});
it('never points the slideshow at the cover-cropped hero tier', async () => {
await setLightboxPreview(false);
const photos = await fetchPhotos();
const image = photos.find((p) => p.id === imagePhotoId);
// hero_url still ships (the gallery header uses it) — it just must not be
// what the slideshow resolves to.
expect(image.hero_url).toBe(`/api/gallery/${SLUG}/hero/${imagePhotoId}`);
expect(image.slideshow_url).not.toBe(image.hero_url);
});
it('emits slideshow_url: null for videos, which have no preview tier', async () => {
await setLightboxPreview(false);
const photos = await fetchPhotos();
const video = photos.find((p) => p.id === videoPhotoId);
expect(video.slideshow_url).toBeNull();
});
});
@@ -0,0 +1,111 @@
/**
* The roles-join fallback in adminAuth fabricates `role_name = 'super_admin'`
* to keep existing sessions working across the RBAC upgrade window. The catch
* around it used to be unconditional, so ANY transient database failure —
* connection reset, deadlock, statement timeout, pool exhaustion — took the
* same branch and handed the caller super_admin for the duration of the fault.
*
* `roleName` is the sole discriminator for every ownership check (ownership.js,
* adminProjects, adminUsers, adminApiTokens, projectService, ...), so that
* inverted the whole authorization model rather than failing the request.
* Issue #968. Same treatment apiTokenAuth already got for the v1 surface.
*/
const jwt = require('jsonwebtoken');
jest.mock('../../src/utils/tokenRevocation', () => ({ isTokenRevoked: jest.fn().mockResolvedValue(false) }));
jest.mock('../../src/utils/logger', () => ({ warn: jest.fn(), error: jest.fn(), debug: jest.fn(), info: jest.fn() }));
// The joined query throws whatever the test stages; the role-less fallback
// query (no .leftJoin) always succeeds, which is what made the original bug
// reachable — it is the cheaper single-table read.
// `mock`-prefixed so jest's module-factory hoisting allows the reference.
let mockJoinError = null;
const mockAdminRow = { id: 7, username: 'scoped', email: 's@example.com', password_changed_at: null };
jest.mock('../../src/database/db', () => ({
db: () => ({
_joined: false,
leftJoin() { this._joined = true; return this; },
where() { return this; },
select() { return this; },
first() {
if (this._joined && mockJoinError) return Promise.reject(mockJoinError);
return Promise.resolve({ ...mockAdminRow });
},
}),
}));
const { adminAuth } = require('../../src/middleware/auth');
const SECRET = 'test-secret-for-admin-auth-fallback';
function makeReq() {
const token = jwt.sign(
{ id: mockAdminRow.id, type: 'admin' },
SECRET,
{ algorithm: 'HS256', issuer: 'picpeak-auth' },
);
return { headers: { authorization: `Bearer ${token}` }, ip: '127.0.0.1', connection: {} };
}
function makeRes() {
return {
statusCode: null,
body: null,
status(code) { this.statusCode = code; return this; },
json(payload) { this.body = payload; return this; },
};
}
describe('adminAuth roles-join fallback (#968)', () => {
const OLD_SECRET = process.env.JWT_SECRET;
beforeAll(() => { process.env.JWT_SECRET = SECRET; });
afterAll(() => { process.env.JWT_SECRET = OLD_SECRET; });
beforeEach(() => { mockJoinError = null; });
it('grants the upgrade-window fallback only for a genuinely missing roles table', async () => {
mockJoinError = new Error('SQLITE_ERROR: no such table: roles');
const req = makeReq();
const res = makeRes();
const next = jest.fn();
await adminAuth(req, res, next);
expect(next).toHaveBeenCalled();
expect(req.admin.roleName).toBe('super_admin');
});
it.each([
['connection reset', new Error('Connection terminated unexpectedly')],
['deadlock', new Error('deadlock detected')],
['pool exhaustion', new Error('Knex: Timeout acquiring a connection')],
['statement timeout', new Error('canceling statement due to statement timeout')],
])('does NOT fabricate super_admin on a transient failure (%s)', async (_label, err) => {
mockJoinError = err;
const req = makeReq();
const res = makeRes();
const next = jest.fn();
await adminAuth(req, res, next);
// Fails closed: request rejected, req.admin never populated. The specific
// status is 401 (adminAuth's blanket outer catch) — what matters is that
// the caller is not elevated and does not reach the route.
expect(next).not.toHaveBeenCalled();
expect(req.admin).toBeUndefined();
expect(res.statusCode).toBe(401);
});
it('does NOT fabricate super_admin when an unrelated table is missing', async () => {
mockJoinError = new Error('SQLITE_ERROR: no such table: admin_sessions');
const req = makeReq();
const res = makeRes();
const next = jest.fn();
await adminAuth(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(req.admin).toBeUndefined();
});
});
@@ -0,0 +1,72 @@
/**
* The roles-join fallback in apiTokenAuth grants `super_admin` (upgrade-path
* parity with adminAuth). It must therefore fire ONLY when the roles schema is
* genuinely absent — a catch-all turns any transient database failure into a
* privilege escalation that reopens GHSA-9697 for a demoted token owner.
*/
const { isMissingRolesSchema } = require('../../src/middleware/apiTokenAuth');
describe('apiTokenAuth roles-schema fallback predicate (GHSA-9697)', () => {
it('accepts a genuinely missing roles table on both engines', () => {
expect(isMissingRolesSchema(new Error('SQLITE_ERROR: no such table: roles'))).toBe(true);
expect(isMissingRolesSchema(
Object.assign(new Error('relation "roles" does not exist'), { code: '42P01' }),
)).toBe(true);
expect(isMissingRolesSchema(
Object.assign(new Error('column roles.name does not exist'), { code: '42703' }),
)).toBe(true);
});
it('rejects transient failures that must not elevate the caller', () => {
expect(isMissingRolesSchema(new Error('Connection terminated unexpectedly'))).toBe(false);
expect(isMissingRolesSchema(new Error('deadlock detected'))).toBe(false);
expect(isMissingRolesSchema(new Error('Knex: Timeout acquiring a connection'))).toBe(false);
expect(isMissingRolesSchema(new Error('canceling statement due to statement timeout'))).toBe(false);
expect(isMissingRolesSchema(undefined)).toBe(false);
});
it('rejects a missing-table error for an unrelated table', () => {
expect(isMissingRolesSchema(new Error('SQLITE_ERROR: no such table: api_tokens'))).toBe(false);
});
// knex prefixes the failing SQL to err.message, and that SQL always names
// `roles` on this join — so the message substring proves nothing about the
// error, and only an exact driver phrase (or a SQLSTATE) may be trusted.
// These are real knex message shapes, captured from the actual query.
describe('with knex\'s SQL prefix on the message (#968)', () => {
const withSql = (driverMessage) => new Error(
'select `roles`.`name` as `role_name` from `admin_users` '
+ 'left join `roles` on `roles`.`id` = `admin_users`.`role_id` '
+ `where \`admin_users\`.\`id\` = 1 limit 1 - ${driverMessage}`,
);
it('accepts both legitimate upgrade-window states', () => {
// pre-054: the roles table does not exist yet
expect(isMissingRolesSchema(
Object.assign(withSql('SQLITE_ERROR: no such table: roles'), { code: 'SQLITE_ERROR' }),
)).toBe(true);
// post-054, pre-057: roles exists, admin_users.role_id not added yet
expect(isMissingRolesSchema(
Object.assign(withSql('SQLITE_ERROR: no such column: admin_users.role_id'), { code: 'SQLITE_ERROR' }),
)).toBe(true);
});
it('rejects an unrelated "does not exist" fault despite the SQL naming roles', () => {
// pgbouncer transaction pooling loses a named prepared statement
// (SQLSTATE 26000). Transient — the fallback query would succeed on a
// fresh connection, so accepting this would fabricate super_admin.
expect(isMissingRolesSchema(
Object.assign(withSql('prepared statement "S_1" does not exist'), { code: '26000' }),
)).toBe(false);
// The DB role/user, not the roles table.
expect(isMissingRolesSchema(
Object.assign(withSql('role "picpeak" does not exist'), { code: '28000' }),
)).toBe(false);
expect(isMissingRolesSchema(
Object.assign(withSql('database "picpeak" does not exist'), { code: '3D000' }),
)).toBe(false);
expect(isMissingRolesSchema(withSql('Connection terminated unexpectedly'))).toBe(false);
});
});
});
@@ -0,0 +1,35 @@
/**
* Migration 167 (projects.created_by) — idempotent on re-run, reversible,
* and backfills the owner from a project's single linked event (GHSA-wrg5).
*/
const path=require('path'), fs=require('fs'), os=require('os');
process.env.NODE_ENV='test';
process.env.TEST_DATABASE_PATH=path.join(fs.mkdtempSync(path.join(os.tmpdir(),'picpeak-mig167-')),'db.sqlite');
process.env.JWT_SECRET='mig';
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const mig = require('../../migrations/core/167_add_projects_created_by');
describe('migration 167', () => {
let db, cleanup;
beforeAll(async()=>{ ({db,cleanup}=await bootCrmDb()); await seedMinimal(db); },120000);
afterAll(async()=>{ if(cleanup) await cleanup(); });
it('is idempotent on re-run and reversible', async () => {
await mig.up(db); // already applied by boot; must no-op
await mig.up(db); // and again
expect(await db.schema.hasColumn('projects','created_by')).toBe(true);
await mig.down(db);
expect(await db.schema.hasColumn('projects','created_by')).toBe(false);
await mig.up(db); // re-apply cleanly
expect(await db.schema.hasColumn('projects','created_by')).toBe(true);
});
it('backfills created_by from a single linked event owner', async () => {
const p = await db('projects').insert({name:'bf',status:'active',created_at:new Date(),updated_at:new Date()}).returning('id');
const pid = p[0]?.id ?? p[0];
await db('events').insert({slug:'bf-ev',event_type:'wedding',event_name:'bf',event_date:'2026-08-01',
host_email:'h@e.com',admin_email:'a@e.com',password_hash:'x',share_token:'t1',share_link:'/g/bf-ev/t1',
created_by: 4242, project_id: pid, expires_at:new Date(Date.now()+864e5).toISOString(),
is_active:1,is_archived:0,is_draft:0,created_at:new Date().toISOString()});
await mig.up(db);
const row = await db('projects').where({id:pid}).first();
expect(row.created_by).toBe(4242);
});
});
@@ -0,0 +1,83 @@
/**
* GHSA-jhcf round 3: scoping the activity feed does nothing about the rows
* already on disk. expenseService used to pass adminId into logActivity's
* `eventId` slot, so upgraded instances carry accounting rows whose event_id
* is an ADMIN id — and the scope predicate happily matches those against a
* same-numbered event the caller owns.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-mig168-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'mig168-test-secret';
const { bootCrmDb } = require('../integration/helpers/crmDb');
const migration = require('../../migrations/core/168_fix_expense_activity_event_id');
describe('migration 168 — legacy accounting activity rows (GHSA-jhcf)', () => {
let db; let cleanup;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('re-attributes the admin id and clears event_id, leaving real rows alone', async () => {
await db('activity_logs').insert([
// Legacy shape: event_id is really admin #7, no actor recorded.
{
activity_type: 'expense_created',
actor_type: 'system',
actor_id: null,
event_id: 7,
metadata: JSON.stringify({ expenseId: 1 }),
created_at: new Date().toISOString(),
},
{
activity_type: 'incoming_invoice_captured',
actor_type: 'system',
actor_id: null,
event_id: 9,
metadata: JSON.stringify({ inboundDocumentId: 2 }),
created_at: new Date().toISOString(),
},
// A genuine event-scoped row from another subsystem must survive intact.
{
activity_type: 'photo_uploaded',
actor_type: 'admin',
actor_id: 3,
event_id: 7,
metadata: JSON.stringify({}),
created_at: new Date().toISOString(),
},
]);
await migration.up(db);
const expense = await db('activity_logs').where({ activity_type: 'expense_created' }).first();
expect(expense.event_id == null).toBe(true);
expect(Number(expense.actor_id)).toBe(7);
expect(expense.actor_type).toBe('admin');
const captured = await db('activity_logs').where({ activity_type: 'incoming_invoice_captured' }).first();
expect(captured.event_id == null).toBe(true);
expect(Number(captured.actor_id)).toBe(9);
const photo = await db('activity_logs').where({ activity_type: 'photo_uploaded' }).first();
expect(Number(photo.event_id)).toBe(7);
expect(Number(photo.actor_id)).toBe(3);
});
it('is idempotent on re-run', async () => {
await expect(migration.up(db)).resolves.toBeUndefined();
const expense = await db('activity_logs').where({ activity_type: 'expense_created' }).first();
expect(Number(expense.actor_id)).toBe(7);
expect(expense.event_id == null).toBe(true);
});
});
@@ -0,0 +1,196 @@
/**
* Dashboard endpoints must not leak other admins' data to event-scoped
* editors — GHSA-c2jj (/stats), GHSA-gqx7 (/analytics), GHSA-jhcf (/activity).
*
* All three are gated only by `analytics.view`, which the `editor` role holds.
* But the events LIST restricts editors to their own rows
* (adminEvents/crud.js: roleName === 'editor' → created_by = admin.id), so an
* editor saw instance-wide totals — and, via /analytics topGalleries, other
* admins' gallery names and SLUGS (the public gallery URL component) — for
* events invisible to them everywhere else.
*
* Scoping deliberately keys on `editor` to mirror the events list exactly, so
* the `admin` role's dashboard is unchanged.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-dashscope-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'dashscope-test-secret';
const request = require('supertest');
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
describe('dashboard scoping (GHSA-c2jj / gqx7 / jhcf)', () => {
let db; let cleanup; let app;
let editorToken; let superToken;
let ownEventId; let foreignEventId;
const mkAdmin = async (username, roleName) => {
const role = await db('roles').where({ name: roleName }).first();
const r = await db('admin_users').insert({
username,
email: `${username}@example.com`,
password_hash: await bcrypt.hash('Passw0rd!', 4),
role_id: role.id,
is_active: 1,
created_at: new Date(),
updated_at: new Date(),
}).returning('id');
const id = r[0]?.id ?? r[0];
const token = jwt.sign(
{ id, username, type: 'admin', role: roleName, loginTime: Date.now() },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' },
);
return { id, token };
};
const mkEvent = async (slug, createdBy) => {
const r = await db('events').insert({
slug,
event_type: 'wedding',
event_name: `${slug}-name`,
event_date: '2026-08-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_token: `tok-${slug}`,
share_link: `/gallery/${slug}/tok-${slug}`,
created_by: createdBy,
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
return r[0]?.id ?? r[0];
};
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const editor = await mkAdmin('scoped-editor', 'editor');
const sup = await mkAdmin('root-admin', 'super_admin');
editorToken = editor.token;
superToken = sup.token;
ownEventId = await mkEvent('own-gallery', editor.id);
foreignEventId = await mkEvent('foreign-gallery', sup.id);
// One photo + one view per event so the aggregates are non-zero.
for (const [eventId, name] of [[ownEventId, 'own'], [foreignEventId, 'foreign']]) {
await db('photos').insert({
event_id: eventId,
filename: `${name}.jpg`,
path: `events/active/${name}.jpg`,
type: 'individual',
size_bytes: 1000,
uploaded_at: new Date().toISOString(),
});
await db('access_logs').insert({
event_id: eventId,
action: 'view',
ip_address: `10.0.0.${eventId}`,
user_agent: 'Mozilla/5.0',
timestamp: new Date().toISOString(),
});
await db('activity_logs').insert({
activity_type: 'photo_viewed',
actor_type: 'admin',
actor_name: `${name}-actor`,
event_id: eventId,
created_at: new Date().toISOString(),
});
}
app = express();
app.use(express.json());
app.use('/api/admin/dashboard', require('../../src/routes/adminDashboard'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('/stats counts only the editor\'s own events and photos', async () => {
const res = await request(app)
.get('/api/admin/dashboard/stats')
.set('Authorization', `Bearer ${editorToken}`);
expect(res.status).toBe(200);
expect(Number(res.body.totalEvents)).toBe(1);
expect(Number(res.body.totalPhotos)).toBe(1);
expect(Number(res.body.storageUsed)).toBe(1000);
});
it('/analytics does not expose a foreign gallery name or slug', async () => {
const res = await request(app)
.get('/api/admin/dashboard/analytics?days=7')
.set('Authorization', `Bearer ${editorToken}`);
expect(res.status).toBe(200);
const body = JSON.stringify(res.body);
expect(body).not.toContain('foreign-gallery');
expect(body).not.toContain('foreign-gallery-name');
expect(res.body.topGalleries.map((g) => g.slug)).toEqual(['own-gallery']);
});
it('/activity does not surface a foreign event\'s entries', async () => {
const res = await request(app)
.get('/api/admin/dashboard/activity')
.set('Authorization', `Bearer ${editorToken}`);
expect(res.status).toBe(200);
const actors = res.body.map((a) => a.actorName);
expect(actors).toContain('own-actor');
expect(actors).not.toContain('foreign-actor');
});
it('leaves super_admin unscoped across all three', async () => {
const stats = await request(app)
.get('/api/admin/dashboard/stats')
.set('Authorization', `Bearer ${superToken}`);
expect(Number(stats.body.totalEvents)).toBe(2);
const analytics = await request(app)
.get('/api/admin/dashboard/analytics?days=7')
.set('Authorization', `Bearer ${superToken}`);
expect(analytics.body.topGalleries.map((g) => g.slug).sort())
.toEqual(['foreign-gallery', 'own-gallery']);
const activity = await request(app)
.get('/api/admin/dashboard/activity')
.set('Authorization', `Bearer ${superToken}`);
expect(activity.body.map((a) => a.actorName)).toContain('foreign-actor');
});
});
/**
* Codex round 2: the /activity filter trusts `activity_logs.event_id`, but
* expenseService was passing `adminId` into logActivity's third positional
* parameter — which is `eventId`. Admin and event id sequences overlap, so a
* foreign admin's expense metadata could surface under an editor's event.
* Those writers now pass the actor instead, leaving event_id NULL.
*/
describe('activity writers do not put admin ids in event_id (GHSA-jhcf)', () => {
it('expenseService passes the actor, not adminId, as the event id', () => {
const fs2 = require('fs');
const src = fs2.readFileSync(
require('path').join(__dirname, '../../src/services/expenseService.js'), 'utf8',
);
// No logActivity call may end with a bare `, adminId)` — that slot is eventId.
const offenders = src.split('\n').filter(
(l) => l.includes('logActivity(') && /,\s*adminId\s*\)/.test(l),
);
expect(offenders).toEqual([]);
// And the actor form must actually be in use.
expect(src).toContain("{ type: 'admin', id: adminId }");
});
});
@@ -0,0 +1,120 @@
/**
* Manual database backup must not honour a caller-supplied destination
* (GHSA-jw8m-43r2-jqrm).
*
* POST /api/admin/database-backup/backup forwarded req.body straight into
* databaseBackupService.backup(), which merges options over its config:
* const { destinationPath = '/backup/database', ... } = { ...config, ...options }
* `destinationPath` is not a persistable setting (the /config allowlist only
* accepts `database_backup_*` keys), so the request body was its ONLY source.
*
* The `admin` role holds backup.create but neither settings.edit nor
* backup.restore — so it could aim a full DB dump (bcrypt hashes, gallery
* password hashes, encrypted SMTP creds) at the PUBLIC /uploads static mount
* (server.js mounts it with no auth middleware) and fetch it unauthenticated.
*
* Pins that destinationPath from the body is ignored, while the legitimate
* knobs still pass through.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-dbbackup-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'dbbackup-test-secret';
// Capture what the route hands the service; never run a real backup.
const mockBackup = jest.fn(async () => ({ success: true }));
jest.mock('../../src/services/databaseBackup', () => ({
databaseBackupService: {
get isRunning() { return false; },
backup: (...args) => mockBackup(...args),
},
}));
const request = require('supertest');
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
describe('manual database backup destination (GHSA-jw8m)', () => {
let db; let cleanup; let app; let adminToken;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const role = await db('roles').where({ name: 'admin' }).first();
const r = await db('admin_users').insert({
username: 'limited-admin',
email: 'limited-admin@example.com',
password_hash: await bcrypt.hash('Passw0rd!', 4),
role_id: role.id,
is_active: 1,
created_at: new Date(),
updated_at: new Date(),
}).returning('id');
const id = r[0]?.id ?? r[0];
adminToken = jwt.sign(
{ id, username: 'limited-admin', type: 'admin', role: 'admin', loginTime: Date.now() },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' },
);
app = express();
app.use(express.json());
app.use('/api/admin/database-backup', require('../../src/routes/adminDatabaseBackup'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
beforeEach(() => mockBackup.mockClear());
it('ignores a caller-supplied destinationPath', async () => {
const res = await request(app)
.post('/api/admin/database-backup/backup')
.set('Authorization', `Bearer ${adminToken}`)
.send({ destinationPath: '/app/storage/uploads' });
expect(res.status).toBe(200);
// Give the fire-and-forget call a tick to land.
await new Promise((resolve) => setImmediate(resolve));
expect(mockBackup).toHaveBeenCalled();
const opts = mockBackup.mock.calls[0][0];
expect(opts).not.toHaveProperty('destinationPath');
expect(JSON.stringify(opts)).not.toContain('uploads');
});
it('still forwards the legitimate backup knobs', async () => {
const res = await request(app)
.post('/api/admin/database-backup/backup')
.set('Authorization', `Bearer ${adminToken}`)
.send({ compress: false, validateIntegrity: false, destinationPath: '/tmp/evil' });
expect(res.status).toBe(200);
await new Promise((resolve) => setImmediate(resolve));
const opts = mockBackup.mock.calls[0][0];
expect(opts.compress).toBe(false);
expect(opts.validateIntegrity).toBe(false);
expect(opts).not.toHaveProperty('destinationPath');
});
it('omits absent knobs entirely so service/config defaults still apply', async () => {
const res = await request(app)
.post('/api/admin/database-backup/backup')
.set('Authorization', `Bearer ${adminToken}`)
.send({});
expect(res.status).toBe(200);
await new Promise((resolve) => setImmediate(resolve));
// An explicit `{compress: undefined}` would override config on spread —
// absent keys must simply not be present.
expect(mockBackup.mock.calls[0][0]).toEqual({});
});
});
@@ -0,0 +1,123 @@
/**
* Logo diagnostic must not leak the filesystem layout, and must mirror what
* resolveLogoFile actually tries (GHSA-29vm, codex round 2).
*
* Round 1 relativised `resolvedTo` and the candidate paths but still echoed
* `sources[].value` verbatim — and branding_logo_path is stored ABSOLUTE by
* multer, so the layout went out anyway. It also dropped the raw-absolute
* candidate, which the resolver retains (subject to containment), making the
* diagnostic report every candidate as missing for a legitimately contained
* absolute logo while `resolvedTo` named the file.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-logodiag-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'logodiag-test-secret';
const request = require('supertest');
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
describe('logo diagnostic disclosure (GHSA-29vm)', () => {
let db; let cleanup; let app; let token;
// bootCrmDb() sets STORAGE_PATH itself, so resolve these AFTER it runs.
let STORAGE; let logoDir; let logoPath;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
// A legitimately contained absolute logo in a NON-standard storage subdir.
STORAGE = process.env.STORAGE_PATH;
logoDir = path.join(STORAGE, 'custom');
logoPath = path.join(logoDir, 'logo.png');
fs.mkdirSync(logoDir, { recursive: true });
fs.writeFileSync(logoPath, 'png');
const setting = { setting_key: 'branding_logo_path', setting_value: JSON.stringify(logoPath), setting_type: 'branding' };
const existing = await db('app_settings').where({ setting_key: 'branding_logo_path' }).first();
if (existing) await db('app_settings').where({ setting_key: 'branding_logo_path' }).update(setting);
else await db('app_settings').insert(setting);
const role = await db('roles').where({ name: 'super_admin' }).first();
const r = await db('admin_users').insert({
username: 'diag-admin', email: 'diag@example.com',
password_hash: await bcrypt.hash('Passw0rd!', 4),
role_id: role.id, is_active: 1,
created_at: new Date(), updated_at: new Date(),
}).returning('id');
const id = r[0]?.id ?? r[0];
token = jwt.sign(
{ id, username: 'diag-admin', type: 'admin', role: 'super_admin', loginTime: Date.now() },
process.env.JWT_SECRET, { expiresIn: '1h', issuer: 'picpeak-auth' },
);
app = express();
app.use(express.json());
app.use('/api/admin/business-profile', require('../../src/routes/adminBusinessProfile'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('does not leak absolute paths, cwd or storage root anywhere in the payload', async () => {
const res = await request(app)
.get('/api/admin/business-profile/logo-diagnostic')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
const body = JSON.stringify(res.body);
expect(body).not.toContain(STORAGE);
expect(body).not.toContain(process.cwd());
expect(res.body.storageRoot).toBeUndefined();
expect(res.body.cwd).toBeUndefined();
});
it('still finds a contained absolute logo outside the standard subdirs', async () => {
const res = await request(app)
.get('/api/admin/business-profile/logo-diagnostic')
.set('Authorization', `Bearer ${token}`);
const source = res.body.sources.find((s) => s.label === 'app_settings.branding_logo_path');
expect(source).toBeTruthy();
// The resolver keeps the contained absolute candidate, so the diagnostic
// must show it existing rather than reporting everything missing.
expect(source.candidates.some((c) => c.exists)).toBe(true);
expect(res.body.resolvedTo).toMatch(/^<STORAGE>\//);
});
it('shows the <STORAGE>/<value> candidate for a ROOT-RELATIVE logo URL (round 3)', async () => {
// `/custom/logo.png` is a URL, not a disk path, but path.isAbsolute() says
// true for both. Gating the stripped joins on isAbsolute() therefore hid
// `<STORAGE>/custom/logo.png` — a candidate resolveLogoFile does try and
// can resolve — so the diagnostic claimed nothing existed for a logo that
// renders fine, and collapsed the configured value to its basename.
await db('app_settings').where({ setting_key: 'branding_logo_path' })
.update({ setting_value: JSON.stringify('/custom/logo.png') });
const res = await request(app)
.get('/api/admin/business-profile/logo-diagnostic')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
const source = res.body.sources.find((s) => s.label === 'app_settings.branding_logo_path');
expect(source.candidates.some((c) => c.path === '<STORAGE>/custom/logo.png' && c.exists)).toBe(true);
// …and the disclosure guarantee still holds for this shape.
const body = JSON.stringify(res.body);
expect(body).not.toContain(STORAGE);
expect(body).not.toContain(process.cwd());
await db('app_settings').where({ setting_key: 'branding_logo_path' })
.update({ setting_value: JSON.stringify(logoPath) });
});
});
@@ -0,0 +1,181 @@
/**
* Project ownership — GHSA-wrg5 (project routes) and GHSA-93x4 (project email
* endpoints).
*
* Project routes authorized on generic events.view / events.edit with NO
* ownership check, so an editor could enumerate, read, update and aggregate
* projects belonging to other admins' events. The email endpoints keyed on an
* email_queue id alone, so any id could be previewed/resent/cancelled.
*
* `projects` had no owner column. It was added in migration 167 (backfilled
* from linked events) rather than relying only on the transitive
* events.project_id -> events.created_by path, because a brand-new EMPTY
* project has no linked event to infer an owner from — which is exactly where
* the create -> attach flow begins.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-projown-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'projown-test-secret';
const request = require('supertest');
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
describe('project ownership (GHSA-wrg5 / GHSA-93x4)', () => {
let db; let cleanup; let app;
let editorToken; let superToken; let editorId; let superId;
let ownProjectId; let foreignProjectId; let foreignEventId; let foreignEmailId;
const mkAdmin = async (username, roleName) => {
const role = await db('roles').where({ name: roleName }).first();
const r = await db('admin_users').insert({
username,
email: `${username}@example.com`,
password_hash: await bcrypt.hash('Passw0rd!', 4),
role_id: role.id,
is_active: 1,
created_at: new Date(),
updated_at: new Date(),
}).returning('id');
const id = r[0]?.id ?? r[0];
return {
id,
token: jwt.sign(
{ id, username, type: 'admin', role: roleName, loginTime: Date.now() },
process.env.JWT_SECRET, { expiresIn: '1h', issuer: 'picpeak-auth' },
),
};
};
const mkProject = async (name, createdBy) => {
const r = await db('projects').insert({
name, status: 'active', created_by: createdBy,
created_at: new Date(), updated_at: new Date(),
}).returning('id');
return r[0]?.id ?? r[0];
};
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
await db('feature_flags').insert({ key: 'projects', value: 1 })
.onConflict('key').merge({ value: 1 });
const editor = await mkAdmin('proj-editor', 'editor');
const sup = await mkAdmin('proj-super', 'super_admin');
editorToken = editor.token; editorId = editor.id;
superToken = sup.token; superId = sup.id;
ownProjectId = await mkProject('own-project', editorId);
foreignProjectId = await mkProject('foreign-project', superId);
// A foreign event linked to the foreign project, plus a queued email on it.
const ev = await db('events').insert({
slug: 'foreign-ev',
event_type: 'wedding',
event_name: 'Foreign Event',
event_date: '2026-08-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_token: 'ftok', share_link: '/gallery/foreign-ev/ftok',
created_by: superId,
project_id: foreignProjectId,
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
foreignEventId = ev[0]?.id ?? ev[0];
const em = await db('email_queue').insert({
event_id: foreignEventId,
recipient_email: 'client@example.com',
email_type: 'gallery_created',
status: 'sent',
created_at: new Date().toISOString(),
}).returning('id');
foreignEmailId = em[0]?.id ?? em[0];
app = express();
app.use(express.json());
app.use('/api/admin/projects', require('../../src/routes/adminProjects'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('lists only the editor\'s own projects', async () => {
const res = await request(app)
.get('/api/admin/projects')
.set('Authorization', `Bearer ${editorToken}`);
expect(res.status).toBe(200);
const names = (res.body.projects || res.body.data?.projects || []).map((p) => p.name);
expect(names).toContain('own-project');
expect(names).not.toContain('foreign-project');
});
it('refuses to read a foreign project', async () => {
const res = await request(app)
.get(`/api/admin/projects/${foreignProjectId}`)
.set('Authorization', `Bearer ${editorToken}`);
expect([403, 404]).toContain(res.status);
});
it('refuses to update or aggregate a foreign project', async () => {
const update = await request(app)
.put(`/api/admin/projects/${foreignProjectId}`)
.set('Authorization', `Bearer ${editorToken}`)
.send({ name: 'hijacked' });
expect([403, 404]).toContain(update.status);
const overview = await request(app)
.get(`/api/admin/projects/${foreignProjectId}/overview`)
.set('Authorization', `Bearer ${editorToken}`);
expect([403, 404]).toContain(overview.status);
// And the name must not have changed.
const row = await db('projects').where({ id: foreignProjectId }).first();
expect(row.name).toBe('foreign-project');
});
it('refuses to attach a FOREIGN event to an owned project', async () => {
const res = await request(app)
.post(`/api/admin/projects/${ownProjectId}/events`)
.set('Authorization', `Bearer ${editorToken}`)
.send({ eventId: foreignEventId });
expect([403, 404]).toContain(res.status);
const ev = await db('events').where({ id: foreignEventId }).first();
expect(ev.project_id).toBe(foreignProjectId); // still attached to its own
});
it('refuses to preview or act on a foreign queued email (GHSA-93x4)', async () => {
const preview = await request(app)
.get(`/api/admin/projects/email/${foreignEmailId}/preview`)
.set('Authorization', `Bearer ${editorToken}`);
expect([403, 404]).toContain(preview.status);
const cancel = await request(app)
.post(`/api/admin/projects/email/${foreignEmailId}/cancel`)
.set('Authorization', `Bearer ${editorToken}`);
expect([403, 404]).toContain(cancel.status);
});
it('leaves super_admin unrestricted', async () => {
const res = await request(app)
.get(`/api/admin/projects/${foreignProjectId}`)
.set('Authorization', `Bearer ${superToken}`);
expect(res.status).toBe(200);
});
});
@@ -0,0 +1,115 @@
/**
* Project ownership edge cases (GHSA-wrg5, codex round 2).
*
* The first predicate union'd "any linked event I can see" with the stored
* owner, which opened two holes:
* - a project owned by B containing ONE legacy ownerless event became
* readable by everyone (and /overview aggregates B's other events,
* invoices and emails);
* - migration 167 deliberately leaves multi-owner projects NULL, and a NULL
* owner was treated as "everyone's".
* The stored owner is now authoritative, and a NULL owner only derives access
* when EVERY linked event is accessible.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-projedge-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'projedge-test-secret';
const bcrypt3 = require('bcrypt');
const { bootCrmDb: boot3, seedMinimal: seed3 } = require('../integration/helpers/crmDb');
describe('project ownership edge cases (GHSA-wrg5, round 2)', () => {
let db3; let cleanup3; let ownership; let editorA; let editorB;
const mkAdmin3 = async (username, roleName) => {
const role = await db3('roles').where({ name: roleName }).first();
const r = await db3('admin_users').insert({
username, email: `${username}@example.com`,
password_hash: await bcrypt3.hash('Passw0rd!', 4),
role_id: role.id, is_active: 1,
created_at: new Date(), updated_at: new Date(),
}).returning('id');
return r[0]?.id ?? r[0];
};
const mkProject3 = async (name, createdBy) => {
const r = await db3('projects').insert({
name, status: 'active', created_by: createdBy,
created_at: new Date(), updated_at: new Date(),
}).returning('id');
return r[0]?.id ?? r[0];
};
const mkEvent3 = async (slug, createdBy, projectId) => {
const r = await db3('events').insert({
slug, event_type: 'wedding', event_name: slug, event_date: '2026-08-01',
host_email: 'h@e.com', admin_email: 'a@e.com', password_hash: 'x',
share_token: `t-${slug}`, share_link: `/g/${slug}/t-${slug}`,
created_by: createdBy, project_id: projectId,
expires_at: new Date(Date.now() + 864e5).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
return r[0]?.id ?? r[0];
};
beforeAll(async () => {
({ db: db3, cleanup: cleanup3 } = await boot3());
await seed3(db3);
ownership = require('../../src/middleware/ownership');
editorA = await mkAdmin3('edge-a', 'editor');
editorB = await mkAdmin3('edge-b', 'editor');
}, 120000);
afterAll(async () => { if (cleanup3) await cleanup3(); });
it('one ownerless event in B\'s project does not expose it to A', async () => {
const pid = await mkProject3('b-project', editorB);
await mkEvent3('b-owned-ev', editorB, pid);
await mkEvent3('legacy-ev', null, pid); // ownerless legacy event
const idsA = await ownership.ownedProjectIds({ id: editorA, roleName: 'editor' });
expect(idsA).not.toContain(Number(pid));
const idsB = await ownership.ownedProjectIds({ id: editorB, roleName: 'editor' });
expect(idsB).toContain(Number(pid));
});
it('a mixed-owner project left NULL by migration 167 is not global', async () => {
const pid = await mkProject3('ambiguous', null);
await mkEvent3('mix-a-ev', editorA, pid);
await mkEvent3('mix-b-ev', editorB, pid);
for (const who of [editorA, editorB]) {
const ids = await ownership.ownedProjectIds({ id: who, roleName: 'editor' });
expect(ids).not.toContain(Number(pid));
}
});
it('a NULL-owner project whose events are all mine IS mine', async () => {
const pid = await mkProject3('legacy-mine', null);
await mkEvent3('mine-ev', editorA, pid);
const ids = await ownership.ownedProjectIds({ id: editorA, roleName: 'editor' });
expect(ids).toContain(Number(pid));
});
it('a project whose creator was deleted falls back to its events', async () => {
const ghost = await mkAdmin3('ghost-admin', 'editor');
const pid = await mkProject3('orphaned', ghost);
await mkEvent3('orphan-ev', editorA, pid);
await db3('admin_users').where({ id: ghost }).del();
const ids = await ownership.ownedProjectIds({ id: editorA, roleName: 'editor' });
expect(ids).toContain(Number(pid));
});
it('super_admin stays unrestricted', async () => {
expect(await ownership.ownedProjectIds({ id: 1, roleName: 'super_admin' })).toBeNull();
});
});
@@ -0,0 +1,86 @@
/**
* Restore path containment must not break the normal restore wizard
* (GHSA-fw4c, codex round 2).
*
* `source` is usually a SOURCE TYPE, not a path: RestoreWizard posts
* 'local' | 's3' | 'upload', and restoreService.restore() branches on those
* literals before deriving a directory. The first version of the containment
* check treated `source` as a path, so path.resolve('local') landed outside
* the configured backup roots and BOTH /validate and /start returned 400 —
* blocking every normal restore.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-restorepath-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'restorepath-test-secret';
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
describe('restore path allowlist (GHSA-fw4c)', () => {
let db; let cleanup; let checkRestorePathsAllowed;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
// Configure a backup root so the allowlist is actually active.
for (const [key, value] of [['backup_destination_path', '/backup']]) {
const existing = await db('app_settings').where({ setting_key: key }).first();
if (existing) {
await db('app_settings').where({ setting_key: key }).update({ setting_value: JSON.stringify(value) });
} else {
await db('app_settings').insert({
setting_key: key, setting_value: JSON.stringify(value), setting_type: 'backup',
});
}
}
({ checkRestorePathsAllowed } = require('../../src/routes/adminRestore')._internal);
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('allows the wizard\'s source TYPE tokens', async () => {
for (const source of ['local', 's3', 'upload']) {
const err = await checkRestorePathsAllowed({
source, manifestPath: '/backup/manifests/backup-manifest-1.json',
});
expect(err).toBeNull();
}
});
it('allows an s3:// source URL', async () => {
const err = await checkRestorePathsAllowed({
source: 's3://bucket/key/backup.tar.gz',
manifestPath: '/backup/manifests/backup-manifest-1.json',
});
expect(err).toBeNull();
});
it('still rejects a manifestPath outside the configured roots', async () => {
const err = await checkRestorePathsAllowed({
source: 'local', manifestPath: '/etc/passwd',
});
expect(err).toMatch(/inside a configured backup location/i);
});
it('still rejects a traversal manifestPath', async () => {
const err = await checkRestorePathsAllowed({
source: 'local', manifestPath: '/backup/../etc/shadow',
});
expect(err).toBeTruthy();
});
it('accepts a real path source inside the roots', async () => {
const err = await checkRestorePathsAllowed({
source: '/backup/run-1', manifestPath: '/backup/run-1/manifest.json',
});
expect(err).toBeNull();
});
});
@@ -0,0 +1,155 @@
/**
* v1 API tokens must respect event ownership (GHSA-9697).
*
* migration 081 documents the intent — "the token's effective permissions are
* the intersection of the user's role permissions and the token's own scope
* flags" — but it was never implemented:
*
* - apiTokenAuth selected only id/username/email/role_id, so
* req.admin.roleName was undefined and every ownership helper (which all
* key on roleName) could not distinguish a super_admin from a viewer.
* - No v1 route applied requirePermission or a created_by predicate, so any
* valid token listed every event and — worst — GET /events/:id/share-link
* returned ANY event's share_token, which is the gallery access credential.
*
* Scenario pinned here: a token owned by a restricted (non-super_admin) admin
* must see only its owner's events, and must not obtain a foreign share_token.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-v1own-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'v1own-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-v1own-storage-'));
const request = require('supertest');
const express = require('express');
const bcrypt = require('bcrypt');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const { generateApiToken } = require('../../src/middleware/apiTokenAuth');
describe('v1 event ownership (GHSA-9697)', () => {
let db; let cleanup; let app;
let editorToken; let superToken;
let ownEventId; let foreignEventId;
const FOREIGN_SHARE_TOKEN = 'f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0';
const mkAdmin = async (username, roleName) => {
const role = await db('roles').where({ name: roleName }).first();
const r = await db('admin_users').insert({
username,
email: `${username}@example.com`,
password_hash: await bcrypt.hash('Passw0rd!', 4),
role_id: role.id,
is_active: 1,
created_at: new Date(),
updated_at: new Date(),
}).returning('id');
return r[0]?.id ?? r[0];
};
const mkToken = async (adminId, scopes = 'admin') => {
const { plaintext, hashed } = generateApiToken();
await db('api_tokens').insert({
name: `tok-${adminId}`,
hashed_token: hashed,
scopes,
created_by: adminId,
created_at: new Date().toISOString(),
});
return plaintext;
};
const mkEvent = async (slug, createdBy, shareToken) => {
const r = await db('events').insert({
slug,
event_type: 'wedding',
event_name: slug,
event_date: '2026-08-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_token: shareToken,
share_link: `/gallery/${slug}/${shareToken}`,
created_by: createdBy,
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
return r[0]?.id ?? r[0];
};
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const editorId = await mkAdmin('restricted-editor', 'editor');
const superId = await mkAdmin('root-admin', 'super_admin');
editorToken = await mkToken(editorId);
superToken = await mkToken(superId);
ownEventId = await mkEvent('own-event', editorId, 'a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1');
foreignEventId = await mkEvent('foreign-event', superId, FOREIGN_SHARE_TOKEN);
app = express();
app.use(express.json());
app.use('/api/v1', require('../../src/routes/v1/events'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('lists only the token owner\'s events', async () => {
const res = await request(app)
.get('/api/v1/events')
.set('Authorization', `Bearer ${editorToken}`);
expect(res.status).toBe(200);
const slugs = res.body.events.map((e) => e.slug);
expect(slugs).toContain('own-event');
expect(slugs).not.toContain('foreign-event');
});
it('refuses to read a foreign event', async () => {
const res = await request(app)
.get(`/api/v1/events/${foreignEventId}`)
.set('Authorization', `Bearer ${editorToken}`);
expect([403, 404]).toContain(res.status);
});
it('does NOT hand out a foreign event\'s share_token', async () => {
const res = await request(app)
.get(`/api/v1/events/${foreignEventId}/share-link`)
.set('Authorization', `Bearer ${editorToken}`);
expect([403, 404]).toContain(res.status);
expect(JSON.stringify(res.body)).not.toContain(FOREIGN_SHARE_TOKEN);
});
it('still allows the owner to read their own event and share link', async () => {
const detail = await request(app)
.get(`/api/v1/events/${ownEventId}`)
.set('Authorization', `Bearer ${editorToken}`);
expect(detail.status).toBe(200);
const share = await request(app)
.get(`/api/v1/events/${ownEventId}/share-link`)
.set('Authorization', `Bearer ${editorToken}`);
expect(share.status).toBe(200);
expect(share.body.share_token).toBe('a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1');
});
it('leaves super_admin tokens unrestricted', async () => {
const res = await request(app)
.get(`/api/v1/events/${foreignEventId}/share-link`)
.set('Authorization', `Bearer ${superToken}`);
expect(res.status).toBe(200);
expect(res.body.share_token).toBe(FOREIGN_SHARE_TOKEN);
});
});
@@ -0,0 +1,108 @@
/**
* v1 token scopes must intersect the owner's CURRENT role permissions
* (GHSA-9697, codex round 2).
*
* Migration 081 documents effective permissions as the intersection of the
* owner's role permissions and the token's scope flags. requireApiScope only
* ever checked the scope half, so a token minted while its owner was
* super_admin kept full write access after the owner was demoted to viewer —
* userManagementService never touches api_tokens, so the token outlives the
* demotion. Ownership scoping alone does not close this: the demoted owner
* still *owns* their events.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-v1perm-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'v1perm-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-v1perm-storage-'));
const request = require('supertest');
const express = require('express');
const bcrypt = require('bcrypt');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const { generateApiToken } = require('../../src/middleware/apiTokenAuth');
describe('v1 token scopes intersect role permissions (GHSA-9697)', () => {
let db; let cleanup; let app; let viewerToken; let viewerEventId;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const role = await db('roles').where({ name: 'viewer' }).first();
const r = await db('admin_users').insert({
username: 'demoted-owner',
email: 'demoted@example.com',
password_hash: await bcrypt.hash('Passw0rd!', 4),
role_id: role.id,
is_active: 1,
created_at: new Date(),
updated_at: new Date(),
}).returning('id');
const ownerId = r[0]?.id ?? r[0];
// A token still carrying the broad 'admin' scope from before demotion.
const { plaintext, hashed } = generateApiToken();
await db('api_tokens').insert({
name: 'stale-token',
hashed_token: hashed,
scopes: 'admin',
created_by: ownerId,
created_at: new Date().toISOString(),
});
viewerToken = plaintext;
const ev = await db('events').insert({
slug: 'viewer-ev',
event_type: 'wedding',
event_name: 'Viewer Event',
event_date: '2026-08-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_token: 'vtok',
share_link: '/gallery/viewer-ev/vtok',
created_by: ownerId,
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
viewerEventId = ev[0]?.id ?? ev[0];
app = express();
app.use(express.json());
app.use('/api/v1', require('../../src/routes/v1/events'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('denies event creation to a demoted viewer despite an admin-scope token', async () => {
const res = await request(app)
.post('/api/v1/events')
.set('Authorization', `Bearer ${viewerToken}`)
.send({ event_name: 'Nope', event_type: 'wedding' });
expect(res.status).toBe(403);
});
it('denies photo upload to a demoted viewer on their OWN event', async () => {
const res = await request(app)
.post(`/api/v1/events/${viewerEventId}/photos`)
.set('Authorization', `Bearer ${viewerToken}`)
.attach('photo', Buffer.from('x'), 'a.jpg');
expect(res.status).toBe(403);
});
it('still allows the viewer to READ their own event', async () => {
const res = await request(app)
.get(`/api/v1/events/${viewerEventId}`)
.set('Authorization', `Bearer ${viewerToken}`);
expect(res.status).toBe(200);
});
});
@@ -0,0 +1,241 @@
/**
* Backup/restore hardening — GHSA-h652 (unbounded gunzip) and GHSA-hgp8
* (unkeyed manifest checksum).
*
* h652: decompressFile() piped gunzip straight to disk with no expanded-size
* bound, so a small crafted .gz could fill the volume.
*
* hgp8: the manifest checksum is a plain SHA-256 — it proves the manifest was
* not corrupted, not that it is authentic. BACKUP_MANIFEST_KEY upgrades new
* manifests to a keyed HMAC. It is deliberately OPT-IN and verify-if-present:
* the key cannot live in the database (the database is inside the backup), so
* a mandatory HMAC would lock an operator out of the exact disaster-recovery
* case this system exists for.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
const zlib = require('zlib');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-bkharden-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'bkharden-test-secret';
const { restoreService } = require('../../src/services/restoreService');
const backupManifest = require('../../src/services/backupManifest');
describe('decompressFile expanded-size bound (GHSA-h652)', () => {
let dir;
beforeAll(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-gz-')); });
afterAll(() => { fs.rmSync(dir, { recursive: true, force: true }); });
afterEach(() => { delete process.env.RESTORE_MAX_DECOMPRESSED_BYTES; });
it('aborts when the decompressed stream exceeds the limit', async () => {
// 5 MB of zeroes compresses to a few KB — the classic shape of the attack.
const gzPath = path.join(dir, 'bomb.gz');
fs.writeFileSync(gzPath, zlib.gzipSync(Buffer.alloc(5 * 1024 * 1024, 0)));
process.env.RESTORE_MAX_DECOMPRESSED_BYTES = String(64 * 1024); // 64 KB
await expect(
restoreService.decompressFile(gzPath, path.join(dir, 'out-bomb'))
).rejects.toThrow(/exceeds limit/i);
});
it('still decompresses a normal file within the limit', async () => {
const payload = Buffer.from('SELECT 1;\n'.repeat(100));
const gzPath = path.join(dir, 'ok.gz');
fs.writeFileSync(gzPath, zlib.gzipSync(payload));
const outPath = path.join(dir, 'out-ok');
await restoreService.decompressFile(gzPath, outPath);
expect(fs.readFileSync(outPath)).toEqual(payload);
});
});
describe('manifest checksum keying (GHSA-hgp8)', () => {
// validateManifest requires all of these sections to be present.
const baseManifest = () => ({
manifest: { version: '1.0', id: 'test' },
backup: { type: 'full' },
system: { platform: 'linux' },
application: { version: '1.0.0' },
files: { count: 1, manifest: [{ path: 'a.jpg', size: 1 }] },
database: { type: 'sqlite' },
verification: { total_checksum: null, checksum_algorithm: null },
});
afterEach(() => { delete process.env.BACKUP_MANIFEST_KEY; });
it('produces a different digest when a key is set', () => {
const m = baseManifest();
const unkeyed = backupManifest.calculateManifestChecksum(m, { keyed: false });
const keyed = backupManifest.calculateManifestChecksum(m, { keyed: 'secret-key' });
expect(keyed).not.toBe(unkeyed);
});
it('validates a legacy unkeyed manifest even when a key IS configured', () => {
// Disaster recovery: manifests written before keying must not become
// un-restorable the moment the operator sets a key.
const m = baseManifest();
m.verification.checksum_algorithm = 'sha256';
m.verification.total_checksum = backupManifest.calculateManifestChecksum(m, { keyed: false });
process.env.BACKUP_MANIFEST_KEY = 'secret-key';
expect(() => backupManifest.validateManifest(m)).not.toThrow();
});
it('accepts a keyed manifest when the matching key is configured', () => {
process.env.BACKUP_MANIFEST_KEY = 'secret-key';
const m = baseManifest();
m.verification.checksum_algorithm = 'hmac-sha256';
m.verification.total_checksum = backupManifest.calculateManifestChecksum(m, { keyed: 'secret-key' });
expect(() => backupManifest.validateManifest(m)).not.toThrow();
});
it('rejects a keyed manifest whose body was tampered with', () => {
process.env.BACKUP_MANIFEST_KEY = 'secret-key';
const m = baseManifest();
m.verification.checksum_algorithm = 'hmac-sha256';
m.verification.total_checksum = backupManifest.calculateManifestChecksum(m, { keyed: 'secret-key' });
m.files.manifest[0].path = '../../etc/passwd';
expect(() => backupManifest.validateManifest(m)).toThrow(/checksum verification failed/i);
});
it('does NOT brick restore when a keyed manifest meets a missing key', () => {
// Key lost with the host — the precise moment a restore is needed.
const m = baseManifest();
m.verification.checksum_algorithm = 'hmac-sha256';
m.verification.total_checksum = backupManifest.calculateManifestChecksum(m, { keyed: 'secret-key' });
delete process.env.BACKUP_MANIFEST_KEY;
expect(() => backupManifest.validateManifest(m)).not.toThrow();
});
});
describe('manifest checksum coverage (canonicalization)', () => {
const fullManifest = () => ({
manifest: { version: '1.0', id: 'test' },
backup: { type: 'full' },
system: { platform: 'linux' },
application: { version: '1.0.0' },
files: { count: 1, manifest: [{ path: 'a.jpg', size: 1 }] },
database: { type: 'sqlite' },
verification: { total_checksum: null, checksum_algorithm: 'sha256' },
});
afterEach(() => { delete process.env.BACKUP_MANIFEST_KEY; });
it('covers nested file entries (the old replacer dropped them)', () => {
const m = fullManifest();
m.verification.total_checksum = backupManifest.calculateManifestChecksum(m, { keyed: false });
// Tampering a file path must now change the digest.
m.files.manifest[0].path = '../../etc/passwd';
expect(() => backupManifest.validateManifest(m)).toThrow(/checksum verification failed/i);
});
it('still accepts a manifest written with the legacy serialization', () => {
const m = fullManifest();
m.verification.total_checksum = backupManifest.calculateManifestChecksum(
m, { keyed: false, legacy: true }
);
expect(() => backupManifest.validateManifest(m)).not.toThrow();
});
});
describe('checksum verification is shared and downgrade-aware (codex round 2)', () => {
const fullManifest = () => ({
manifest: { version: '1.0', id: 'test' },
backup: { type: 'full' },
system: { platform: 'linux' },
application: { version: '1.0.0' },
files: { count: 1, manifest: [{ path: 'a.jpg', size: 1 }] },
database: { type: 'sqlite' },
verification: { total_checksum: null, checksum_algorithm: 'sha256' },
});
afterEach(() => {
delete process.env.BACKUP_MANIFEST_KEY;
delete process.env.BACKUP_MANIFEST_REQUIRE_KEYED;
});
it('accepts a legacy-serialized manifest through the SHARED verifier', () => {
// restoreService recomputed the digest itself with the canonical
// serializer, which rejected every pre-existing backup.
const m = fullManifest();
m.verification.total_checksum = backupManifest.calculateManifestChecksum(
m, { keyed: false, legacy: true },
);
const res = backupManifest.verifyManifestChecksum(m);
expect(res.valid).toBe(true);
expect(res.warnings.join(' ')).toMatch(/legacy checksum serialization/i);
});
it('warns but accepts an unkeyed manifest when a key is configured', () => {
const m = fullManifest();
m.verification.total_checksum = backupManifest.calculateManifestChecksum(m, { keyed: false });
process.env.BACKUP_MANIFEST_KEY = 'secret-key';
const res = backupManifest.verifyManifestChecksum(m);
expect(res.valid).toBe(true);
expect(res.warnings.join(' ')).toMatch(/authenticity NOT established/i);
});
it('REJECTS the algorithm downgrade once REQUIRE_KEYED is on', () => {
// Attacker rewrites the manifest, strips checksum_algorithm and recomputes
// a plain SHA-256. With the strict flag set that must not verify.
const m = fullManifest();
m.files.manifest[0].path = '../../etc/passwd';
m.verification.total_checksum = backupManifest.calculateManifestChecksum(m, { keyed: false });
process.env.BACKUP_MANIFEST_KEY = 'secret-key';
process.env.BACKUP_MANIFEST_REQUIRE_KEYED = 'true';
const res = backupManifest.verifyManifestChecksum(m);
expect(res.valid).toBe(false);
expect(res.error).toMatch(/downgrade/i);
});
it('rejects a keyed manifest with no key when REQUIRE_KEYED is on', () => {
const m = fullManifest();
m.verification.checksum_algorithm = 'hmac-sha256';
m.verification.total_checksum = backupManifest.calculateManifestChecksum(m, { keyed: 'k' });
process.env.BACKUP_MANIFEST_REQUIRE_KEYED = 'true';
expect(backupManifest.verifyManifestChecksum(m).valid).toBe(false);
});
it('REJECTS a manifest whose checksum was stripped entirely', () => {
// The cheapest bypass of every rule above: delete the field instead of
// forging it. Both the helper's early return and restoreService's
// `if (…total_checksum)` guard used to wave that through.
const m = fullManifest();
delete m.verification.total_checksum;
const res = backupManifest.verifyManifestChecksum(m);
expect(res.valid).toBe(false);
expect(res.error).toMatch(/no checksum/i);
delete m.verification;
expect(backupManifest.verifyManifestChecksum(m).valid).toBe(false);
});
it('REJECTS an unkeyed manifest under REQUIRE_KEYED even with no key configured', () => {
// Strict mode is a claim about the manifests, not about this host — so a
// fresh disaster-recovery box that lost BACKUP_MANIFEST_KEY must not
// silently start accepting plain SHA-256 manifests again.
const m = fullManifest();
m.verification.total_checksum = backupManifest.calculateManifestChecksum(m, { keyed: false });
process.env.BACKUP_MANIFEST_REQUIRE_KEYED = 'true';
delete process.env.BACKUP_MANIFEST_KEY;
const res = backupManifest.verifyManifestChecksum(m);
expect(res.valid).toBe(false);
expect(res.error).toMatch(/downgrade/i);
});
});
@@ -0,0 +1,127 @@
/**
* Inbound-mail resource caps (GHSA-2qf9).
*
* emailIntakeService downloaded, parsed and persisted every message with no
* size, attachment-count or attachment-byte limit. Anyone who can email the
* operator's mailbox reaches this path unauthenticated.
*
* The teeth were in the dedup key: on failure the service wrote an error row
* keyed `err-<uid>-<Date.now()>`, which can never match the envelope-derived
* `messageId` the dedup pass compares against. So the same oversized message
* was re-downloaded every poll interval forever — and an OOM-kill/restart just
* resumed the loop. This pins that an over-limit message is (a) never
* downloaded and (b) recorded under its REAL message id so it dedups.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-intake-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'intake-test-secret';
process.env.EMAIL_INTAKE_MAX_MESSAGE_BYTES = '1000';
const OVERSIZED_UID = 11;
const NORMAL_UID = 12;
const OVERSIZED_MSGID = '<huge@example.com>';
const fetchOneCalls = [];
jest.mock('imapflow', () => ({
ImapFlow: class {
async connect() {}
async logout() {}
async getMailboxLock() { return { release() {} }; }
async search() { return [OVERSIZED_UID, NORMAL_UID]; }
// Envelope pass now also returns `size`.
async *fetch() {
yield { uid: OVERSIZED_UID, size: 50_000, envelope: { messageId: OVERSIZED_MSGID } };
yield { uid: NORMAL_UID, size: 500, envelope: { messageId: '<ok@example.com>' } };
}
async fetchOne(uid) {
fetchOneCalls.push(String(uid));
return { source: Buffer.from('Subject: ok\r\n\r\nbody') };
}
async messageFlagsAdd() { return true; }
},
}));
jest.mock('mailparser', () => ({
simpleParser: async () => ({
messageId: '<ok@example.com>',
subject: 'ok',
date: new Date(),
attachments: [],
text: 'body',
html: null,
}),
}));
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
describe('email intake caps (GHSA-2qf9)', () => {
let db; let cleanup; let intake;
let pollResult;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
// pollOnce short-circuits unless the feature flag is on AND an IMAP
// account is configured — without both, this suite would pass vacuously.
await db('feature_flags')
.insert({ key: 'incomingMail', value: 1 })
.onConflict('key').merge({ value: 1 });
// getImapConfig() reads email_configs.first() — seedMinimal may already
// have inserted a row, so update that one rather than adding a second
// (the first row would win and report "unconfigured").
const imapFields = {
imap_host: 'imap.example.com',
imap_user: 'intake@example.com',
imap_pass: 'x',
imap_folder: 'INBOX',
};
const existingCfg = await db('email_configs').first();
if (existingCfg) {
await db('email_configs').where({ id: existingCfg.id }).update(imapFields);
} else {
await db('email_configs').insert({
smtp_host: 'smtp.example.com',
smtp_port: 587,
from_email: 'intake@example.com',
...imapFields,
});
}
intake = require('../../src/services/emailIntakeService');
pollResult = await intake.pollOnce().catch((e) => ({ thrown: e.message }));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('actually ran the poll (guards against a vacuous suite)', () => {
expect(pollResult).toBeDefined();
expect(pollResult.skipped).toBeUndefined();
});
it('never downloads a message whose envelope size exceeds the cap', () => {
// The oversized uid must never reach fetchOne (the source download) —
// that download is the DoS. The normal one must still be processed.
expect(fetchOneCalls).not.toContain(String(OVERSIZED_UID));
expect(fetchOneCalls).toContain(String(NORMAL_UID));
});
it('records the skip under the REAL message id so it dedups next poll', async () => {
const row = await db('received_emails').where({ message_id: OVERSIZED_MSGID }).first();
expect(row).toBeTruthy();
expect(row.status).toBe('error');
expect(String(row.error)).toMatch(/too large/i);
// The whole point: keyed by messageId, NOT err-<uid>-<timestamp>, which
// could never match the dedup pass and so looped forever.
expect(row.message_id).not.toMatch(/^err-/);
});
});
@@ -0,0 +1,229 @@
/**
* Deal-lineage ownership on project attach (GHSA-wrg5, codex round 3).
*
* requireProjectOwnership vets only the DESTINATION project. Attaching a quote
* cascades through linkDealToProject, which re-points every event the deal
* produced into that project — so an editor could create an empty project of
* their own, attach another admin's quote, and pull that admin's events (and
* the invoices, emails and gallery that roll up with them) into a project they
* own and can read via /:id/overview. An unassigned project offered no
* resistance either: it ADOPTS the deal's customer rather than rejecting it.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-deallineage-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'deallineage-test-secret';
const bcrypt = require('bcrypt');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
describe('linkDealToProject enforces lineage ownership (GHSA-wrg5, round 3)', () => {
let db; let cleanup; let projectService;
let editorA; let editorB; let superAdmin;
let customerId;
const mkAdmin = async (username, roleName) => {
const role = await db('roles').where({ name: roleName }).first();
const r = await db('admin_users').insert({
username, email: `${username}@example.com`,
password_hash: await bcrypt.hash('Passw0rd!', 4),
role_id: role.id, is_active: 1,
created_at: new Date(), updated_at: new Date(),
}).returning('id');
return r[0]?.id ?? r[0];
};
const mkProject = async (name, createdBy) => {
const r = await db('projects').insert({
name, status: 'active', created_by: createdBy,
created_at: new Date(), updated_at: new Date(),
}).returning('id');
return r[0]?.id ?? r[0];
};
const mkEvent = async (slug, createdBy) => {
const r = await db('events').insert({
slug, event_type: 'wedding', event_name: slug, event_date: '2026-08-01',
host_email: 'h@e.com', admin_email: 'a@e.com', password_hash: 'x',
share_token: `t-${slug}`, share_link: `/g/${slug}/t-${slug}`,
created_by: createdBy,
expires_at: new Date(Date.now() + 864e5).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
return r[0]?.id ?? r[0];
};
const mkQuote = async (dealUuid, convertedEventId) => {
const r = await db('quotes').insert({
quote_number: `Q-${dealUuid}`,
customer_account_id: customerId,
deal_uuid: dealUuid,
converted_event_id: convertedEventId,
status: 'accepted',
currency: 'EUR',
issue_date: '2026-08-01',
total_amount_minor: 1000,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}).returning('id');
return r[0]?.id ?? r[0];
};
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
projectService = require('../../src/services/projectService');
editorA = await mkAdmin('deal-a', 'editor');
editorB = await mkAdmin('deal-b', 'editor');
superAdmin = await mkAdmin('deal-root', 'super_admin');
const c = await db('customer_accounts').first('id');
customerId = c.id;
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it("refuses to move another admin's event into the caller's project", async () => {
const victimEvent = await mkEvent('victim-gala', editorB);
const quoteId = await mkQuote('deal-foreign', victimEvent);
const attackerProject = await mkProject('attacker-empty', editorA);
await expect(
projectService.assignQuote(attackerProject, quoteId, { id: editorA, roleName: 'editor' }),
).rejects.toMatchObject({ code: 'DEAL_EVENT_FORBIDDEN' });
// Nothing may be half-applied: neither the event nor the quote moved.
const ev = await db('events').where({ id: victimEvent }).first('project_id');
expect(ev.project_id == null).toBe(true);
const q = await db('quotes').where({ id: quoteId }).first('project_id');
expect(q.project_id == null).toBe(true);
});
it("allows the caller's own event through the same path", async () => {
const ownEvent = await mkEvent('own-gala', editorA);
const quoteId = await mkQuote('deal-own', ownEvent);
const project = await mkProject('attacker-own', editorA);
await projectService.assignQuote(project, quoteId, { id: editorA, roleName: 'editor' });
const ev = await db('events').where({ id: ownEvent }).first('project_id');
expect(Number(ev.project_id)).toBe(Number(project));
});
it('leaves super_admin unrestricted', async () => {
const victimEvent = await mkEvent('root-gala', editorB);
const quoteId = await mkQuote('deal-root', victimEvent);
const project = await mkProject('root-project', superAdmin);
await projectService.assignQuote(project, quoteId, { id: superAdmin, roleName: 'super_admin' });
const ev = await db('events').where({ id: victimEvent }).first('project_id');
expect(Number(ev.project_id)).toBe(Number(project));
});
it('resolves the role from a bare admin id (quote/contract create+update paths)', async () => {
// Those services thread `adminId`, not req.admin — the lookup must still
// scope them, and must fail closed rather than assume super_admin.
const victimEvent = await mkEvent('bare-gala', editorB);
const quoteId = await mkQuote('deal-bare', victimEvent);
const project = await mkProject('bare-project', editorA);
await expect(
projectService.assignQuote(project, quoteId, { id: editorA }),
).rejects.toMatchObject({ code: 'DEAL_EVENT_FORBIDDEN' });
});
// The lineage guard above only fires once a deal has produced an event. The
// quote/contract create+update paths call linkDealToProject with a
// body-supplied projectId and NO route-level ownership guard, so a brand-new
// deal (eventIds empty) skipped every check and wrote into a foreign project.
describe('destination ownership (codex review follow-up)', () => {
it('refuses a foreign project even when the deal has no events yet', async () => {
const victimProject = await mkProject('victim-destination', editorB);
const quoteId = await mkQuote('deal-no-events', null);
await expect(
projectService.linkDealToProject('deal-no-events', victimProject, db, { id: editorA }),
).rejects.toMatchObject({ code: 'PROJECT_NOT_FOUND' });
const q = await db('quotes').where({ id: quoteId }).first('project_id');
expect(q.project_id == null).toBe(true);
});
it('refuses an OWNERLESS project with no events (the escalation path)', async () => {
// created_by NULL + no linked events is exactly the shape that would let
// the caller claim the project via ownedProjectsSubquery's second branch
// once their quote converts to an event.
const orphan = await mkProject('orphan-destination', null);
await mkQuote('deal-orphan', null);
await expect(
projectService.linkDealToProject('deal-orphan', orphan, db, { id: editorA }),
).rejects.toMatchObject({ code: 'PROJECT_NOT_FOUND' });
});
it("still allows the caller's own project with no events", async () => {
const own = await mkProject('own-destination', editorA);
const quoteId = await mkQuote('deal-own-dest', null);
await projectService.linkDealToProject('deal-own-dest', own, db, { id: editorA });
const q = await db('quotes').where({ id: quoteId }).first('project_id');
expect(Number(q.project_id)).toBe(Number(own));
});
it('refuses a foreign project when the deal_uuid is NULL (codex round 1)', async () => {
// deal_uuid is nullable (migration 107) and quoteService.update passes the
// EXISTING row's value, so a legacy quote reaches linkDealToProject with
// null. The old `if (!dealUuid || !projectId) return` bailed before the
// guard — while the caller had already written project_id onto its row.
const victimProject = await mkProject('victim-nulldeal', editorB);
await expect(
projectService.linkDealToProject(null, victimProject, db, { id: editorA }),
).rejects.toMatchObject({ code: 'PROJECT_NOT_FOUND' });
});
it('still no-ops on a NULL deal_uuid pointed at the caller-s own project', async () => {
// The destination is vetted, then it returns without cascading — there is
// no lineage to move.
const own = await mkProject('own-nulldeal', editorA);
await expect(
projectService.linkDealToProject(null, own, db, { id: editorA }),
).resolves.toBeUndefined();
});
it('does not leak customer association through the error code', async () => {
// The customer check used to run first, so a foreign project whose
// customer differed answered 422 PROJECT_CUSTOMER_MISMATCH while an
// unknown id answered 404 — enough to enumerate projects and infer their
// customer. Both must now be indistinguishable to a scoped caller.
const foreignWithCustomer = await mkProject('victim-customer', editorB);
await db('projects').where({ id: foreignWithCustomer }).update({ customer_account_id: customerId });
await mkQuote('deal-oracle', null);
await expect(
projectService.linkDealToProject('deal-oracle', foreignWithCustomer, db, { id: editorA }),
).rejects.toMatchObject({ code: 'PROJECT_NOT_FOUND' });
await expect(
projectService.linkDealToProject('deal-oracle', 999999, db, { id: editorA }),
).rejects.toMatchObject({ code: 'PROJECT_NOT_FOUND' });
});
it('leaves super_admin unrestricted on a foreign destination', async () => {
const victimProject = await mkProject('root-destination', editorB);
const quoteId = await mkQuote('deal-root-dest', null);
await projectService.linkDealToProject('deal-root-dest', victimProject, db, {
id: superAdmin, roleName: 'super_admin',
});
const q = await db('quotes').where({ id: quoteId }).first('project_id');
expect(Number(q.project_id)).toBe(Number(victimProject));
});
});
});
@@ -0,0 +1,149 @@
/**
* getProjectOverview stamps each email with `canAct` whether the queued-mail
* routes (requireOwnedQueuedEmail) would actually accept an action on it.
*
* The cockpit used to derive this client-side from `event_id != null`, which is
* weaker than the backend rule in a way that still produced dead controls:
* requireOwnedQueuedEmail ALSO requires ownership of that event, while
* getProjectOverview lists the project's events by project_id alone. Project
* ownership does not imply event ownership ownedProjectsSubquery's
* `projects.created_by = admin.id` branch places no constraint on the linked
* events' owners, so a super_admin can attach admin B's event to admin A's
* project. See #969 / codex review round 1.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-canact-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'canact-test-secret';
const bcrypt = require('bcrypt');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
describe('getProjectOverview email canAct (#969)', () => {
let db; let cleanup; let projectService;
let adminA; let adminB; let superAdmin;
let projectId; let ownEventId; let foreignEventId; let ownerlessEventId;
const mkAdmin = async (username, roleName) => {
const role = await db('roles').where({ name: roleName }).first();
const r = await db('admin_users').insert({
username, email: `${username}@example.com`,
password_hash: await bcrypt.hash('Passw0rd!', 4),
role_id: role.id, is_active: 1,
created_at: new Date().toISOString(), updated_at: new Date().toISOString(),
}).returning('id');
return r[0]?.id ?? r[0];
};
const mkEvent = async (slug, createdBy, project) => {
const r = await db('events').insert({
slug, event_type: 'wedding', event_name: slug, event_date: '2026-08-01',
host_email: 'h@e.com', admin_email: 'a@e.com', password_hash: 'x',
share_token: `t-${slug}`, share_link: `/g/${slug}/t-${slug}`,
created_by: createdBy, project_id: project,
expires_at: new Date(Date.now() + 864e5).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
return r[0]?.id ?? r[0];
};
const mkMail = async (eventId, type) => {
const r = await db('email_queue').insert({
recipient_email: 'kunde@example.com', email_type: type, status: 'sent',
event_id: eventId,
created_at: new Date().toISOString(), sent_at: new Date().toISOString(),
}).returning('id');
return r[0]?.id ?? r[0];
};
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
projectService = require('../../src/services/projectService');
adminA = await mkAdmin('canact-a', 'editor');
adminB = await mkAdmin('canact-b', 'editor');
superAdmin = await mkAdmin('canact-root', 'super_admin');
const p = await db('projects').insert({
name: 'Cockpit canAct', status: 'active', created_by: adminA,
created_at: new Date().toISOString(), updated_at: new Date().toISOString(),
}).returning('id');
projectId = p[0]?.id ?? p[0];
// All three hang off adminA's project. Only the first is adminA's; the
// third is an ownerless legacy row, which filterOwnedEventIds treats as
// owned by whoever asks — but only once we know who is asking.
ownEventId = await mkEvent('canact-own', adminA, projectId);
foreignEventId = await mkEvent('canact-foreign', adminB, projectId);
ownerlessEventId = await mkEvent('canact-legacy', null, projectId);
await mkMail(ownEventId, 'gallery_ready');
await mkMail(foreignEventId, 'gallery_ready');
await mkMail(ownerlessEventId, 'gallery_ready');
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
const byEvent = (overview) => {
const m = new Map();
for (const e of overview.emails) m.set(e.eventId, e);
return m;
};
it('clears mail on an event the caller owns', async () => {
const overview = await projectService.getProjectOverview(
projectId, {}, { id: adminA, roleName: 'editor' },
);
expect(byEvent(overview).get(ownEventId).canAct).toBe(true);
});
it('denies mail on a foreign admin\'s event inside the caller\'s own project', async () => {
const overview = await projectService.getProjectOverview(
projectId, {}, { id: adminA, roleName: 'editor' },
);
// event_id is non-null here — the old client-side rule would have offered
// controls, and requireOwnedQueuedEmail would have 404'd them.
const row = byEvent(overview).get(foreignEventId);
expect(row.eventId).not.toBeNull();
expect(row.canAct).toBe(false);
});
it('clears everything for a super_admin', async () => {
const overview = await projectService.getProjectOverview(
projectId, {}, { id: superAdmin, roleName: 'super_admin' },
);
expect(overview.emails.every((e) => e.canAct === true)).toBe(true);
});
it('clears mail on an ownerless legacy event for an identified caller', async () => {
// Parity with filterOwnedEventIds, which allows created_by IS NULL.
const overview = await projectService.getProjectOverview(
projectId, {}, { id: adminA, roleName: 'editor' },
);
expect(byEvent(overview).get(ownerlessEventId).canAct).toBe(true);
});
it('denies everything when no admin context is supplied', async () => {
// Including the ownerless event: `created_by == null` must not read as
// "owned" when we do not know who is asking (codex review round 2).
const overview = await projectService.getProjectOverview(projectId, {});
expect(overview.emails.length).toBe(3);
expect(overview.emails.every((e) => e.canAct === false)).toBe(true);
});
it('does not leak event ownership to the client', async () => {
const overview = await projectService.getProjectOverview(
projectId, {}, { id: adminA, roleName: 'editor' },
);
expect(overview.events.length).toBe(3);
for (const e of overview.events) expect(e).not.toHaveProperty('created_by');
});
});
@@ -0,0 +1,91 @@
/**
* Brand-token substitution must not reintroduce markup after sanitization
* (GHSA-j347).
*
* buildCachedPayload sanitizes the operator's HTML and THEN calls
* applyBrandTokens on the result, which did a plain `String.replace` with no
* escaping. The default templates interpolate tokens into text and into quoted
* attributes (`<img src="{{brand_logo_url}}" alt="{{company_name}} logo">`,
* `href="mailto:{{support_email}}"`), so a token value could close the
* attribute and inject markup into the public origin.
*
* The writer is settings.edit (super_admin only) and the CSP blocks inline
* script, so this is defence-in-depth rather than a live RCE but the
* sanitize-then-substitute ordering is a real bug either way.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-brandtok-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'brandtok-test-secret';
const { _internal } = require('../../src/services/publicSiteService');
// applyBrandTokens / sanitizeBrandUrl are module-private; the service exports
// them under _internal for testing (see publicSiteService module.exports).
const { applyBrandTokens, sanitizeBrandUrl } = _internal || {};
const maybe = applyBrandTokens ? describe : describe.skip;
maybe('applyBrandTokens escaping (GHSA-j347)', () => {
it('escapes markup in a text-position token', () => {
const out = applyBrandTokens('<p>{{company_name}}</p>', {
companyName: '<script>alert(1)</script>',
});
expect(out).not.toContain('<script>');
expect(out).toContain('&lt;script&gt;');
});
it('escapes a quote that would break out of an attribute', () => {
const out = applyBrandTokens(
'<img src="/x.png" alt="{{company_name}} logo">',
{ companyName: '" onerror="alert(1)' },
);
// The injected quotes must be entity-encoded, so the payload stays INSIDE
// the alt value as text instead of terminating it and forming a real
// onerror attribute. (`onerror=` still appears as literal characters —
// that is inert; what matters is that no raw `"` closed the attribute.)
expect(out).not.toContain('" onerror="');
expect(out).toContain('&quot; onerror=&quot;');
});
it('escapes the logo url token used inside src="..."', () => {
const out = applyBrandTokens('<img src="{{brand_logo_url}}">', {
logoUrl: '" onerror="alert(1)',
});
expect(out).not.toContain('" onerror="');
expect(out).toContain('&quot;');
});
it('leaves ordinary values readable', () => {
const out = applyBrandTokens('<p>{{company_name}}</p>', { companyName: 'Acme Photos' });
expect(out).toContain('Acme Photos');
});
});
const maybeUrl = sanitizeBrandUrl ? describe : describe.skip;
maybeUrl('sanitizeBrandUrl scheme allowlist (GHSA-j347)', () => {
it('rejects javascript: regardless of case', () => {
expect(sanitizeBrandUrl('javascript:alert(1)')).toBeNull();
// The old check was a case-sensitive startsWith and missed these.
expect(sanitizeBrandUrl('JavaScript:alert(1)')).toBeNull();
expect(sanitizeBrandUrl(' JAVASCRIPT:alert(1)')).toBeNull();
});
it('rejects other non-http schemes', () => {
expect(sanitizeBrandUrl('data:text/html;base64,PHN2Zz4=')).toBeNull();
expect(sanitizeBrandUrl('vbscript:msgbox(1)')).toBeNull();
});
it('keeps http(s) and relative logo paths working', () => {
expect(sanitizeBrandUrl('https://cdn.example.com/logo.png'))
.toBe('https://cdn.example.com/logo.png');
expect(sanitizeBrandUrl('/uploads/logos/logo.png')).toBe('/uploads/logos/logo.png');
});
});
@@ -0,0 +1,96 @@
/**
* Credential redaction for log payloads (GHSA-pgmp / GHSA-r794).
*
* Event create/update logged the whole request body. Beyond the plaintext
* gallery password named in the advisories, the update path also logged
* `client_share_token` a LIVE bearer credential for client gallery access,
* freshly minted by `regenerate_client_token` and `client_password_hash`.
*/
const { sanitizeForLog, isSensitiveKey } = require('../../src/utils/sanitizeForLog');
describe('sanitizeForLog', () => {
it('redacts the credentials an event body actually carries', () => {
const out = sanitizeForLog({
event_name: 'Wedding',
password: 'FAKE-PLAINTEXT-PASSWORD',
client_password: 'FAKE-CLIENT-PASSWORD',
client_password_hash: 'FAKE-BCRYPT-HASH-PLACEHOLDER',
client_share_token: 'FAKE-CLIENT-SHARE-TOKEN',
share_token: 'FAKE-SHARE-TOKEN',
});
expect(out.event_name).toBe('Wedding');
for (const key of ['password', 'client_password', 'client_password_hash',
'client_share_token', 'share_token']) {
expect(out[key]).toBe('[redacted]');
}
expect(JSON.stringify(out)).not.toContain('FAKE-PLAINTEXT-PASSWORD');
expect(JSON.stringify(out)).not.toContain('FAKE-CLIENT-SHARE-TOKEN');
});
it('redacts nested and array-nested secrets', () => {
const out = sanitizeForLog({
smtp: { host: 'mail.example.com', smtp_password: 'p' },
users: [{ name: 'a', api_key: 'k' }],
});
expect(out.smtp.host).toBe('mail.example.com');
expect(out.smtp.smtp_password).toBe('[redacted]');
expect(out.users[0].name).toBe('a');
expect(out.users[0].api_key).toBe('[redacted]');
});
it('passes non-objects through and survives cycles', () => {
expect(sanitizeForLog('plain')).toBe('plain');
expect(sanitizeForLog(42)).toBe(42);
expect(sanitizeForLog(null)).toBeNull();
const cyclic = { name: 'x' };
cyclic.self = cyclic;
expect(() => sanitizeForLog(cyclic)).not.toThrow();
expect(sanitizeForLog(cyclic).self).toBe('[circular]');
});
it('matches key names case-insensitively and by fragment', () => {
expect(isSensitiveKey('Authorization')).toBe(true);
expect(isSensitiveKey('CLIENT_SHARE_TOKEN')).toBe(true);
expect(isSensitiveKey('event_name')).toBe(false);
});
});
/**
* Codex round 2: sanitizing req.body was not enough. express-validator's
* errors.array() embeds the SUBMITTED value per field, so a password rejected
* for being too short was still logged in plaintext.
*/
describe('sanitizeValidationErrors', () => {
const { sanitizeValidationErrors } = require('../../src/utils/sanitizeForLog');
it('redacts the submitted value for a password field', () => {
const out = sanitizeValidationErrors([
{ type: 'field', path: 'password', msg: 'too short', value: 'FAKE-PLAINTEXT-PASSWORD' },
{ type: 'field', path: 'event_name', msg: 'required', value: '' },
]);
expect(out[0].value).toBe('[redacted]');
expect(out[0].msg).toBe('too short');
expect(JSON.stringify(out)).not.toContain('FAKE-PLAINTEXT-PASSWORD');
expect(out[1].value).toBe('');
});
it('handles the legacy `param` field name', () => {
const out = sanitizeValidationErrors([{ param: 'client_password', value: 'FAKE-SECRET' }]);
expect(out[0].value).toBe('[redacted]');
});
it('recurses into object values on non-sensitive fields', () => {
const out = sanitizeValidationErrors([
{ path: 'config', value: { host: 'h', api_key: 'k' } },
]);
expect(out[0].value.host).toBe('h');
expect(out[0].value.api_key).toBe('[redacted]');
});
it('passes non-arrays through untouched', () => {
expect(sanitizeValidationErrors(undefined)).toBeUndefined();
});
});
@@ -0,0 +1,71 @@
/**
* Migration 167: give `projects` a first-class owner (GHSA-wrg5).
*
* Project routes authorize on generic `events.view` / `events.edit` only, with
* no ownership check, so an editor-like admin could enumerate, read, update
* and aggregate projects belonging to other admins' events.
*
* Ownership IS derivable transitively `events.project_id` (migration 117)
* plus `events.created_by` (migration 060) but only for projects that have
* at least one linked event. A freshly created, still-empty project has no
* derivable owner, which would leave a hole exactly where the create attach
* flow starts. Storing the creator removes that ambiguity: projectService
* already receives `adminId` in createProject() and simply discarded it.
*
* Backfill uses the transitive path, which is well-defined here: migration 117
* created exactly one auto-project per pre-existing event, so those projects
* map 1:1 to an owning event. Projects with no linked event (or whose events
* are themselves ownerless legacy rows) stay NULL and are treated as
* unowned//legacy by the ownership helper — same convention the events table
* already uses for `created_by IS NULL`.
*
* down() drops the column; the derived data is reconstructible by re-running
* the same backfill, so nothing is lost irreversibly.
*/
exports.up = async function (knex) {
if (!(await knex.schema.hasTable('projects'))) return;
if (!(await knex.schema.hasColumn('projects', 'created_by'))) {
await knex.schema.alterTable('projects', (t) => {
// No FK constraint: admin_users rows can be removed, and orphaning a
// project would be worse than a dangling id (which reads as unowned).
t.integer('created_by').nullable();
});
}
// Backfill from the linked events, only where we can determine it
// unambiguously (every owning event agrees on a single non-null creator).
if (await knex.schema.hasColumn('events', 'project_id')
&& await knex.schema.hasColumn('events', 'created_by')) {
const rows = await knex('events')
.whereNotNull('project_id')
.whereNotNull('created_by')
.select('project_id', 'created_by')
.groupBy('project_id', 'created_by');
const byProject = new Map();
for (const row of rows) {
const list = byProject.get(row.project_id) || [];
list.push(row.created_by);
byProject.set(row.project_id, list);
}
for (const [projectId, creators] of byProject) {
// Ambiguous (events from two different admins) → leave NULL rather than
// guess an owner and hand one admin authority over another's work.
if (creators.length !== 1) continue;
await knex('projects')
.where({ id: projectId })
.whereNull('created_by')
.update({ created_by: creators[0] });
}
}
};
exports.down = async function (knex) {
if (!(await knex.schema.hasTable('projects'))) return;
if (await knex.schema.hasColumn('projects', 'created_by')) {
await knex.schema.alterTable('projects', (t) => t.dropColumn('created_by'));
}
};
@@ -0,0 +1,64 @@
/**
* GHSA-jhcf data correction for legacy accounting activity rows.
*
* expenseService called `logActivity(type, metadata, adminId)`, but the third
* positional parameter of logActivity is `eventId`, not the actor. Every
* expense / incoming-invoice entry therefore stored the ACTING ADMIN'S ID in
* `activity_logs.event_id` (and no actor at all).
*
* That is not merely cosmetic. The dashboard activity feed now scopes rows via
* `WHERE activity_logs.event_id IN (SELECT id FROM events WHERE created_by = me)`.
* Admin ids and event ids are both small integers drawn from the same range, so
* on any upgraded instance an editor who happens to own the event whose id
* equals another admin's id is served that admin's accounting activity
* verbatim metadata included. Scoping new writes correctly does nothing for the
* rows already on disk, so they are corrected here.
*
* The stored value is exactly the actor id we lost, so this re-attributes
* rather than discards: event_id actor_id (when no actor was recorded), then
* event_id is cleared so the scope predicate can no longer match it.
*
* All ten activity types below are emitted by expenseService and nothing else,
* so no row with a genuine event_id is touched.
*/
const AFFECTED_TYPES = [
'incoming_invoice_captured',
'incoming_invoice_updated',
'incoming_invoice_categorized',
'incoming_invoice_rebilled',
'incoming_invoices_rebilled_bundle',
'incoming_invoice_supplier_payment',
'expense_created',
'expense_updated',
'expense_invoiced',
'expense_paid',
];
exports.up = async function up(knex) {
if (!(await knex.schema.hasTable('activity_logs'))) return;
if (!(await knex.schema.hasColumn('activity_logs', 'event_id'))) return;
const hasActorId = await knex.schema.hasColumn('activity_logs', 'actor_id');
const hasActorType = await knex.schema.hasColumn('activity_logs', 'actor_type');
if (hasActorId) {
const patch = { actor_id: knex.ref('event_id') };
if (hasActorType) patch.actor_type = 'admin';
await knex('activity_logs')
.whereIn('activity_type', AFFECTED_TYPES)
.whereNotNull('event_id')
.whereNull('actor_id')
.update(patch);
}
await knex('activity_logs')
.whereIn('activity_type', AFFECTED_TYPES)
.whereNotNull('event_id')
.update({ event_id: null });
};
// Irreversible by design: this is a data correction, and the pre-migration
// state is a cross-admin disclosure. Re-planting admin ids in event_id would
// reopen GHSA-jhcf.
exports.down = async function down() {};
+19 -19
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-backend",
"version": "3.45.10",
"version": "3.45.14",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-backend",
"version": "3.45.10",
"version": "3.45.14",
"dependencies": {
"@aws-sdk/client-s3": "^3.850.0",
"@aws-sdk/lib-storage": "^3.850.0",
@@ -46,7 +46,7 @@
"pdf-lib": "^1.17.1",
"pdfkit": "^0.17.2",
"pg": "^8.16.3",
"postcss": "8.5.18",
"postcss": "8.5.23",
"qrcode": "^1.5.4",
"react-i18next": "^15.6.0",
"sanitize-html": "2.17.5",
@@ -4499,9 +4499,9 @@
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
@@ -7104,9 +7104,9 @@
}
},
"node_modules/ip-address": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz",
"integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==",
"license": "MIT",
"engines": {
"node": ">= 12"
@@ -7981,9 +7981,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"funding": [
{
"type": "github",
@@ -9076,9 +9076,9 @@
}
},
"node_modules/nanoid": {
"version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"funding": [
{
"type": "github",
@@ -10032,9 +10032,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.18",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.18.tgz",
"integrity": "sha512-xdB1oSLHbz1vRWgCDalrCqEFTWzFlhqFC5tIHLMOSUIjhm3XXQ1qrFy8S/ESr1JYRRXqM3c1QFiMZUJdUTqyMQ==",
"version": "8.5.23",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
"integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
"funding": [
{
"type": "opencollective",
@@ -10051,7 +10051,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.12",
"nanoid": "^3.3.16",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "picpeak-backend",
"version": "3.45.12",
"version": "3.45.15",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"engines": {
@@ -55,7 +55,7 @@
"pdf-lib": "^1.17.1",
"pdfkit": "^0.17.2",
"pg": "^8.16.3",
"postcss": "8.5.18",
"postcss": "8.5.23",
"qrcode": "^1.5.4",
"react-i18next": "^15.6.0",
"sanitize-html": "2.17.5",
@@ -85,13 +85,13 @@
"fast-xml-parser": ">=5.7.0",
"qs": ">=6.15.2",
"tar": ">=7.5.21",
"brace-expansion": ">=5.0.7",
"brace-expansion": ">=5.0.9",
"minimatch": ">=9.0.7",
"path-to-regexp": "0.1.13",
"lodash": ">=4.18.1",
"follow-redirects": ">=1.16.0",
"@tootallnate/once": ">=3.0.1",
"ip-address": ">=10.1.1",
"ip-address": ">=10.3.1",
"uuid": "^11.1.1",
"nodemailer": "^9.0.1"
}
+16 -4
View File
@@ -1005,8 +1005,15 @@ async function startServer() {
// Runs AFTER install-from-backup so a restored instance (which repopulates
// admin_users) never prints a throwaway token. Best-effort — never blocks boot.
let setupToken = null;
let setupTokenFile = null;
try {
setupToken = await require('./src/services/setupService').ensureSetupToken();
const setupSvc = require('./src/services/setupService');
setupToken = await setupSvc.ensureSetupToken();
// The path the write ACTUALLY produced (null when it failed). existsSync
// on the candidate answered a different question and reported success
// for a stale, read-only or directory-shaped SETUP_TOKEN — suppressing
// the token here while pointing the operator at content that is not it.
setupTokenFile = setupSvc.writtenSetupTokenFile();
} catch (err) {
logger.warn(`[setup] ensureSetupToken skipped: ${err.message}`);
}
@@ -1026,12 +1033,17 @@ async function startServer() {
logger.info(`Server running on port ${PORT}`);
logger.info(`Admin interface: ${process.env.ADMIN_URL || 'http://localhost:3000'}`);
logger.info(`Frontend: ${process.env.FRONTEND_URL || 'http://localhost:3001'}`);
// First-run: print the one-time setup token to STDOUT (the file logger
// doesn't reach `docker logs`), as the last + most visible thing at boot.
// First-run banner. Print the TOKEN ITSELF only when the 0600 token file
// could not be written — otherwise this lands a live first-admin
// credential in `docker logs` / journald, which is the leak GHSA-r794's
// sweep turned up. When the file exists we point at it instead.
if (setupToken) {
const url = `${process.env.ADMIN_URL || 'http://localhost:3000'}/admin`;
const line = '='.repeat(64);
console.log(`\n${line}\n PicPeak first-run setup — no admin account yet.\n Open: ${url}\n One-time setup token: ${setupToken}\n (also saved to data/SETUP_TOKEN)\n${line}\n`);
const secretLine = setupTokenFile
? ` Setup token saved to: ${setupTokenFile}\n (read it there — deliberately not printed)`
: ` One-time setup token: ${setupToken}\n (could not write the token file, so it is shown here)`;
console.log(`\n${line}\n PicPeak first-run setup — no admin account yet.\n Open: ${url}\n${secretLine}\n${line}\n`);
}
});
} catch (error) {
@@ -52,7 +52,11 @@ describe('publicSiteService', () => {
const payload = await getPublicSitePayload({ bypassCache: true });
expect(payload.enabled).toBe(true);
expect(payload.html).toContain('<h1>Willow & Pine Studio</h1>');
// Brand tokens are HTML-escaped on substitution now (GHSA-j347), so a bare
// `&` in the company name is emitted as the `&amp;` entity. That renders
// identically in a browser — it is the correctly-encoded form — but the raw
// payload string differs from the pre-fix output.
expect(payload.html).toContain('<h1>Willow &amp; Pine Studio</h1>');
expect(payload.html).not.toContain('<script');
expect(payload.baseCss.length).toBeGreaterThan(0);
expect(payload.branding.companyName).toBe('Willow & Pine Studio');
+42 -5
View File
@@ -1,5 +1,7 @@
const crypto = require('crypto');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { isMissingRolesSchema } = require('../utils/dbErrors');
const logger = require('../utils/logger');
const TOKEN_PREFIX = 'pp_live_';
@@ -63,10 +65,36 @@ async function apiTokenAuth(req, res, next) {
return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
}
const admin = await db('admin_users')
.where({ id: row.created_by, is_active: true })
.select('id', 'username', 'email', 'role_id')
.first();
// Load the owner WITH their role name (GHSA-9697). Without it,
// req.admin.roleName was undefined — and every ownership check keys on
// roleName — so the v1 surface could not tell a super_admin from a
// demoted viewer. Mirrors adminAuth's shape, including the
// roles-table-missing fallback used during upgrades.
let admin;
try {
admin = await db('admin_users')
.leftJoin('roles', 'roles.id', 'admin_users.role_id')
.where({ 'admin_users.id': row.created_by, 'admin_users.is_active': formatBoolean(true) })
.select(
'admin_users.id',
'admin_users.username',
'admin_users.email',
'roles.id as role_id',
'roles.name as role_name'
)
.first();
} catch (joinError) {
// Fail CLOSED on anything that isn't a genuinely missing roles schema:
// the fallback fabricates super_admin, so a transient query failure must
// not become a free privilege upgrade. Rethrow → outer catch → 500.
if (!isMissingRolesSchema(joinError)) throw joinError;
logger.debug('Roles table not available in apiTokenAuth', { error: joinError.message });
admin = await db('admin_users')
.where({ id: row.created_by, is_active: formatBoolean(true) })
.select('id', 'username', 'email', 'role_id')
.first();
if (admin) admin.role_name = 'super_admin'; // upgrade-path parity with adminAuth
}
if (!admin) {
return res.status(401).json({ error: 'Token owner unavailable', code: 'OWNER_INACTIVE' });
}
@@ -75,7 +103,15 @@ async function apiTokenAuth(req, res, next) {
db('api_tokens').where({ id: row.id }).update({ last_used_at: new Date() })
.catch((err) => logger.debug('api_tokens last_used update failed', { err: err.message }));
req.admin = admin;
// Same shape adminAuth produces, so requirePermission / ownership helpers
// behave identically whether the caller used a session or an API token.
req.admin = {
id: admin.id,
username: admin.username,
email: admin.email,
roleId: admin.role_id,
roleName: admin.role_name
};
req.apiToken = {
id: row.id,
name: row.name,
@@ -118,6 +154,7 @@ module.exports = {
generateApiToken,
hashToken,
parseScopes,
isMissingRolesSchema,
TOKEN_PREFIX,
VALID_SCOPES
};
+9
View File
@@ -1,6 +1,7 @@
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { isMissingRolesSchema } = require('../utils/dbErrors');
const { isTokenRevoked } = require('../utils/tokenRevocation');
const logger = require('../utils/logger');
const { getAdminTokenFromRequest, getGalleryTokenFromRequest } = require('../utils/tokenUtils');
@@ -75,6 +76,14 @@ async function adminAuth(req, res, next) {
)
.first();
} catch (joinError) {
// Fail CLOSED on anything that isn't a genuinely missing roles schema:
// the fallback below fabricates super_admin, so a transient query failure
// (connection reset, deadlock, statement timeout, pool exhaustion) must
// not become a free privilege upgrade for every scoped admin. Rethrow →
// outer catch → 401, which is already how a transient DB fault in this
// try block behaves (isTokenRevoked hits the DB here). apiTokenAuth takes
// the same posture on the v1 surface, differing only in its 500.
if (!isMissingRolesSchema(joinError)) throw joinError;
// Fallback: roles table may not exist yet during upgrade
// Query without role join - user will have no role info but can still authenticate
logger.debug('Roles table not available, falling back to basic auth', { error: joinError.message });
+99 -1
View File
@@ -32,6 +32,20 @@ function requireEventOwnership(req, res, next) {
});
}
/**
* Apply the ownership predicate to a knex query over `events`, for list
* endpoints that can't use requireEventOwnership (no :id to check).
* super_admin is unrestricted; everyone else sees ownerless (legacy/system)
* events plus their own the same rule requireEventOwnership enforces
* per-row.
*/
function scopeEventsQuery(query, admin, column = 'created_by') {
if (admin?.roleName === 'super_admin') {
return query;
}
return query.where((q) => q.whereNull(column).orWhere(column, admin.id));
}
/**
* Return the subset of `eventIds` the admin may act on, mirroring
* requireEventOwnership for bulk routes that can't use it (they take an
@@ -64,4 +78,88 @@ async function filterOwnedEventIds(admin, eventIds) {
return { allowed, denied };
}
module.exports = { requireEventOwnership, filterOwnedEventIds };
/**
* Knex subquery selecting the ids of projects `admin` may act on, or `null`
* when the caller is unrestricted (GHSA-wrg5).
*
* Rules, in priority order:
* 1. A project's STORED owner is authoritative. If `projects.created_by` is
* set to a live admin, only that admin (and super_admin) may act on it.
* Earlier this union'd in "any linked event I can see", which meant one
* legacy ownerless event inside another admin's project exposed the whole
* project its other events, invoices and emails through the overview.
* 2. Only when there is NO usable stored owner (NULL, or pointing at a
* deleted admin) do we derive from linked events, and then EVERY linked
* event must be accessible: a project the old unrestricted routes filled
* with several admins' events is ambiguous, and migration 167 deliberately
* leaves those NULL. Granting on "any" would have made exactly those
* mixed projects readable by everyone.
* 3. A project with no usable owner AND no linked events (an orphan not
* creatable since createProject stamps created_by) stays super_admin-only.
* Failing closed beats failing open; a super_admin can reassign it.
*
* Returned as a subquery so callers avoid materialising an id list.
*/
function ownedProjectsSubquery(admin) {
if (admin?.roleName === 'super_admin') return null;
const linkedEvents = () => db('events').select(db.raw('1')).whereRaw('events.project_id = projects.id');
return db('projects').select('projects.id').where((w) => {
w.where('projects.created_by', admin.id)
.orWhere((noOwner) => {
noOwner
// No usable stored owner: NULL, or a creator that no longer exists
// (hard-deleted admin) — otherwise that project would be locked away
// from everyone but super_admin forever.
.where((c) => c
.whereNull('projects.created_by')
.orWhereNotIn('projects.created_by', db('admin_users').select('id')))
.whereExists(linkedEvents())
.whereNotExists(
linkedEvents().whereNotNull('events.created_by').whereNot('events.created_by', admin.id),
);
});
});
}
/**
* Materialised form of ownedProjectsSubquery, for callers that need the ids
* themselves. `null` = unrestricted.
*
* @returns {Promise<number[]|null>}
*/
async function ownedProjectIds(admin) {
const sub = ownedProjectsSubquery(admin);
if (sub === null) return null;
const rows = await sub;
return rows.map((r) => Number(r.id));
}
/**
* Middleware enforcing ownedProjectIds() on a :id project route. 404 (not 403)
* on a foreign project so the endpoint isn't an existence oracle same
* posture filterOwnedEventIds takes for foreign-vs-missing ids.
*/
function requireProjectOwnership(req, res, next) {
const sub = ownedProjectsSubquery(req.admin);
if (sub === null) return next();
const projectId = Number(req.params.id);
sub.clone()
.where('projects.id', projectId)
.first()
.then((row) => {
if (!row) return res.status(404).json({ error: 'Project not found' });
next();
})
.catch(() => res.status(500).json({ error: 'Failed to verify project ownership' }));
}
module.exports = {
requireEventOwnership,
filterOwnedEventIds,
scopeEventsQuery,
ownedProjectIds,
ownedProjectsSubquery,
requireProjectOwnership,
};
+63 -12
View File
@@ -249,33 +249,84 @@ router.get(
const brandingLogoUrl = await getAppSetting('branding_logo_url');
const resolved = await resolveLogoFile(profile);
// GHSA-29vm: report candidates RELATIVE to the storage roots rather than
// echoing absolute container paths and process.cwd(). This endpoint exists
// to answer "which candidate did/didn't exist", which relative paths answer
// just as well without handing out the filesystem layout.
const cwdStorage = path.join(process.cwd(), 'storage');
const relativise = (p) => {
for (const [name, root] of [['STORAGE', storageRoot], ['CWD_STORAGE', cwdStorage]]) {
const rel = path.relative(root, p);
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
return `<${name}>/${rel.split(path.sep).join('/')}`;
}
}
return path.basename(p);
};
const inspect = (label, raw) => {
const value = (raw || '').toString().trim();
if (!value) return { label, value: null, candidates: [] };
const stripped = value.replace(/^\/+/, '');
const baseName = path.basename(value);
// Mirrors resolveLogoFile's candidate list EXACTLY. It keeps the raw
// absolute value as a candidate (multer stores branding_logo_path
// absolute) and lets the storage-root containment filter reject it when
// it points outside — so the diagnostic must include it too, or a
// legitimately-contained absolute logo shows every candidate as missing
// while resolvedTo names the file.
// The stripped joins (`<ROOT>/<value-minus-leading-slash>`) are gated on
// containment, NOT on path.isAbsolute(). isAbsolute() cannot tell a
// multer disk path from a root-relative URL like `/custom/logo.png`, and
// for the URL form `<STORAGE>/custom/logo.png` is a file the resolver
// genuinely returns — skipping it made this endpoint report "no source
// candidate exists" about a logo that renders fine.
//
// The gate is instead: does the raw value ALREADY resolve inside a
// storage root? If so it is a real disk path, the raw candidate below
// covers it, and the stripped join would only produce a double-prefixed
// path that can never exist while re-embedding the absolute path
// GHSA-29vm exists to stop echoing (redact() strips only the leading
// root, so the inner one would survive).
const valueInsideRoot = path.isAbsolute(value) && [
path.resolve(storageRoot), path.resolve(cwdStorage),
].some((root) => {
const r = path.resolve(value);
return r === root || r.startsWith(root + path.sep);
});
const strippedJoins = valueInsideRoot
? []
: [path.join(storageRoot, stripped), path.join(cwdStorage, stripped)];
const candidates = [
path.isAbsolute(value) ? value : null,
path.join(storageRoot, stripped),
...(path.isAbsolute(value) ? [value] : []),
...strippedJoins,
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);
path.join(cwdStorage, 'uploads', 'logos', baseName),
path.join(cwdStorage, 'branding', baseName),
];
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 {
label, value,
candidates: [...new Set(candidates)].map((p) => ({
path: p,
label,
// GHSA-29vm: branding_logo_path is stored absolute by multer, so
// echoing it back handed out the filesystem layout just as the
// candidate paths did. Relativise it the same way.
value: path.isAbsolute(value) ? relativise(value) : value,
candidates: [...new Set(contained)].map((p) => ({
path: relativise(p),
exists: (() => { try { return fs.existsSync(p) && fs.statSync(p).isFile(); } catch { return false; } })(),
})),
};
};
return successResponse(res, {
storageRoot,
cwd: process.cwd(),
resolvedTo: resolved,
// Absolute storageRoot / cwd deliberately omitted (GHSA-29vm); the
// candidate paths below are shown relative to <STORAGE>/<CWD_STORAGE>.
resolvedTo: resolved ? relativise(resolved) : null,
sources: [
inspect('business_profile.logo_path', profile?.logo_path),
inspect('app_settings.branding_logo_path', brandingDiskPath),
+72 -23
View File
@@ -24,11 +24,47 @@ function normaliseDateKey(value) {
return String(value).slice(0, 10);
}
/**
* Event ids the caller's dashboard may aggregate over, or `null` when the
* caller is unrestricted (GHSA-c2jj / gqx7 / jhcf).
*
* These endpoints are gated only by `analytics.view`, which the `editor` role
* holds yet the events *list* restricts editors to their own rows
* (adminEvents/crud.js: `roleName === 'editor'` `created_by = admin.id`).
* The dashboard therefore reported instance-wide totals, and the analytics
* endpoint returned other admins' gallery names and slugs, to a role that
* cannot see those events anywhere else.
*
* Scoped on `editor` specifically to mirror the events list exactly, so the
* `admin` role's dashboard is unchanged. (`filterOwnedEventIds` uses the
* broader `!== super_admin` rule; the two conventions disagree in this
* codebase and matching the list is the no-regression choice.)
*
* @returns {Promise<number[]|null>} ids to restrict to, or null for no limit
*/
function isScopedAdmin(admin) {
return admin?.roleName === 'editor';
}
/**
* Restrict `query` to the caller's own events.
*
* Uses a SUBQUERY rather than materialising the id list. An editor owning more
* events than the driver's bind-parameter limit (~999 on SQLite, 65535 on
* Postgres) would otherwise blow past it once every id became a placeholder,
* turning all three dashboard endpoints into 500s and even well below that
* limit the whole list was re-sent for each of the ~10 aggregates per request.
*/
function applyEventScope(query, admin, column) {
if (!isScopedAdmin(admin)) return query;
return query.whereIn(column, db('events').select('id').where('created_by', admin.id));
}
// Get dashboard statistics
router.get('/stats', adminAuth, requirePermission('analytics.view'), async (req, res) => {
try {
// Get active events count
const activeEvents = await db('events')
const activeEvents = await applyEventScope(db('events'), req.admin, 'id')
.where('is_active', formatBoolean(true))
.where('is_archived', formatBoolean(false))
.count('id as count')
@@ -39,7 +75,7 @@ router.get('/stats', adminAuth, requirePermission('analytics.view'), async (req,
sevenDaysFromNow.setDate(sevenDaysFromNow.getDate() + 7);
const now = new Date();
const expiringEvents = await db('events')
const expiringEvents = await applyEventScope(db('events'), req.admin, 'id')
.where('is_active', formatBoolean(true))
.where('is_archived', formatBoolean(false))
.where('expires_at', '<=', sevenDaysFromNow.toISOString())
@@ -48,12 +84,12 @@ router.get('/stats', adminAuth, requirePermission('analytics.view'), async (req,
.first();
// Get total photos count
const totalPhotos = await db('photos')
const totalPhotos = await applyEventScope(db('photos'), req.admin, 'event_id')
.count('id as count')
.first();
// Get storage usage (sum of all photo sizes)
const storageUsed = await db('photos')
const storageUsed = await applyEventScope(db('photos'), req.admin, 'event_id')
.sum('size_bytes as total')
.first();
@@ -61,21 +97,21 @@ router.get('/stats', adminAuth, requirePermission('analytics.view'), async (req,
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const totalViews = await db('access_logs')
const totalViews = await applyEventScope(db('access_logs'), req.admin, 'event_id')
.where('action', 'view')
.where('timestamp', '>=', thirtyDaysAgo.toISOString())
.count('id as count')
.first();
// Get total downloads (last 30 days) - include both single and bulk downloads
const totalDownloads = await db('access_logs')
const totalDownloads = await applyEventScope(db('access_logs'), req.admin, 'event_id')
.whereIn('action', ['download', 'download_all', 'download_all_presigned', 'download_selected'])
.where('timestamp', '>=', thirtyDaysAgo.toISOString())
.count('id as count')
.first();
// Get archived events count
const archivedEvents = await db('events')
const archivedEvents = await applyEventScope(db('events'), req.admin, 'id')
.where('is_archived', formatBoolean(true))
.count('id as count')
.first();
@@ -83,7 +119,7 @@ router.get('/stats', adminAuth, requirePermission('analytics.view'), async (req,
// Get total events count (all events regardless of status) — used by the
// events list page to render accurate "All (N)" / Total Events counters
// when the table is server-paginated (#346).
const totalEvents = await db('events')
const totalEvents = await applyEventScope(db('events'), req.admin, 'id')
.count('id as count')
.first();
@@ -91,14 +127,14 @@ router.get('/stats', adminAuth, requirePermission('analytics.view'), async (req,
const sixtyDaysAgo = new Date();
sixtyDaysAgo.setDate(sixtyDaysAgo.getDate() - 60);
const previousViews = await db('access_logs')
const previousViews = await applyEventScope(db('access_logs'), req.admin, 'event_id')
.where('action', 'view')
.where('timestamp', '>=', sixtyDaysAgo.toISOString())
.where('timestamp', '<', thirtyDaysAgo.toISOString())
.count('id as count')
.first();
const previousDownloads = await db('access_logs')
const previousDownloads = await applyEventScope(db('access_logs'), req.admin, 'event_id')
.whereIn('action', ['download', 'download_all', 'download_all_presigned', 'download_selected'])
.where('timestamp', '>=', sixtyDaysAgo.toISOString())
.where('timestamp', '<', thirtyDaysAgo.toISOString())
@@ -136,9 +172,19 @@ router.get('/activity', adminAuth, requirePermission('analytics.view'), async (r
try {
const { limit } = getPagination(req, { limit: 10 });
const activities = await db('activity_logs')
.select('activity_logs.*', 'events.event_name')
.leftJoin('events', 'activity_logs.event_id', 'events.id')
// Scope the feed to the caller's own events (GHSA-jhcf) — it otherwise
// returned every admin's actions, including actor names and verbatim
// metadata. `activity_logs.event_id` is NULLABLE: system-level entries
// (logins, settings changes) carry no event, and those are deliberately
// EXCLUDED for a scoped caller rather than shown, since they are exactly
// the cross-admin actions this advisory is about.
const activities = await applyEventScope(
db('activity_logs')
.select('activity_logs.*', 'events.event_name')
.leftJoin('events', 'activity_logs.event_id', 'events.id'),
req.admin,
'activity_logs.event_id'
)
.orderBy('activity_logs.created_at', 'desc')
.limit(limit);
@@ -244,7 +290,7 @@ router.get('/health', adminAuth, requirePermission('settings.view'), async (req,
router.get('/analytics', adminAuth, requirePermission('analytics.view'), async (req, res) => {
try {
const days = sanitizeDays(req.query.days || 7);
// Generate date range
const dates = [];
for (let i = days - 1; i >= 0; i--) {
@@ -262,21 +308,21 @@ router.get('/analytics', adminAuth, requirePermission('analytics.view'), async (
const startDateStr = startDate.toISOString();
// Get views per day
const viewsData = await db('access_logs')
const viewsData = await applyEventScope(db('access_logs'), req.admin, 'event_id')
.select(db.raw('DATE(timestamp) as date'), db.raw('COUNT(*) as count'))
.where('action', 'view')
.where('timestamp', '>=', startDateStr)
.groupByRaw('DATE(timestamp)');
// Get downloads per day - include both single and bulk downloads
const downloadsData = await db('access_logs')
const downloadsData = await applyEventScope(db('access_logs'), req.admin, 'event_id')
.select(db.raw('DATE(timestamp) as date'), db.raw('COUNT(*) as count'))
.whereIn('action', ['download', 'download_all', 'download_all_presigned', 'download_selected'])
.where('timestamp', '>=', startDateStr)
.groupByRaw('DATE(timestamp)');
// Get unique visitors per day
const visitorsData = await db('access_logs')
const visitorsData = await applyEventScope(db('access_logs'), req.admin, 'event_id')
.select(db.raw('DATE(timestamp) as date'), db.raw('COUNT(DISTINCT ip_address) as count'))
.where('timestamp', '>=', startDateStr)
.groupByRaw('DATE(timestamp)');
@@ -303,7 +349,7 @@ router.get('/analytics', adminAuth, requirePermission('analytics.view'), async (
});
// Get top galleries by views with additional metrics
const topGalleries = await db('access_logs')
const topGalleries = await applyEventScope(db('access_logs'), req.admin, 'access_logs.event_id')
.select('events.id', 'events.event_name', 'events.slug')
.select(db.raw('COUNT(CASE WHEN action = \'view\' THEN 1 END) as views'))
.select(db.raw('COUNT(DISTINCT CASE WHEN action = \'view\' THEN ip_address END) as uniqueVisitors'))
@@ -324,7 +370,10 @@ router.get('/analytics', adminAuth, requirePermission('analytics.view'), async (
let devices = { desktop: 0, mobile: 0, tablet: 0 };
let devicesSource = 'access_logs';
const adapter = await resolveAdapter();
// The external tracker reports instance-wide device data with no way to
// filter it by event, so a scoped caller must not receive it (GHSA-gqx7).
// They fall through to the access_logs heuristic, which IS scoped.
const adapter = isScopedAdmin(req.admin) ? null : await resolveAdapter();
if (adapter) {
try {
const trackerDevices = await adapter.fetchDeviceBreakdown({
@@ -346,7 +395,7 @@ router.get('/analytics', adminAuth, requirePermission('analytics.view'), async (
// Local heuristic on access_logs user_agent. Coarse — `LIKE` doesn't
// cover every UA shape (some Android browsers, embedded webviews, etc.)
// — and counts come back as strings on Postgres, hence Number() below.
const deviceData = await db('access_logs')
const deviceData = await applyEventScope(db('access_logs'), req.admin, 'event_id')
.select(
db.raw(`
CASE
@@ -370,19 +419,19 @@ router.get('/analytics', adminAuth, requirePermission('analytics.view'), async (
}
// Calculate totals for the period (matching /stats logic)
const totalViews = await db('access_logs')
const totalViews = await applyEventScope(db('access_logs'), req.admin, 'event_id')
.where('action', 'view')
.where('timestamp', '>=', startDateStr)
.count('id as count')
.first();
const totalDownloadsCount = await db('access_logs')
const totalDownloadsCount = await applyEventScope(db('access_logs'), req.admin, 'event_id')
.whereIn('action', ['download', 'download_all', 'download_all_presigned', 'download_selected'])
.where('timestamp', '>=', startDateStr)
.count('id as count')
.first();
const totalUniqueVisitors = await db('access_logs')
const totalUniqueVisitors = await applyEventScope(db('access_logs'), req.admin, 'event_id')
.where('timestamp', '>=', startDateStr)
.countDistinct('ip_address as count')
.first();
+14 -2
View File
@@ -123,8 +123,20 @@ router.post('/backup', requirePermission('backup.create'), async (req, res) => {
trackingUrl: '/api/admin/database-backup/progress'
});
// Run backup in background
databaseBackupService.backup(req.body).catch(error => {
// Forward ONLY the real backup knobs (GHSA-jw8m). Passing req.body
// straight through let the caller set `destinationPath`, which the
// service merges over its config — so a backup.create holder (the
// `admin` role, which has neither settings.edit nor backup.restore)
// could dump the whole database into the PUBLIC /uploads static mount
// and fetch it unauthenticated, hashes and encrypted SMTP creds included.
// destinationPath is not a persistable setting; the request body was its
// only source, so dropping it here costs no legitimate behaviour.
const body = req.body || {};
const options = {};
for (const key of ['compress', 'validateIntegrity', 'includeChecksums']) {
if (body[key] !== undefined) options[key] = body[key];
}
databaseBackupService.backup(options).catch(error => {
logger.error('Manual database backup failed:', error);
});
} catch (error) {
+12 -4
View File
@@ -16,6 +16,7 @@ const path = require('path');
const { escapeLikePattern } = require('../../utils/sqlSecurity');
const { validatePasswordInContext, getBcryptRounds } = require('../../utils/passwordValidation');
const logger = require('../../utils/logger');
const { sanitizeForLog, sanitizeValidationErrors } = require('../../utils/sanitizeForLog');
const { errorResponse } = require('../../utils/routeHelpers');
const { buildShareLinkVariants } = require('../../services/shareLinkService');
const { parseBooleanInput } = require('../../utils/parsers');
@@ -126,10 +127,13 @@ module.exports = (router) => {
body('customer_account_ids.*').optional().isInt({ min: 1 })
], async (req, res) => {
try {
logger.debug('Create event request body', { body: req.body });
// Redact credentials — the body carries the gallery password (GHSA-r794).
logger.debug('Create event request body', { body: sanitizeForLog(req.body) });
const errors = validationResult(req);
if (!errors.isEmpty()) {
logger.error('Validation errors:', errors.array());
// errors.array() embeds the SUBMITTED value per field — including a
// rejected plaintext password (GHSA-r794).
logger.error('Validation errors:', sanitizeValidationErrors(errors.array()));
return res.status(400).json({ errors: errors.array() });
}
@@ -1263,7 +1267,8 @@ module.exports = (router) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
logger.debug('Update event validation errors', { errors: errors.array(), body: req.body });
// Redact credentials — an invalid update still logs the whole body (GHSA-pgmp).
logger.debug('Update event validation errors', { errors: sanitizeValidationErrors(errors.array()), body: sanitizeForLog(req.body) });
return res.status(400).json({ errors: errors.array() });
}
@@ -1441,9 +1446,12 @@ module.exports = (router) => {
}
// Log the update request for debugging
// `updates` no longer holds the plaintext password (stripped above), but
// it still carries client_password_hash and — when regenerate_client_token
// was passed — a LIVE client_share_token bearer credential.
logger.debug('Update event request', {
id,
updates,
updates: sanitizeForLog(updates),
color_theme_length: updates.color_theme ? updates.color_theme.length : 0,
color_theme_type: typeof updates.color_theme,
hero_photo_id: updates.hero_photo_id,
+59 -11
View File
@@ -15,6 +15,7 @@ const { requirePermission, userHasAnyPermission } = require('../middleware/permi
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
const projectService = require('../services/projectService');
const { db } = require('../database/db');
const { ownedProjectsSubquery, requireProjectOwnership, filterOwnedEventIds } = require('../middleware/ownership');
const { ForbiddenError } = require('../utils/errors');
const router = express.Router();
@@ -65,10 +66,15 @@ router.get('/', requirePermission('events.view'), handleAsync(async (req, res) =
bills: await userHasAnyPermission(req.admin.id, ['bills.view']),
quotes: await userHasAnyPermission(req.admin.id, ['quotes.view']),
};
// Only the caller's projects (GHSA-wrg5). Passed as a SUBQUERY so a large
// project count can't hit the driver's bind-parameter limit; null means
// unrestricted.
const projectIds = ownedProjectsSubquery(req.admin);
const projects = await projectService.listProjects({
search: req.query.q || '',
status: req.query.status || null,
perms,
projectIds,
});
return successResponse(res, { projects });
}));
@@ -88,7 +94,7 @@ router.post('/',
);
// Detail
router.get('/:id', requirePermission('events.view'), [param('id').isInt({ min: 1 })], handleAsync(async (req, res) => {
router.get('/:id', requirePermission('events.view'), requireProjectOwnership, [param('id').isInt({ min: 1 })], handleAsync(async (req, res) => {
validateRequest(req);
const project = await projectService.getProjectById(parseInt(req.params.id, 10));
if (!project) return res.status(404).json({ error: 'Project not found' });
@@ -98,6 +104,7 @@ router.get('/:id', requirePermission('events.view'), [param('id').isInt({ min: 1
// Update
router.put('/:id',
requirePermission('events.edit'),
requireProjectOwnership,
[
param('id').isInt({ min: 1 }),
body('name').optional().isString().trim().isLength({ min: 1, max: 255 }),
@@ -119,10 +126,20 @@ router.put('/:id',
// Attach an event to the project
router.post('/:id/events',
requirePermission('events.edit'),
requireProjectOwnership,
[param('id').isInt({ min: 1 }), body('eventId').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
const result = await projectService.assignEvent(parseInt(req.params.id, 10), parseInt(req.body.eventId, 10));
const eventId = parseInt(req.body.eventId, 10);
// Both sides must be the caller's (GHSA-wrg5): requireProjectOwnership
// covers the project, this covers the INCOMING event. Otherwise an editor
// could pull a foreign event into a project they own and then read that
// event's rolled-up documents through /:id/overview.
const { denied } = await filterOwnedEventIds(req.admin, [eventId]);
if (denied.length) {
return res.status(403).json({ error: 'That event is not yours to attach' });
}
const result = await projectService.assignEvent(parseInt(req.params.id, 10), eventId);
return successResponse(res, result, 200, 'Event attached to project');
}),
);
@@ -132,12 +149,13 @@ router.post('/:id/events',
// mutates a separately-permissioned document domain (GHSA-v4vw).
router.post('/:id/quotes',
requirePermission(['events.edit', 'quotes.manage'], { requireAll: true }),
requireProjectOwnership,
[param('id').isInt({ min: 1 }), body('quoteId').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
const quoteId = parseInt(req.body.quoteId, 10);
await assertCascadePermitted(req, 'quotes', quoteId, 'contracts', 'contracts.manage');
const result = await projectService.assignQuote(parseInt(req.params.id, 10), quoteId);
const result = await projectService.assignQuote(parseInt(req.params.id, 10), quoteId, req.admin);
return successResponse(res, result, 200, 'Quote attached to project');
}),
);
@@ -146,30 +164,60 @@ router.post('/:id/quotes',
// to events.edit (GHSA-v4vw).
router.post('/:id/contracts',
requirePermission(['events.edit', 'contracts.manage'], { requireAll: true }),
requireProjectOwnership,
[param('id').isInt({ min: 1 }), body('contractId').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
const contractId = parseInt(req.body.contractId, 10);
await assertCascadePermitted(req, 'contracts', contractId, 'quotes', 'quotes.manage');
const result = await projectService.assignContract(parseInt(req.params.id, 10), contractId);
const result = await projectService.assignContract(parseInt(req.params.id, 10), contractId, req.admin);
return successResponse(res, result, 200, 'Contract attached to project');
}),
);
// The cockpit aggregation — doc types gated on the admin's own permissions
router.get('/:id/overview', requirePermission('events.view'), [param('id').isInt({ min: 1 })], handleAsync(async (req, res) => {
router.get('/:id/overview', requirePermission('events.view'), requireProjectOwnership, [param('id').isInt({ min: 1 })], handleAsync(async (req, res) => {
validateRequest(req);
const perms = {
bills: await userHasAnyPermission(req.admin.id, ['bills.view']),
quotes: await userHasAnyPermission(req.admin.id, ['quotes.view']),
contracts: await userHasAnyPermission(req.admin.id, ['contracts.view']),
};
const overview = await projectService.getProjectOverview(parseInt(req.params.id, 10), perms);
const overview = await projectService.getProjectOverview(parseInt(req.params.id, 10), perms, req.admin);
return successResponse(res, overview);
}));
/**
* These routes key on an `email_queue` id alone (GHSA-93x4) nothing tied the
* row to a project or event the caller can see, so any admin holding
* `events.view` / `email.send` could preview, resend, cancel or retry ANY
* queued mail on the instance by walking ids.
*
* Scoped via `email_queue.event_id` the caller's owned events. `event_id` is
* NULL for CRM document mail (quote/contract/invoice sends carry no event), and
* those rows have no ownable parent here, so a scoped caller is denied them
* rather than guessed into access. 404, not 403, so this isn't an id oracle.
*/
async function requireOwnedQueuedEmail(req, res, next) {
try {
if (req.admin?.roleName === 'super_admin') return next();
const emailId = parseInt(req.params.emailId, 10);
const row = await db('email_queue').where({ id: emailId }).first('event_id');
if (!row || !row.event_id) {
return res.status(404).json({ error: 'Email not found' });
}
const { denied } = await filterOwnedEventIds(req.admin, [row.event_id]);
if (denied.length) {
return res.status(404).json({ error: 'Email not found' });
}
return next();
} catch (err) {
return next(err);
}
}
// Email preview — the ACTUAL sent HTML (or null for pre-rendered_html rows)
router.get('/email/:emailId/preview', requirePermission('events.view'), [param('emailId').isInt({ min: 1 })], handleAsync(async (req, res) => {
router.get('/email/:emailId/preview', requirePermission('events.view'), [param('emailId').isInt({ min: 1 })], requireOwnedQueuedEmail, handleAsync(async (req, res) => {
validateRequest(req);
const preview = await projectService.getEmailPreview(parseInt(req.params.emailId, 10));
return successResponse(res, preview);
@@ -182,9 +230,9 @@ const emailAction = (fn) => handleAsync(async (req, res) => {
const result = await projectService[fn](parseInt(req.params.emailId, 10), req.admin.id);
return successResponse(res, result);
});
router.post('/email/:emailId/resend', requirePermission('email.send'), [param('emailId').isInt({ min: 1 })], emailAction('resendEmail'));
router.post('/email/:emailId/cancel', requirePermission('email.send'), [param('emailId').isInt({ min: 1 })], emailAction('cancelEmail'));
router.post('/email/:emailId/retry', requirePermission('email.send'), [param('emailId').isInt({ min: 1 })], emailAction('retryEmail'));
router.post('/email/:emailId/send-now', requirePermission('email.send'), [param('emailId').isInt({ min: 1 })], emailAction('sendEmailNow'));
router.post('/email/:emailId/resend', requirePermission('email.send'), [param('emailId').isInt({ min: 1 })], requireOwnedQueuedEmail, emailAction('resendEmail'));
router.post('/email/:emailId/cancel', requirePermission('email.send'), [param('emailId').isInt({ min: 1 })], requireOwnedQueuedEmail, emailAction('cancelEmail'));
router.post('/email/:emailId/retry', requirePermission('email.send'), [param('emailId').isInt({ min: 1 })], requireOwnedQueuedEmail, emailAction('retryEmail'));
router.post('/email/:emailId/send-now', requirePermission('email.send'), [param('emailId').isInt({ min: 1 })], requireOwnedQueuedEmail, emailAction('sendEmailNow'));
module.exports = router;
+75
View File
@@ -91,6 +91,12 @@ router.post('/validate', requirePermission('backup.restore'), [
}
try {
// Constrain the caller-supplied paths to configured backup roots (GHSA-fw4c)
const pathError = await checkRestorePathsAllowed(req.body);
if (pathError) {
return res.status(400).json({ success: false, error: pathError });
}
// Transform S3 config from frontend format
const s3Config = transformS3Config(req.body);
@@ -165,6 +171,12 @@ router.post('/start', requirePermission('backup.restore'), [
});
}
// Constrain the caller-supplied paths to configured backup roots (GHSA-fw4c)
const pathError = await checkRestorePathsAllowed(req.body);
if (pathError) {
return res.status(400).json({ success: false, error: pathError });
}
// Check permissions for dangerous options
const settings = await getRestoreSettings();
if (req.body.force && !settings.restore_allow_force) {
@@ -759,4 +771,67 @@ async function getBackupConfig() {
return config;
}
/**
* GHSA-fw4c: `source` and `manifestPath` were validated only as "not empty"
* before being handed to the privileged restore engine, which reads them,
* parses the manifest and executes the referenced SQL against the live
* database. Constrain them to the operator-configured backup locations.
*
* The allowlist is the SAME set the restore wizard discovers from
* (`backup_destination_path` + `backup_manifest_path`), so the disaster-
* recovery flow is untouched: an operator restoring from a rescued mount
* already has to point those settings at it for the backup to be listed.
* RESTORE_ALLOWED_ROOTS (colon-separated) is an escape hatch for unusual
* layouts. S3 sources are URLs, not paths, and are validated elsewhere.
*
* @returns {Promise<string|null>} an error message, or null when acceptable
*/
// `source` is usually a SOURCE TYPE, not a path: the restore wizard posts
// 'local' | 's3' | 'upload' and restoreService.restore() branches on those
// literals before deriving an actual directory (see its comment at the
// `options.source === 'local'` branch). Treating them as paths resolved
// 'local' to <cwd>/local, failed containment, and 400'd the entire normal
// restore workflow — so type tokens are excluded from the path check.
const SOURCE_TYPE_TOKENS = ['local', 's3', 'upload'];
async function checkRestorePathsAllowed({ source, manifestPath }) {
const isS3 = (v) => typeof v === 'string' && v.startsWith('s3://');
const isTypeToken = (v) => typeof v === 'string'
&& SOURCE_TYPE_TOKENS.includes(v.trim().toLowerCase());
const candidates = [source, manifestPath]
.filter((v) => v && !isS3(v) && !isTypeToken(v));
if (candidates.length === 0) return null;
const config = await getBackupConfig();
const roots = [];
if (config.backup_destination_path) roots.push(config.backup_destination_path);
if (config.backup_manifest_path) roots.push(config.backup_manifest_path);
for (const extra of (process.env.RESTORE_ALLOWED_ROOTS || '').split(':')) {
if (extra.trim()) roots.push(extra.trim());
}
if (roots.length === 0) {
// Nothing configured to compare against — a restore can't be scoped, so
// don't pretend to enforce. Discovery would find nothing either.
return null;
}
const resolvedRoots = roots.map((r) => path.resolve(r));
for (const candidate of candidates) {
const resolved = path.resolve(candidate);
const inside = resolvedRoots.some(
(root) => resolved === root || resolved.startsWith(root + path.sep)
);
if (!inside) {
logger.warn('Refusing restore path outside the configured backup roots', {
candidate, roots,
});
return 'Backup source and manifest path must be inside a configured backup location';
}
}
return null;
}
module.exports = router;
// Exposed for tests: the source/manifestPath containment rules (GHSA-fw4c) are
// worth pinning directly, especially the source-TYPE-token carve-out.
module.exports._internal = { checkRestorePathsAllowed, SOURCE_TYPE_TOKENS };
+11
View File
@@ -726,6 +726,17 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
&& (!photo.mime_type || !photo.mime_type.startsWith('video/'))
? `/api/gallery/${req.params.slug}/preview/${photo.id}${wmQuery}`
: null,
// Slideshow source (#1015). Same preview tier, but emitted
// unconditionally: the slideshow has no `url` fallback worth
// taking (originals are projector-sized) and must never land on
// `hero_url`, which is cover-cropped to 16:9 — that made the
// "no crop" fit letterbox an already-cropped frame. The preview
// route generates lazily and redirects to the original on any
// failure, so this is safe even where no preview exists yet.
slideshow_url: photo.media_type !== 'video'
&& (!photo.mime_type || !photo.mime_type.startsWith('video/'))
? `/api/gallery/${req.params.slug}/preview/${photo.id}${wmQuery}`
: null,
secure_url_template: `/api/secure-images/${req.params.slug}/secure/${photo.id}/{{token}}`,
download_url_template: `/api/secure-images/${req.params.slug}/secure-download/${photo.id}/{{token}}`,
type: photo.type,
@@ -43,10 +43,24 @@ jest.mock('../../../database/db', () => {
};
});
// RBAC is enforced on these routes since GHSA-9697 (requirePermission), but
// this suite mocks the database, so a real permission lookup would 500. These
// tests cover route logic, not authorization — the intersection of token
// scopes and role permissions is pinned in __tests__/routes/v1EventOwnership.
jest.mock('../../../middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
userHasAnyPermission: async () => true,
userHasAllPermissions: async () => true,
}));
jest.mock('../../../middleware/apiTokenAuth', () => ({
apiTokenAuth: (req, _res, next) => {
req.apiToken = { id: 1, admin_id: 1, scopes: ['write'] };
req.admin = { id: 1, username: 'token-admin' };
// roleName matters since GHSA-9697: requireEventOwnership now guards this
// route. super_admin short-circuits it without issuing a DB query, which
// keeps this suite's sequenced dbMock chains aligned — this suite is about
// category scoping, not ownership (see v1EventOwnership.test.js for that).
req.admin = { id: 1, username: 'token-admin', roleName: 'super_admin' };
next();
},
requireApiScope: () => (_req, _res, next) => next(),
@@ -51,6 +51,16 @@ jest.mock('../../../database/db', () => {
};
});
// RBAC is enforced on these routes since GHSA-9697 (requirePermission), but
// this suite mocks the database, so a real permission lookup would 500. These
// tests cover route logic, not authorization — the intersection of token
// scopes and role permissions is pinned in __tests__/routes/v1EventOwnership.
jest.mock('../../../middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
userHasAnyPermission: async () => true,
userHasAllPermissions: async () => true,
}));
jest.mock('../../../middleware/apiTokenAuth', () => ({
apiTokenAuth: (req, _res, next) => {
req.apiToken = { id: 1, admin_id: 1, scopes: ['admin'] };
+24 -6
View File
@@ -20,6 +20,15 @@ const sharp = require('sharp');
const { body, query, validationResult } = require('express-validator');
const { db, logActivity } = require('../../database/db');
const { apiTokenAuth, requireApiScope } = require('../../middleware/apiTokenAuth');
const { requireEventOwnership, scopeEventsQuery } = require('../../middleware/ownership');
// GHSA-9697: migration 081 defines a token's effective permissions as the
// INTERSECTION of the owner's role permissions and the token's scope flags.
// requireApiScope only ever checked the scope half — so a token minted while
// its owner was super_admin kept full write access after the owner was demoted
// to viewer (userManagementService never touches api_tokens). These
// requirePermission gates supply the missing half; they key on req.admin.id,
// which apiTokenAuth populates.
const { requirePermission } = require('../../middleware/permissions');
const { buildShareLinkVariants } = require('../../services/shareLinkService');
const { generateThumbnail } = require('../../services/imageProcessor');
const logger = require('../../utils/logger');
@@ -115,6 +124,7 @@ router.post(
'/events',
apiTokenAuth,
requireApiScope('admin'),
requirePermission('events.create'),
[
body('event_name').isString().trim().notEmpty(),
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other', 'family']),
@@ -419,6 +429,7 @@ router.get(
'/events',
apiTokenAuth,
requireApiScope('read'),
requirePermission('events.view'),
[
query('page').optional().isInt({ min: 1 }).toInt(),
query('limit').optional().isInt({ min: 1, max: 100 }).toInt()
@@ -429,14 +440,19 @@ router.get(
const limit = req.query.limit || 25;
const offset = (page - 1) * limit;
// Scope to events the token owner may see (GHSA-9697). Previously this
// listed every event on the instance regardless of who owned the token.
const [events, totalRow] = await Promise.all([
db('events')
.select('id', 'slug', 'event_name', 'event_type', 'event_date', 'expires_at',
'is_active', 'is_archived', 'is_draft', 'created_at')
scopeEventsQuery(
db('events')
.select('id', 'slug', 'event_name', 'event_type', 'event_date', 'expires_at',
'is_active', 'is_archived', 'is_draft', 'created_at'),
req.admin
)
.orderBy('created_at', 'desc')
.limit(limit)
.offset(offset),
db('events').count('id as count').first()
scopeEventsQuery(db('events').count('id as count'), req.admin).first()
]);
const total = parseInt(totalRow?.count || 0, 10);
res.json({ events, pagination: { page, limit, total } });
@@ -467,7 +483,7 @@ router.get(
* 200: { description: Event details }
* 404: { description: Not found }
*/
router.get('/events/:id', apiTokenAuth, requireApiScope('read'), async (req, res) => {
router.get('/events/:id', apiTokenAuth, requireApiScope('read'), requirePermission('events.view'), requireEventOwnership, async (req, res) => {
try {
const event = await db('events').where({ id: req.params.id }).first();
if (!event) return res.status(404).json({ error: 'Event not found' });
@@ -533,6 +549,8 @@ router.post(
'/events/:id/photos',
apiTokenAuth,
requireApiScope('write'),
requirePermission('photos.upload'),
requireEventOwnership,
photoUpload.single('photo'),
async (req, res) => {
let tempPath = null;
@@ -685,7 +703,7 @@ router.post(
* share_url: { type: string, format: uri }
* 404: { description: Not found }
*/
router.get('/events/:id/share-link', apiTokenAuth, requireApiScope('read'), async (req, res) => {
router.get('/events/:id/share-link', apiTokenAuth, requireApiScope('read'), requirePermission('events.view'), requireEventOwnership, async (req, res) => {
try {
const event = await db('events').where({ id: req.params.id }).first();
if (!event) return res.status(404).json({ error: 'Event not found' });
+168 -9
View File
@@ -134,7 +134,10 @@ class BackupManifestGenerator {
}
};
// Calculate total checksum of the manifest
// Calculate total checksum of the manifest. Records WHICH algorithm was
// used so validation can tell a keyed manifest from a legacy unkeyed one
// (GHSA-hgp8).
manifest.verification.checksum_algorithm = this.getManifestKey() ? 'hmac-sha256' : 'sha256';
manifest.verification.total_checksum = this.calculateManifestChecksum(manifest);
return manifest;
@@ -227,11 +230,15 @@ class BackupManifestGenerator {
throw new Error('File count mismatch');
}
// Validate total checksum
const calculatedChecksum = this.calculateManifestChecksum(manifest);
if (manifest.verification.total_checksum !== calculatedChecksum) {
throw new Error('Manifest checksum verification failed');
// Validate total checksum (GHSA-hgp8) — delegated so every caller shares
// the same fallback rules. restoreService.performPreRestoreValidation()
// used to recompute the digest itself with the default (canonical, keyed)
// settings, which silently rejected every pre-existing backup.
const checksumResult = this.verifyManifestChecksum(manifest);
if (!checksumResult.valid) {
throw new Error(checksumResult.error || 'Manifest checksum verification failed');
}
checksumResult.warnings.forEach((w) => logger.warn(w));
logger.info('Manifest validation passed');
return true;
@@ -327,6 +334,7 @@ class BackupManifestGenerator {
// otherwise validateManifest() rejects the loaded manifest because
// generateManifest() stamped a checksum that did NOT include this
// section.
fullManifest.verification.checksum_algorithm = this.getManifestKey() ? 'hmac-sha256' : 'sha256';
fullManifest.verification.total_checksum = this.calculateManifestChecksum(fullManifest);
return fullManifest;
@@ -439,16 +447,167 @@ class BackupManifestGenerator {
}
}
calculateManifestChecksum(manifest) {
/**
* GHSA-hgp8: the plain SHA-256 below proves the manifest wasn't CORRUPTED,
* not that it is AUTHENTIC anyone who can rewrite the file can recompute
* it. Setting BACKUP_MANIFEST_KEY upgrades new manifests to a keyed HMAC,
* which matters when the backup store is a different trust domain from the
* host (S3 bucket creds != host creds).
*
* Deliberately OPT-IN and verify-if-present: the key cannot live in the
* database (the database is inside the backup), so a mandatory HMAC would
* lock an operator out of the exact disaster-recovery case this system
* exists for total host loss, fresh install, only the backup survives.
* Unkeyed manifests therefore still validate, and a keyed manifest is only
* held to the keyed check when a key is configured.
*/
getManifestKey() {
const key = process.env.BACKUP_MANIFEST_KEY;
return typeof key === 'string' && key.trim() ? key.trim() : null;
}
/**
* Single source of truth for "does this manifest's checksum verify?"
* (GHSA-hgp8). Returns a result object rather than throwing so callers can
* surface warnings without duplicating the fallback rules a duplicated
* check in restoreService recomputed the digest with the default canonical
* serializer and rejected every manifest written before that change.
*
* Rules, in order:
* - keyed manifest + no key configured cannot verify; accept with a
* loud warning (refusing would brick recovery when the key was lost with
* the host, which is exactly when a restore is needed), UNLESS
* BACKUP_MANIFEST_REQUIRE_KEYED is set.
* - unkeyed manifest + key configured possible downgrade. Accepted with
* a warning by default for backward compatibility; rejected when
* BACKUP_MANIFEST_REQUIRE_KEYED is set, which is the setting an operator
* turns on once all their backups are keyed.
* - digest mismatch retry with the legacy (pre-canonicalization)
* serialization so old backups stay restorable, then fail.
* - no checksum at all reject. Every manifest this codebase has ever
* written stamps `verification.total_checksum` (generateManifest and
* the incremental path both do), so an absent one means the manifest
* was rewritten and accepting it would let an attacker strip the
* field to skip verification entirely, walking straight past both the
* downgrade guard and BACKUP_MANIFEST_REQUIRE_KEYED.
*
* @returns {{valid: boolean, error?: string, warnings: string[]}}
*/
verifyManifestChecksum(manifest) {
const warnings = [];
if (!manifest?.verification?.total_checksum) {
return {
valid: false,
error: 'Manifest carries no checksum — refusing to treat an unverifiable manifest as authentic',
warnings,
};
}
const declaredAlgorithm = manifest.verification.checksum_algorithm || 'sha256';
const key = this.getManifestKey();
const requireKeyed = /^(1|true|yes)$/i.test(String(process.env.BACKUP_MANIFEST_REQUIRE_KEYED || ''));
if (declaredAlgorithm === 'hmac-sha256' && !key) {
if (requireKeyed) {
return {
valid: false,
error: 'Manifest is keyed but BACKUP_MANIFEST_KEY is not set (BACKUP_MANIFEST_REQUIRE_KEYED is on)',
warnings,
};
}
warnings.push(
'Manifest declares a keyed checksum but BACKUP_MANIFEST_KEY is not set — '
+ 'authenticity cannot be verified. Set the key to enable verification.'
);
return { valid: true, warnings };
}
// Downgrade guard: with a key configured, an attacker who can rewrite the
// backup store could otherwise strip checksum_algorithm, edit the manifest
// and recompute a plain SHA-256 that we would happily accept. Rejecting
// that by default would break every pre-key backup, so it is opt-in.
//
// The strict rejection must NOT be conditional on a key being configured:
// strict mode is a statement about the manifests ("all mine are keyed"),
// not about this host. Gating it on `key` made the flag fail open on
// exactly the fresh disaster-recovery host that is missing the secret.
if (declaredAlgorithm !== 'hmac-sha256') {
if (requireKeyed) {
return {
valid: false,
error: 'Manifest is not keyed but BACKUP_MANIFEST_REQUIRE_KEYED is on — refusing a possible checksum downgrade',
warnings,
};
}
if (key) {
warnings.push(
'Manifest uses an unkeyed checksum while BACKUP_MANIFEST_KEY is set — integrity verified, '
+ 'authenticity NOT established (a rewritten manifest could have downgraded the algorithm). '
+ 'Set BACKUP_MANIFEST_REQUIRE_KEYED=true once all backups are keyed.'
);
}
}
const keyedArg = declaredAlgorithm === 'hmac-sha256' ? key : false;
const expected = manifest.verification.total_checksum;
if (expected === this.calculateManifestChecksum(manifest, { keyed: keyedArg })) {
return { valid: true, warnings };
}
// Pre-canonicalization manifests hashed a serialization that omitted
// nested fields; accept those so existing backups stay restorable.
if (expected === this.calculateManifestChecksum(manifest, { keyed: keyedArg, legacy: true })) {
warnings.push(
'Manifest uses the legacy checksum serialization, which did not cover the file list — '
+ 'integrity of file paths/sizes is unverified. Re-run a backup to upgrade it.'
);
return { valid: true, warnings };
}
return { valid: false, error: 'Manifest checksum verification failed', warnings };
}
/**
* Canonical JSON: object keys sorted recursively so the digest is stable
* regardless of property insertion order, and critically so NESTED
* values are actually covered.
*
* The previous implementation passed `Object.keys(manifest).sort()` as
* JSON.stringify's second argument. That parameter is an array *replacer*
* (a property allowlist applied at every depth), not a key sorter, so every
* nested key absent from that top-level list `path`, `size`, per-file
* `checksum` was dropped before hashing. The file list was therefore
* outside the "integrity" check entirely: a manifest path could be rewritten
* to `../../etc/passwd` without disturbing the checksum.
*/
canonicalize(value) {
if (Array.isArray(value)) return value.map((v) => this.canonicalize(v));
if (value && typeof value === 'object') {
return Object.keys(value).sort().reduce((acc, k) => {
acc[k] = this.canonicalize(value[k]);
return acc;
}, {});
}
return value;
}
calculateManifestChecksum(manifest, { keyed = null, legacy = false } = {}) {
// Create a copy without the checksum field
const manifestCopy = JSON.parse(JSON.stringify(manifest));
if (manifestCopy.verification) {
delete manifestCopy.verification.total_checksum;
delete manifestCopy.verification.checksum_algorithm;
}
// Calculate SHA256 of the sorted JSON
const content = JSON.stringify(manifestCopy, Object.keys(manifestCopy).sort());
return crypto.createHash('sha256').update(content).digest('hex');
// `legacy` reproduces the old (under-covering) serialization so manifests
// written by earlier versions still validate — see validateManifest.
const content = legacy
? JSON.stringify(manifestCopy, Object.keys(manifestCopy).sort())
: JSON.stringify(this.canonicalize(manifestCopy));
const key = keyed === null ? this.getManifestKey() : keyed;
return key
? crypto.createHmac('sha256', key).update(content).digest('hex')
: crypto.createHash('sha256').update(content).digest('hex');
}
/**
+2 -2
View File
@@ -205,7 +205,7 @@ async function createContract(payload, adminId) {
}
const inserted = await trx('contracts').insert(row).returning('id');
if (row.project_id && row.deal_uuid) {
await require('../projectService').linkDealToProject(row.deal_uuid, row.project_id, trx);
await require('../projectService').linkDealToProject(row.deal_uuid, row.project_id, trx, { id: adminId });
}
const contractId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
@@ -302,7 +302,7 @@ async function updateContract(id, payload, adminId) {
// Cascade across the deal lineage (linked quote / event / invoices).
if (updates.project_id) {
const dealRow = await trx('contracts').where({ id }).select('deal_uuid').first();
await require('../projectService').linkDealToProject(dealRow && dealRow.deal_uuid, updates.project_id, trx);
await require('../projectService').linkDealToProject(dealRow && dealRow.deal_uuid, updates.project_id, trx, { id: adminId });
}
// Replace inclusions only when the caller sent an explicit list.
+76 -4
View File
@@ -20,6 +20,31 @@ const sanitizeHtml = require('sanitize-html');
const { isUniqueViolation } = require('../utils/dbErrors');
const ALLOWED_MIME = ['application/pdf', 'image/jpeg', 'image/png'];
// Resource caps for inbound mail (GHSA-2qf9). Anyone who can email the
// operator's mailbox reaches this code path unauthenticated, and nothing here
// used to bound message size, attachment count or attachment bytes. Defaults
// are generous for real supplier invoices; all three are env-overridable.
const numFromEnv = (name, fallback) => {
const n = Number(process.env[name]);
return Number.isFinite(n) && n > 0 ? n : fallback;
};
const MAX_MESSAGE_BYTES = numFromEnv('EMAIL_INTAKE_MAX_MESSAGE_BYTES', 25 * 1024 * 1024);
// received_emails.message_id is varchar(512) WITH a UNIQUE constraint. A sender
// can legally emit a Message-ID longer than that; the insert then throws, the
// catch path stores a synthetic err-<uid>-<now> key that can never match the
// dedup pass, and every poll re-downloads and re-parses the same message
// forever. Collapse anything overlong to a stable hash so the key always fits
// and always reproduces (GHSA-2qf9).
const MESSAGE_ID_MAX = 512;
const boundedMessageId = (raw, fallback) => {
const value = String(raw || fallback || '').trim() || String(fallback || '');
if (value.length <= MESSAGE_ID_MAX) return value;
return `sha256:${require('crypto').createHash('sha256').update(value).digest('hex')}`;
};
const MAX_ATTACHMENTS = numFromEnv('EMAIL_INTAKE_MAX_ATTACHMENTS', 25);
const MAX_ATTACHMENT_BYTES = numFromEnv('EMAIL_INTAKE_MAX_ATTACHMENT_BYTES', 25 * 1024 * 1024);
let polling = false;
// Fail fast instead of hanging on a wrong host/port (e.g. IMAP pointed at an
@@ -280,8 +305,17 @@ async function pollAccountOnce(cfg, { accountKey = 'accounting', routeToExpenses
const candidates = [];
if (uids.length) {
// eslint-disable-next-line no-restricted-syntax
for await (const m of client.fetch(uids, { uid: true, envelope: true }, { uid: true })) {
candidates.push({ uid: m.uid, messageId: (m.envelope && m.envelope.messageId) || `uid-${cfg.folder}-${m.uid}` });
// `size` rides along in the same cheap envelope pass, so an oversized
// message can be rejected BEFORE its source is downloaded (GHSA-2qf9).
for await (const m of client.fetch(uids, { uid: true, envelope: true, size: true }, { uid: true })) {
candidates.push({
uid: m.uid,
size: Number(m.size) || 0,
messageId: boundedMessageId(
m.envelope && m.envelope.messageId,
`uid-${cfg.folder}-${m.uid}`,
),
});
}
}
@@ -300,10 +334,30 @@ async function pollAccountOnce(cfg, { accountKey = 'accounting', routeToExpenses
let claimKey = null;
let claimed = false;
try {
// Refuse oversized messages before download (GHSA-2qf9). Recorded
// under the REAL message id — not a synthetic err-<uid>-<now> key —
// so the step-3 dedup skips it on the next poll. Without that, the
// same huge message was re-downloaded every poll interval forever,
// and an OOM-kill/restart simply resumed the loop.
if (MAX_MESSAGE_BYTES > 0 && cand.size > MAX_MESSAGE_BYTES) {
logger.warn?.(`emailIntake: skipping uid ${cand.uid}${cand.size} bytes exceeds the ${MAX_MESSAGE_BYTES}-byte limit`);
await db('received_emails').insert({
message_id: cand.messageId,
account_key: accountKey,
status: 'error',
error: `Message too large (${cand.size} bytes); limit is ${MAX_MESSAGE_BYTES}`,
attachment_count: 0,
received_at: new Date(),
created_at: new Date(),
});
await client.messageFlagsAdd(cand.uid, ['\\Seen'], { uid: true });
continue;
}
const one = await client.fetchOne(String(cand.uid), { source: true }, { uid: true });
if (!one || !one.source) continue;
const parsed = await simpleParser(one.source);
messageId = parsed.messageId || cand.messageId;
messageId = boundedMessageId(parsed.messageId, cand.messageId);
// Claim key: a no-Message-ID mail still needs a non-null, per-message
// key so two pollers converge — fall back to the mailbox uid.
claimKey = messageId || `nomsgid-${cand.uid}`;
@@ -347,7 +401,25 @@ async function pollAccountOnce(cfg, { accountKey = 'accounting', routeToExpenses
let count = 0;
const attErrors = [];
if (routeToExpenses) {
const atts = (parsed.attachments || []).filter((a) => ALLOWED_MIME.includes(a.contentType));
const allowed = (parsed.attachments || []).filter((a) => ALLOWED_MIME.includes(a.contentType));
// Cap attachment count AND cumulative bytes (GHSA-2qf9) — a single
// in-limit message can still carry hundreds of attachments, each
// written to disk by saveAttachment().
const atts = [];
let attBytes = 0;
for (const att of allowed) {
if (atts.length >= MAX_ATTACHMENTS) {
attErrors.push(`Attachment limit reached (${MAX_ATTACHMENTS}); remaining attachments skipped`);
break;
}
const size = att.content ? att.content.length : 0;
if (attBytes + size > MAX_ATTACHMENT_BYTES) {
attErrors.push(`Cumulative attachment size limit reached (${MAX_ATTACHMENT_BYTES} bytes); remaining attachments skipped`);
break;
}
attBytes += size;
atts.push(att);
}
for (const att of atts) {
try {
const filePath = await saveAttachment(att);
+20 -11
View File
@@ -22,6 +22,15 @@ const { AppError } = require('../utils/errors');
const logger = require('../utils/logger');
const invoiceService = require('./invoiceService');
/**
* Actor for logActivity. `adminId` is legitimately absent on automated paths
* emailIntakeService calls recordInboundDocument() with none and an
* unconditional `{ type: 'admin' }` would store actor_type='admin' with a null
* id, mislabelling mailbox captures as somebody's deliberate action. Returning
* null restores logActivity's 'system' attribution for those.
*/
const adminActor = (adminId) => (adminId ? { type: 'admin', id: adminId } : null);
const DISPOSITIONS = ['rebill', 'durchlaufend', 'eigener_aufwand', 'duplikat', 'abgelehnt'];
const TAX_TREATMENTS = ['domestic', 'reverse_charge_service', 'foreign_vat_non_reclaimable', 'import_goods'];
const MARKUP_TYPES = ['none', 'percent', 'flat'];
@@ -193,7 +202,7 @@ async function recordInboundDocument({ source, filePath, originalFilename, mimeT
};
const inserted = await db('inbound_documents').insert(row).returning('id');
const id = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
await logActivity('incoming_invoice_captured', { inboundDocumentId: id, source: row.source, duplicate: !!duplicateOfId }, adminId);
await logActivity('incoming_invoice_captured', { inboundDocumentId: id, source: row.source, duplicate: !!duplicateOfId }, null, adminActor(adminId));
return getInbound(id);
}
@@ -242,7 +251,7 @@ async function updateInbound(id, payload, adminId) {
if (payload[camel] !== undefined) patch[snake] = payload[camel] === '' ? null : payload[camel];
}
await db('inbound_documents').where({ id }).update(patch);
await logActivity('incoming_invoice_updated', { inboundDocumentId: id }, adminId);
await logActivity('incoming_invoice_updated', { inboundDocumentId: id }, null, adminActor(adminId));
return getInbound(id);
}
@@ -458,8 +467,8 @@ async function categorizeInbound(id, payload, adminId) {
});
// Audit logging AFTER commit — logActivity writes via the global db and would
// deadlock if run inside the transaction above on a SQLite-backed install.
await logActivity('incoming_invoice_categorized', { inboundDocumentId: id, disposition }, adminId);
if (billedInvoiceId) await logActivity('incoming_invoice_rebilled', { inboundDocumentId: id, invoiceId: billedInvoiceId }, adminId);
await logActivity('incoming_invoice_categorized', { inboundDocumentId: id, disposition }, null, adminActor(adminId));
if (billedInvoiceId) await logActivity('incoming_invoice_rebilled', { inboundDocumentId: id, invoiceId: billedInvoiceId }, null, adminActor(adminId));
return getInbound(id);
}
@@ -494,7 +503,7 @@ async function rebillInbound(id, payload, adminId, trx0) {
const invoiceId = trx0 ? await run(trx0) : await db.transaction(run);
// Log after commit (global-db write — see billInboundNow). When a caller
// supplied trx0, that outer transaction owns the audit log instead.
if (!trx0) await logActivity('incoming_invoice_rebilled', { inboundDocumentId: id, invoiceId }, adminId);
if (!trx0) await logActivity('incoming_invoice_rebilled', { inboundDocumentId: id, invoiceId }, null, adminActor(adminId));
return { document: await getInbound(id), invoiceId };
}
@@ -607,7 +616,7 @@ async function billPendingRebills(customerId, adminId) {
return { invoiceId, count: pending.length };
});
// Audit log after commit (global-db write — see billInboundNow).
await logActivity('incoming_invoices_rebilled_bundle', { customerId: customer.id, invoiceId: result.invoiceId, count: result.count }, adminId);
await logActivity('incoming_invoices_rebilled_bundle', { customerId: customer.id, invoiceId: result.invoiceId, count: result.count }, null, adminActor(adminId));
return result;
}
@@ -624,7 +633,7 @@ async function markInboundSupplierPayment(id, { paid, paidAt, paymentMethod, pay
supplier_payment_ref: paid ? (paymentReference || null) : null,
updated_at: new Date(),
});
await logActivity('incoming_invoice_supplier_payment', { inboundDocumentId: id, paid: !!paid }, adminId);
await logActivity('incoming_invoice_supplier_payment', { inboundDocumentId: id, paid: !!paid }, null, adminActor(adminId));
return getInbound(id);
}
@@ -714,7 +723,7 @@ async function createExpense(payload, adminId, { receiptPath } = {}) {
});
const inserted = await db('expenses').insert(row).returning('id');
const id = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
await logActivity('expense_created', { expenseId: id, kind: row.kind }, adminId);
await logActivity('expense_created', { expenseId: id, kind: row.kind }, null, adminActor(adminId));
return getExpense(id);
}
@@ -747,7 +756,7 @@ async function updateExpense(id, payload, adminId, { receiptPath } = {}) {
}
if (receiptPath) patch.receipt_path = receiptPath;
await db('expenses').where({ id }).update(patch);
await logActivity('expense_updated', { expenseId: id }, adminId);
await logActivity('expense_updated', { expenseId: id }, null, adminActor(adminId));
return getExpense(id);
}
@@ -787,7 +796,7 @@ async function rebillExpense(id, payload, adminId, trx0) {
status: 'invoiced',
updated_at: new Date(),
});
await logActivity('expense_invoiced', { expenseId: id, invoiceId }, adminId);
await logActivity('expense_invoiced', { expenseId: id, invoiceId }, null, adminActor(adminId));
return invoiceId;
};
const invoiceId = trx0 ? await run(trx0) : await db.transaction(run);
@@ -807,7 +816,7 @@ async function markExpensePaid(id, { paid, paidAt, paymentMethod, paymentReferen
payment_reference: paid ? (paymentReference || null) : null,
updated_at: new Date(),
});
await logActivity('expense_paid', { expenseId: id, paid: !!paid }, adminId);
await logActivity('expense_paid', { expenseId: id, paid: !!paid }, null, adminActor(adminId));
return getExpense(id);
}
+171 -13
View File
@@ -34,7 +34,7 @@ function transformProject(p) {
/** List projects with customer email + event count + rolled-up value.
* `perms` gates which document types feed the value (matches the cockpit):
* invoices need bills.view, quotes need quotes.view. */
async function listProjects({ search = '', status = null, perms = {} } = {}) {
async function listProjects({ search = '', status = null, perms = {}, projectIds = null } = {}) {
let q = db('projects')
.leftJoin('customer_accounts', 'customer_accounts.id', 'projects.customer_account_id')
.select(
@@ -43,6 +43,11 @@ async function listProjects({ search = '', status = null, perms = {} } = {}) {
db('events').count('* as c').whereRaw('events.project_id = projects.id').as('event_count'),
)
.orderBy('projects.updated_at', 'desc');
// Ownership allowlist (GHSA-wrg5). `null` = unrestricted; otherwise a knex
// SUBQUERY of allowed ids (a plain array also works). The subquery keeps a
// large project count off the driver's bind-parameter limit, and correctly
// yields no rows for an admin who owns nothing.
if (projectIds !== null) q = q.whereIn('projects.id', projectIds);
if (status) q = q.where('projects.status', status);
if (search) {
q = q.where(function () {
@@ -130,13 +135,21 @@ async function getProjectById(id) {
async function createProject({ name, customerAccountId = null }, adminId) {
if (!name || !String(name).trim()) throw new AppError('Project name is required', 400);
const inserted = await db('projects').insert({
const row = {
name: String(name).trim(),
customer_account_id: customerAccountId || null,
status: 'active',
created_at: new Date(),
updated_at: new Date(),
}).returning('id');
};
// Record the owner (GHSA-wrg5). adminId was already passed in and silently
// discarded, which left a brand-new empty project with no derivable owner —
// it has no linked events to infer one from yet. Guarded so an instance that
// has not run migration 167 still creates projects.
if (adminId && await hasColumnCached('projects', 'created_by')) {
row.created_by = adminId;
}
const inserted = await db('projects').insert(row).returning('id');
const id = (inserted[0] && typeof inserted[0] === 'object') ? inserted[0].id : inserted[0];
return getProjectById(id);
}
@@ -222,6 +235,26 @@ async function assignEvent(projectId, eventId) {
return { projectId, eventId };
}
/**
* Resolve `actor.roleName`, looking it up when the caller only had an admin id
* to hand (the quote/contract create+update paths thread `adminId`, not the
* full req.admin). Fails CLOSED an unresolvable role is treated as scoped,
* never as super_admin.
*/
async function isSuperAdmin(actor, conn = db) {
if (!actor) return false;
if (actor.roleName !== undefined) return actor.roleName === 'super_admin';
try {
const row = await conn('admin_users')
.leftJoin('roles', 'roles.id', 'admin_users.role_id')
.where('admin_users.id', actor.id)
.first('roles.name as role_name');
return row?.role_name === 'super_admin';
} catch (err) {
return false;
}
}
/**
* Cascade a project link across a whole deal's lineage. Given a deal_uuid, link
* every quote + contract in that deal to the project, re-point every event the
@@ -229,9 +262,67 @@ async function assignEvent(projectId, eventId) {
* adopt the deal's customer onto the project when it has none. This is what
* makes "drop a quote on an empty project" fill the cockpit with the linked
* contract, event and invoices. Idempotent; pass a trx to run inside a txn.
*
* `actor` (req.admin) enables the ownership guard below and MUST be supplied by
* any admin-facing caller route-level project ownership only vets the
* destination, while this function re-points the deal's events into it.
*/
async function linkDealToProject(dealUuid, projectId, conn = db) {
if (!dealUuid || !projectId) return;
async function linkDealToProject(dealUuid, projectId, conn = db, actor = null) {
if (!projectId) return;
// Ownership of the DESTINATION. `attachDocumentToProject` reaches here behind
// requireProjectOwnership, but the quote/contract create+update paths do not:
// adminQuotes.js / adminContracts.js take `projectId` straight from the body
// behind `quotes.manage` / `contracts.manage`, which are permissions, not
// ownership. So the destination has to be vetted here, at the one choke point
// every caller shares, rather than relying on a route guard three of the four
// callers never had.
//
// Without it a scoped admin could point a new quote at a project they do not
// own: the lineage check below is skipped when the deal has produced no event
// yet (`eventIds.size` is 0), and an unassigned project ADOPTS the deal's
// customer instead of rejecting it. That writes their document into another
// admin's cockpit, and on an OWNERLESS project (created_by IS NULL — legacy
// rows migration 167's backfill could not attribute) it escalates: once the
// quote converts to an event, that event becomes the project's only linked
// event, which is exactly the condition ownedProjectsSubquery's second branch
// grants ownership on — handing the caller read access to whatever documents
// were already attached there.
//
// Mirrors ownedProjectsSubquery (middleware/ownership.js) rather than calling
// it, because that helper binds the module-level `db` and this runs inside the
// caller's transaction.
if (actor?.id && !(await isSuperAdmin(actor, conn))) {
const owned = await conn('projects')
.where({ id: projectId })
.where((w) => {
w.where('created_by', actor.id)
.orWhere((noOwner) => {
noOwner
.where((c) => c
.whereNull('created_by')
.orWhereNotIn('created_by', conn('admin_users').select('id')))
.whereExists(
conn('events').select(conn.raw('1')).whereRaw('events.project_id = projects.id'),
)
.whereNotExists(
conn('events').select(conn.raw('1')).whereRaw('events.project_id = projects.id')
.whereNotNull('events.created_by').whereNot('events.created_by', actor.id),
);
});
})
.first('id');
if (!owned) {
throw new AppError('Project not found', 404, 'PROJECT_NOT_FOUND');
}
}
// Nothing to cascade without a deal, but the destination above still had
// to be vetted: every caller writes `project_id` onto its own row BEFORE
// calling us, and `deal_uuid` is nullable (migration 107). A legacy quote
// with no deal would otherwise return here having bypassed the check while
// its foreign project link stood.
if (!dealUuid) return;
// Collect ALL the deal's customers across its quote/contract/invoice lineage
// AND every event it converted into — BEFORE mutating anything, so a link
@@ -273,6 +364,30 @@ async function linkDealToProject(dealUuid, projectId, conn = db) {
throw new AppError('That belongs to a different customer than this project', 422, 'PROJECT_CUSTOMER_MISMATCH');
}
// Ownership of the LINEAGE, not just the destination (GHSA-wrg5). The writes
// below re-point every event this deal produced into it. Without this check an
// editor could create an empty project, attach another admin's quote, and
// pull that admin's events — plus the invoices, emails and gallery that roll
// up with them — into a project they own and can read via /:id/overview.
// An unassigned project offers no resistance either, since it ADOPTS the
// deal's customer below rather than rejecting it.
//
// Events are the only ownership signal a deal carries: quotes/contracts have
// no created_by in this schema, so a deal whose lineage produced no event
// still cannot be attributed to an admin — a pre-existing property of the CRM
// model, not something this guard can close.
if (actor?.id && eventIds.size && !(await isSuperAdmin(actor, conn))) {
const ownable = await conn('events')
.whereIn('id', Array.from(eventIds))
.andWhere((q) => q.whereNull('created_by').orWhere('created_by', actor.id))
.pluck('id');
if (ownable.length !== eventIds.size) {
throw new AppError(
'That deal includes events that are not yours to move', 403, 'DEAL_EVENT_FORBIDDEN',
);
}
}
// Cleared to write: link the deal's quotes/contracts, re-point its events so
// invoices/emails/gallery roll up automatically.
if (quotesHaveDeal && await hasColumnCached('quotes', 'project_id')) {
@@ -294,7 +409,7 @@ async function linkDealToProject(dealUuid, projectId, conn = db) {
/** Attach (or, with projectId=null, detach) a quote/contract to a project.
* Attaching cascades the link across the deal lineage (see linkDealToProject). */
async function assignDocument(table, projectId, documentId) {
async function assignDocument(table, projectId, documentId, actor = null) {
if (!(await hasColumnCached(table, 'project_id'))) {
throw new AppError('This instance has no project_id column yet — run migrations', 409);
}
@@ -317,15 +432,21 @@ async function assignDocument(table, projectId, documentId) {
) {
throw new AppError('That belongs to a different customer than this project', 422, 'PROJECT_CUSTOMER_MISMATCH');
}
await db(table).where({ id: documentId }).update({ project_id: projectId || null });
// Cascade FIRST, then stamp this document. linkDealToProject runs the
// lineage-ownership guard and throws before it writes anything, so a refused
// attach leaves no half-applied link behind — the other order committed the
// foreign document into the caller's project and only then refused the
// cascade. It already stamps this row's project_id via the deal_uuid sweep;
// the update below covers the standalone (no-deal) document.
if (projectId && doc.deal_uuid) {
await linkDealToProject(doc.deal_uuid, projectId);
await linkDealToProject(doc.deal_uuid, projectId, db, actor);
}
await db(table).where({ id: documentId }).update({ project_id: projectId || null });
return { projectId: projectId || null, documentId };
}
const assignQuote = (projectId, quoteId) => assignDocument('quotes', projectId, quoteId);
const assignContract = (projectId, contractId) => assignDocument('contracts', projectId, contractId);
const assignQuote = (projectId, quoteId, actor) => assignDocument('quotes', projectId, quoteId, actor);
const assignContract = (projectId, contractId, actor) => assignDocument('contracts', projectId, contractId, actor);
/**
* Project valuation "newest stage wins per deal, cumulative across events".
@@ -386,16 +507,48 @@ function computeValuation(invoices = [], quotes = []) {
* Full overview aggregation for the cockpit. Returns the project, its events,
* and the rolled-up emails / quotes / contracts / invoices / hours + a
* timeline of milestones. `perms` gates which doc types are included.
*
* `admin` (optional) is used only to stamp each email with `canAct` whether
* the queued-mail routes would actually accept an action on it. See below.
*/
async function getProjectOverview(id, perms = {}) {
async function getProjectOverview(id, perms = {}, admin = null) {
const project = await getProjectById(id);
if (!project) throw new AppError('Project not found', 404);
const events = await db('events')
// `created_by` is selected for the ownership check below and stripped again
// before the response — the cockpit has no business learning who owns a
// sibling event.
const eventRows = await db('events')
.where({ project_id: id })
.select('id', 'event_name', 'event_date', 'slug', 'is_active', 'is_draft', 'expires_at', 'is_archived');
.select('id', 'event_name', 'event_date', 'slug', 'is_active', 'is_draft', 'expires_at', 'is_archived', 'created_by');
const events = eventRows.map(({ created_by: _ignored, ...e }) => e);
const eventIds = events.map((e) => e.id);
// Which of this project's events would filterOwnedEventIds() let `admin`
// act on. Mirrors that predicate exactly (ownership.js): super_admin gets
// everything, otherwise created_by IS NULL OR created_by = admin.id.
//
// Project ownership does NOT imply event ownership — ownedProjectsSubquery's
// `projects.created_by = admin.id` branch places no constraint on who owns
// the linked events, so a super_admin can attach admin B's event to admin
// A's project. Deriving actionability from `event_id != null` alone (as the
// UI first did) would then still render controls that requireOwnedQueuedEmail
// rejects with a 404.
// No admin context → nothing is actionable. Without this, an ownerless
// (legacy/system) event would satisfy `created_by == null` and be marked
// actionable for a caller we know nothing about.
const isSuperAdmin = admin?.roleName === 'super_admin';
let actionableEventIds = new Set();
if (isSuperAdmin) {
actionableEventIds = new Set(eventIds);
} else if (admin?.id != null) {
actionableEventIds = new Set(
eventRows
.filter((e) => e.created_by == null || Number(e.created_by) === Number(admin.id))
.map((e) => e.id),
);
}
const out = { project, events, emails: [], quotes: [], contracts: [], invoices: [], hours: { entries: [], totalMinutes: 0 } };
// Invoices (by event) incl. storno.
@@ -450,6 +603,11 @@ async function getProjectOverview(id, perms = {}) {
queuedAt: e.created_at, sentAt: e.sent_at, error: e.error_message, eventId: e.event_id,
// false → the cockpit preview will re-render from the current template.
stored: !!Number(e.has_rendered),
// Would requireOwnedQueuedEmail accept preview/resend/cancel/retry/send-now
// on this row? Authoritative here because the client cannot derive it: CRM
// document mail has no event to own, and event mail additionally requires
// ownership of THAT event, which the response deliberately does not expose.
canAct: isSuperAdmin || (e.event_id != null && actionableEventIds.has(e.event_id)),
});
const emailRows = [];
+35 -3
View File
@@ -63,13 +63,40 @@ function sanitizeBrandUrl(url) {
}
const trimmed = url.trim();
if (trimmed.startsWith('javascript:')) {
// GHSA-j347: the old check was a case-sensitive literal `javascript:`, which
// `JavaScript:` walks straight past. Allowlist the schemes a logo URL can
// legitimately use instead of blocklisting one spelling. Relative paths (the
// common case — /uploads/logos/x.png) carry no scheme and are unaffected.
const scheme = trimmed.match(/^\s*([a-z][a-z0-9+.-]*)\s*:/i);
if (scheme && !['http', 'https'].includes(scheme[1].toLowerCase())) {
return null;
}
return trimmed;
}
/**
* HTML-escape a brand token value (GHSA-j347).
*
* Brand tokens are substituted AFTER sanitize-html runs, so markup in a token
* value reaches the public page unfiltered. The default templates interpolate
* tokens into text AND into quoted attributes
* (`<img src="{{brand_logo_url}}" alt="{{company_name}} logo">`,
* `href="mailto:{{support_email}}"`), so escaping the five HTML-significant
* characters is correct in both positions.
*
* Mirrors galleryOgService's escapeHtml, which already handles this correctly.
*/
function escapeTokenValue(value) {
if (value === null || value === undefined) return '';
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
async function fetchBrandingContext() {
const rows = await db('app_settings')
.whereIn('setting_key', [
@@ -265,13 +292,18 @@ function applyBrandTokens(html, branding) {
brand_text_hex: branding.colors?.text || '#0f172a'
};
// Escape on substitution (GHSA-j347) — this runs AFTER sanitizeHtmlPayload,
// so an unescaped value would reintroduce raw markup into the public origin.
return html.replace(/\{\{\s*(company_name|company_tagline|support_email|brand_logo_url|brand_primary_hex|brand_accent_hex|brand_background_hex|brand_text_hex)\s*\}\}/gi,
(_, key) => tokens[key] || '');
(_, key) => escapeTokenValue(tokens[key] || ''));
}
module.exports = {
getPublicSitePayload,
clearPublicSiteCache,
getDefaultPublicSitePayload,
getRawPublicSiteSettings
getRawPublicSiteSettings,
// Exposed for tests only — the token-escaping and URL-scheme rules
// (GHSA-j347) are worth pinning directly rather than through the cache.
_internal: { applyBrandTokens, sanitizeBrandUrl }
};
+2 -2
View File
@@ -612,7 +612,7 @@ async function createQuote(payload, adminId) {
// quote with no contract/event yet — just adopts the customer onto an
// empty project).
if (row.project_id) {
await require('./projectService').linkDealToProject(row.deal_uuid, row.project_id, trx);
await require('./projectService').linkDealToProject(row.deal_uuid, row.project_id, trx, { id: adminId });
}
if (totals.lineItems.length > 0) {
@@ -751,7 +751,7 @@ async function updateQuote(id, payload, adminId) {
// contract / event / invoices roll up into the same project automatically.
if (updates.project_id) {
const dealRow = await trx('quotes').where({ id }).select('deal_uuid').first();
await require('./projectService').linkDealToProject(dealRow && dealRow.deal_uuid, updates.project_id, trx);
await require('./projectService').linkDealToProject(dealRow && dealRow.deal_uuid, updates.project_id, trx, { id: adminId });
}
// Delete + reinsert keeps the editor flow simple: the frontend
+40 -8
View File
@@ -3,6 +3,7 @@ const path = require('path');
const crypto = require('crypto');
const zlib = require('zlib');
const { pipeline } = require('stream/promises');
const { Transform } = require('stream');
const { createReadStream, createWriteStream } = require('fs');
const { spawnAsync, spawnToFile, spawnFromFile } = require('../utils/safeExec');
const { db } = require('../database/db');
@@ -519,13 +520,21 @@ class RestoreService {
};
try {
// Check backup integrity
if (manifest.verification && manifest.verification.total_checksum) {
const calculatedChecksum = backupManifest.calculateManifestChecksum(manifest);
if (calculatedChecksum !== manifest.verification.total_checksum) {
validation.errors.push('Manifest checksum verification failed');
validation.isValid = false;
}
// Check backup integrity. MUST delegate to verifyManifestChecksum rather
// than recomputing here — that helper owns the legacy-serialization and
// keyed/unkeyed fallbacks (GHSA-hgp8). Recomputing with the default
// canonical+keyed settings rejected every backup written before those
// changes, i.e. every existing one.
//
// Called UNCONDITIONALLY: the old `if (…total_checksum)` guard meant an
// attacker who could rewrite the backup store simply deleted the field
// to skip verification altogether. The helper owns that case now and
// rejects it.
const checksumResult = backupManifest.verifyManifestChecksum(manifest);
checksumResult.warnings.forEach((w) => this.log('warn', w));
if (!checksumResult.valid) {
validation.errors.push(checksumResult.error || 'Manifest checksum verification failed');
validation.isValid = false;
}
// Check backup age
@@ -1512,10 +1521,33 @@ END $$;`
* Decompress gzip file
*/
async decompressFile(inputPath, outputPath) {
// Bound the EXPANDED size (GHSA-h652). gunzip happily inflates a small
// crafted .gz into an unbounded stream, filling the disk before any later
// validation runs. Cap it and fail the pipeline the moment the limit is
// crossed. The default is deliberately generous — real database dumps are
// large — and overridable for installs with genuinely bigger data.
const configured = Number(process.env.RESTORE_MAX_DECOMPRESSED_BYTES);
const maxBytes = Number.isFinite(configured) && configured > 0
? configured
: 50 * 1024 * 1024 * 1024; // 50 GB
let written = 0;
const limiter = new Transform({
transform(chunk, _enc, cb) {
written += chunk.length;
if (written > maxBytes) {
return cb(new Error(
`Decompressed size exceeds limit of ${maxBytes} bytes — refusing to continue`
));
}
cb(null, chunk);
},
});
const gunzip = zlib.createGunzip();
const source = createReadStream(inputPath);
const destination = createWriteStream(outputPath);
await pipeline(source, gunzip, destination);
await pipeline(source, gunzip, limiter, destination);
}
/**
+50 -9
View File
@@ -15,11 +15,23 @@ const { formatBoolean } = require('../utils/dbCompat');
// First-run bootstrap. The app boots with NO admin account and no
// ADMIN_PASSWORD in the environment; the first browser visit creates the admin.
// That create call is guarded by a one-time setup token, generated at boot
// while no admin exists and printed to the logs (+ a best-effort data/SETUP_TOKEN
// file). The token is ALWAYS required and burned on first use, so the endpoint
// is permanently closed once setup is done — safe even on a public IP.
// while no admin exists and written to a 0600 data/SETUP_TOKEN file — and only
// echoed to the logs when that write fails (see ensureSetupToken). The token is
// ALWAYS required and burned on first use, so the endpoint is permanently
// closed once setup is done — safe even on a public IP.
const SETUP_TOKEN_KEY = 'setup_token';
// Path of the token file as ACTUALLY written by the last ensureSetupToken()
// run, or null when that write failed. server.js keys its stdout banner on
// this: it used to re-derive the answer with existsSync(), which reports
// success for a stale, read-only or directory-shaped SETUP_TOKEN that the write
// could not replace — suppressing the token while pointing the operator at
// content that is wrong or unreadable.
let writtenTokenFile = null;
function writtenSetupTokenFile() {
return writtenTokenFile;
}
async function noAdminExists() {
const row = await db('admin_users').count({ c: '*' }).first();
return Number(row?.c || 0) === 0;
@@ -32,8 +44,8 @@ async function getSetupStatus() {
return { needsAdmin, complete: !needsAdmin };
}
// Logs are the source of truth; the file is a convenience for operators who
// reach a shell more easily than the container log view (e.g. `cat data/SETUP_TOKEN`).
// The file is the source of truth (`cat data/SETUP_TOKEN`); the logs only carry
// the token when this file could not be written.
function setupTokenFilePath() {
const dir = process.env.DATA_DIR || path.join(__dirname, '..', '..', 'data');
return path.join(dir, 'SETUP_TOKEN');
@@ -49,6 +61,7 @@ async function clearSetupToken() {
}
async function ensureSetupToken() {
writtenTokenFile = null;
if (!(await noAdminExists())) {
await clearSetupToken();
return null;
@@ -60,13 +73,41 @@ async function ensureSetupToken() {
// (getAppSetting JSON.parses on read). A raw string is rejected by jsonb.
await upsertAppSetting(SETUP_TOKEN_KEY, JSON.stringify(token), 'string');
}
logger.warn(`[setup] No admin account yet — open /admin to finish setup. One-time setup token: ${token}`);
// Write the token to a 0600 file first, and only surface it in the logs /
// stdout when that write FAILED. Previously it was logged unconditionally at
// `warn`, so every default install (LOG_LEVEL=info) wrote a live
// first-admin-bootstrap credential into combined.log and security.log —
// both under the host-bind-mounted ./logs — never rotated out after use.
// The log line remains as the documented last-resort recovery path.
//
// server.js makes the same decision for its stdout banner by reading
// writtenSetupTokenFile() — the outcome recorded here, not a re-derived
// existsSync() guess: printing the token there lands it in `docker logs` /
// journald, which is the very leak this closes, and suppressing it when the
// file is NOT actually current strands the operator with no token at all.
let file = null;
let writeError = null;
try {
const file = setupTokenFilePath();
file = setupTokenFilePath();
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, `${token}\n`, { mode: 0o600 });
writtenTokenFile = file;
} catch (err) {
logger.warn(`[setup] Could not write setup token file (logs still have it): ${err.message}`);
writeError = err;
file = null;
}
if (writeError) {
logger.warn(
`[setup] Could not write the setup token file (${writeError.message}) — `
+ 'falling back to the log. No admin account yet; open /admin to finish setup. '
+ `One-time setup token: ${token}`
);
} else {
logger.warn(
'[setup] No admin account yet — open /admin to finish setup. '
+ `The one-time setup token is in ${file} (not logged).`
);
}
return token;
}
@@ -173,4 +214,4 @@ async function createInitialAdmin({ token, email, password, ip }) {
};
}
module.exports = { getSetupStatus, ensureSetupToken, verifySetupToken, createInitialAdmin };
module.exports = { getSetupStatus, ensureSetupToken, setupTokenFilePath, writtenSetupTokenFile, verifySetupToken, createInitialAdmin };
@@ -59,6 +59,13 @@ function buildAdapter({ baseUrl, websiteId, apiKey }) {
Authorization: `Bearer ${apiKey}`,
Accept: 'application/json',
},
// Never follow a redirect (GHSA-mw76). undici only strips
// Authorization/Cookie/Proxy-Authorization/Host when a redirect
// crosses origins — a custom key header would be replayed verbatim to
// whatever host the tracker redirects to. Self-hosted trackers on
// private addresses keep working; only a proxy that 301s is affected,
// and that surfaces as a clear logged error rather than a silent leak.
redirect: 'error',
signal: controller.signal,
});
} catch (err) {
@@ -41,6 +41,13 @@ function buildAdapter({ baseUrl, websiteId, apiKey }) {
'x-umami-api-key': apiKey,
Accept: 'application/json',
},
// Never follow a redirect (GHSA-mw76). undici only strips
// Authorization/Cookie/Proxy-Authorization/Host when a redirect
// crosses origins — a custom key header would be replayed verbatim to
// whatever host the tracker redirects to. Self-hosted trackers on
// private addresses keep working; only a proxy that 301s is affected,
// and that surfaces as a clear logged error rather than a silent leak.
redirect: 'error',
signal: controller.signal,
});
} catch (err) {
+34 -1
View File
@@ -12,4 +12,37 @@ function isUniqueViolation(err) {
return /unique/i.test(msg) || /sqlite_constraint/i.test(msg);
}
module.exports = { isUniqueViolation };
/**
* Does this error mean the `roles` table/column genuinely isn't there yet
* (mid-upgrade), as opposed to the database being briefly unhappy?
*
* The distinction matters because both auth paths fall back to granting
* super_admin when the roles join fails: a catch-all would turn any transient
* failure connection reset, deadlock, statement timeout, pool exhaustion
* into a privilege escalation that hands a demoted viewer exactly the access
* GHSA-9697 closes. Callers must rethrow anything this returns false for.
*/
function isMissingRolesSchema(err) {
if (!err) return false;
const message = String(err.message || '');
// Postgres is authoritative via SQLSTATE: 42P01 undefined_table, 42703
// undefined_column. Both are schema conditions, never transient.
if (err.code === '42P01' || err.code === '42703') return true;
// SQLite carries no SQLSTATE, so the driver's wording is all there is — but
// it must be matched EXACTLY, naming the object the roles join needs. A
// generic /does not exist/ test would be unsound here: knex prefixes the
// failing SQL to err.message, and that SQL always names `roles` on this
// join, so any "... does not exist" fault on the connection (e.g. pgbouncer
// losing a named prepared statement, SQLSTATE 26000) would read as a missing
// roles schema and fabricate super_admin.
//
// Two states are legitimate, per the migration order:
// pre-054 → roles table absent
// post-054, pre-057 → roles exists, admin_users.role_id not added yet
return /no such table: roles\b/i.test(message)
|| /no such column: (roles\.|admin_users\.role_id\b)/i.test(message);
}
module.exports = { isUniqueViolation, isMissingRolesSchema };
+84
View File
@@ -0,0 +1,84 @@
/**
* Redact credential-bearing fields before an object reaches the logs
* (GHSA-pgmp / GHSA-r794).
*
* Event create/update routes logged the whole request body. That body can
* carry a gallery `password`, a bcrypt `client_password_hash`, and when
* `regenerate_client_token` is set a freshly minted `client_share_token`,
* which is a LIVE bearer credential for client gallery access, not a hash.
*
* Deliberately key-name based rather than value-shaped: a deny-set of names is
* predictable and cheap, whereas guessing at "this looks like a secret" both
* misses and false-positives. Matching is case-insensitive and substring-based
* so `client_password_hash` and `smtp_pass` are caught without enumerating
* every variant.
*/
const DENY_FRAGMENTS = [
'password',
'passwd',
'secret',
'token',
'api_key',
'apikey',
'authorization',
'credential',
'private_key',
];
const REDACTED = '[redacted]';
function isSensitiveKey(key) {
const k = String(key).toLowerCase();
return DENY_FRAGMENTS.some((fragment) => k.includes(fragment));
}
/**
* Return a copy of `value` with sensitive fields replaced by `[redacted]`.
* Non-objects pass through unchanged. Cycles are handled so a caller can't
* turn a log line into an infinite loop.
*
* @param {*} value
* @param {number} [depth] internal recursion guard
* @param {WeakSet} [seen] internal cycle guard
*/
function sanitizeForLog(value, depth = 0, seen = new WeakSet()) {
if (value === null || typeof value !== 'object') return value;
if (depth > 6) return '[truncated]';
if (seen.has(value)) return '[circular]';
seen.add(value);
if (Array.isArray(value)) {
return value.map((v) => sanitizeForLog(v, depth + 1, seen));
}
const out = {};
for (const [key, val] of Object.entries(value)) {
out[key] = isSensitiveKey(key) ? REDACTED : sanitizeForLog(val, depth + 1, seen);
}
return out;
}
/**
* Redact express-validator's `errors.array()` before logging.
*
* Each entry carries the SUBMITTED value under `value`, keyed by `path`. A
* password that fails the length check therefore lands in the log in plaintext
* sanitizing only `req.body` does not close that (GHSA-pgmp / r794).
*
* @param {Array} errors output of validationResult(req).array()
*/
function sanitizeValidationErrors(errors) {
if (!Array.isArray(errors)) return errors;
return errors.map((err) => {
if (!err || typeof err !== 'object') return err;
const field = err.path || err.param;
if (field && isSensitiveKey(field)) {
return { ...err, value: REDACTED };
}
// Even for a non-sensitive field the value may be an object carrying one.
return 'value' in err ? { ...err, value: sanitizeForLog(err.value) } : err;
});
}
module.exports = { sanitizeForLog, sanitizeValidationErrors, isSensitiveKey };
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "picpeak-frontend",
"private": true,
"version": "3.45.12",
"version": "3.45.15",
"type": "module",
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -3619,7 +3619,7 @@
"tokenCommandLabel": "Token nicht gefunden? Führen Sie dies im Projektverzeichnis aus:",
"copyCommand": "Befehl kopieren",
"tokenRotatedLink": "Logs bereits rotiert? Zur Einrichtungsanleitung",
"tokenLocationHint": "Nicht gefunden? Führen Sie aus: docker compose logs backend | grep -i \"setup token\"",
"tokenLocationHint": "Nicht gefunden? Führen Sie aus: docker compose exec backend cat /app/data/SETUP_TOKEN",
"invalidToken": "Dieser Setup-Token ist ungültig.",
"passwordRequirements": "Verwenden Sie mindestens 8 Zeichen mit einem Groß- und einem Kleinbuchstaben sowie einer Ziffer.",
"emailLabel": "E-Mail-Adresse",
+1 -1
View File
@@ -3515,7 +3515,7 @@
"tokenCommandLabel": "Can't find your token? Run this in the project directory:",
"copyCommand": "Copy command",
"tokenRotatedLink": "Logs already rotated away? Read the setup guide",
"tokenLocationHint": "Can't find it? Run: docker compose logs backend | grep -i \"setup token\"",
"tokenLocationHint": "Can't find it? Run: docker compose exec backend cat /app/data/SETUP_TOKEN",
"invalidToken": "That setup token is not valid.",
"passwordRequirements": "Use at least 8 characters with an upper-case letter, a lower-case letter and a number.",
"emailLabel": "Email address",
+1 -1
View File
@@ -92,7 +92,7 @@ export const SetupPage: React.FC = () => {
if (errors[field]) setErrors((prev) => ({ ...prev, [field]: '' }));
};
const recoveryCommand = 'docker compose logs backend | grep -i "setup token"';
const recoveryCommand = 'docker compose exec backend cat /app/data/SETUP_TOKEN';
const copyRecoveryCommand = async () => {
try {
@@ -27,6 +27,7 @@ import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
import { useMutationWithToast } from '../../../hooks';
import { formatMoneyMinor } from '../../../utils/money';
import { useFeatureFlags, type FeatureKey } from '../../../contexts/FeatureFlagsContext';
import { usePermissions } from '../../../contexts/PermissionsContext';
type FeedKind = 'email' | 'quote' | 'contract' | 'invoice' | 'gallery' | 'hours';
@@ -42,6 +43,9 @@ interface FeedItem {
emailId?: number;
emailStatus?: string;
reRendered?: boolean;
/** Server's verdict on whether the queued-mail routes would accept an action
* on this row. Not derivable client-side see canActOnEmail. */
emailCanAct?: boolean;
}
/** The feature flag that gates each document's detail ROUTE (RequireFeature
@@ -109,8 +113,23 @@ export const ProjectCockpitPage: React.FC = () => {
const navigate = useNavigate();
const qc = useQueryClient();
const { flags } = useFeatureFlags();
const { hasPermission } = usePermissions();
const { format, formatTime } = useLocalizedDate();
// Which email controls are actually reachable for THIS admin, so the feed
// stops offering buttons the API will reject (#969):
// 404 — requireOwnedQueuedEmail (adminProjects.js) scopes queued mail
// through email_queue.event_id AND ownership of that event. CRM
// document mail carries no event_id at all. The server decides this
// for us (`canAct`) because neither half is derivable here: the
// overview deliberately does not expose who owns a sibling event.
// Absent (older backend) → treat as not actionable, so we fail to
// hiding a live control rather than offering a dead one.
// 403 — preview needs `events.view`, but resend/cancel/retry/send-now
// need `email.send`; the feed used to render all four regardless.
const canActOnEmail = (item: FeedItem) => item.emailCanAct === true;
const canSendEmail = hasPermission('email.send');
const [editName, setEditName] = useState<string | null>(null);
const [preview, setPreview] = useState<EmailPreview | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
@@ -189,6 +208,7 @@ export const ProjectCockpitPage: React.FC = () => {
title: t(`projects.feed.email`, 'Email') + ` · ${e.type}`,
subtitle: e.recipient + (e.error ? `${e.error}` : ''),
status: e.status, emailId: e.id, emailStatus: e.status, reRendered: !e.stored,
emailCanAct: e.canAct,
});
}
for (const q of data.quotes) {
@@ -376,7 +396,9 @@ export const ProjectCockpitPage: React.FC = () => {
// not a dead strip next to them). Hours have neither → static.
const onRowClick = item.href
? () => navigate(item.href as string)
: (item.kind === 'email' && item.emailId != null ? () => openPreview(item.emailId as number) : undefined);
: (item.kind === 'email' && item.emailId != null && canActOnEmail(item)
? () => openPreview(item.emailId as number)
: undefined);
return (
<li
key={item.key}
@@ -397,7 +419,7 @@ export const ProjectCockpitPage: React.FC = () => {
<span className="inline-block rounded-full px-2 py-0.5 text-xs bg-neutral-100 dark:bg-neutral-700 text-neutral-600 dark:text-neutral-300">{item.status}</span>
)}
{item.amount && <span className="text-xs font-medium text-neutral-700 dark:text-neutral-300">{item.amount}</span>}
{item.kind === 'email' && item.emailId != null && (
{item.kind === 'email' && item.emailId != null && canActOnEmail(item) && (
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<button onClick={() => openPreview(item.emailId as number)} className="inline-flex items-center gap-1 text-xs text-primary-600 hover:underline">
<Eye className="w-3 h-3" />{t('projects.email.preview', 'Preview')}
@@ -410,12 +432,12 @@ export const ProjectCockpitPage: React.FC = () => {
{t('projects.email.reRenderedTag', '≈ re-rendered')}
</span>
)}
{item.emailStatus === 'sent' && (
{canSendEmail && item.emailStatus === 'sent' && (
<button onClick={() => emailActionMutation.mutate({ action: 'resend', emailId: item.emailId as number })} className="inline-flex items-center gap-1 text-xs text-neutral-600 dark:text-neutral-300 hover:underline">
<Send className="w-3 h-3" />{t('projects.email.resend', 'Resend')}
</button>
)}
{item.emailStatus === 'pending' && (
{canSendEmail && item.emailStatus === 'pending' && (
<>
<button onClick={() => emailActionMutation.mutate({ action: 'sendNow', emailId: item.emailId as number })} className="inline-flex items-center gap-1 text-xs text-neutral-600 dark:text-neutral-300 hover:underline">
<Send className="w-3 h-3" />{t('projects.email.sendNow', 'Send now')}
@@ -425,7 +447,7 @@ export const ProjectCockpitPage: React.FC = () => {
</button>
</>
)}
{item.emailStatus === 'failed' && (
{canSendEmail && item.emailStatus === 'failed' && (
<button onClick={() => emailActionMutation.mutate({ action: 'retry', emailId: item.emailId as number })} className="inline-flex items-center gap-1 text-xs text-amber-600 hover:underline">
<RotateCw className="w-3 h-3" />{t('projects.email.retry', 'Retry')}
</button>
@@ -0,0 +1,158 @@
/**
* The cockpit's email feed used to render preview/resend/cancel/retry/send-now
* for every mail, consulting neither the caller's role nor their permissions.
* Two ways that produced dead controls (#969):
*
* 404 requireOwnedQueuedEmail scopes queued mail through
* email_queue.event_id. CRM document mail (quote/contract/invoice/
* storno) is queued with event_id = null, so for a non-super_admin it
* has no ownable parent and always 404s. Introduced by the GHSA-93x4
* fix in #960/#966, which added the ownership middleware.
* 403 preview requires `events.view` but the four write actions require
* `email.send`; the feed rendered all of them regardless.
*/
import { render, screen, waitFor } from '@testing-library/react';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter, Routes, Route } from 'react-router-dom';
vi.mock('react-i18next', async () => {
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next');
return {
...actual,
useTranslation: () => ({
t: (k: string, fb?: unknown) => (typeof fb === 'string' ? fb : k),
i18n: { language: 'en' },
}),
};
});
vi.mock('react-toastify', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
// An event mail (actionable by anyone who owns the event) and a CRM document
// mail (event_id null → only a super_admin can act on it). Both 'sent', so the
// Resend button is the one under test in each row.
const OVERVIEW = {
project: {
id: 1, name: 'Hochzeit Müller', customerAccountId: 1,
customerEmail: 'kunde@example.com', status: 'active',
createdAt: '2026-07-01T00:00:00Z', updatedAt: '2026-08-01T00:00:00Z',
},
events: [], quotes: [], contracts: [], invoices: [],
hours: { entries: [], totalMinutes: 0 },
milestones: [],
valuation: { byCurrency: [] },
emails: [
// Event mail the caller owns → the server says actionable.
{ id: 11, recipient: 'kunde@example.com', type: 'gallery_ready', status: 'sent',
queuedAt: '2026-08-01T10:00:00Z', sentAt: '2026-08-01T10:00:05Z', error: null,
eventId: 42, stored: true, canAct: true },
// CRM document mail — no event to own, so 404 for a non-super_admin.
{ id: 12, recipient: 'kunde@example.com', type: 'invoice_sent', status: 'sent',
queuedAt: '2026-08-01T11:00:00Z', sentAt: '2026-08-01T11:00:05Z', error: null,
eventId: null, stored: true, canAct: false },
// Event mail for a FOREIGN event: a super_admin can attach admin B's event
// to admin A's project, and project ownership does not imply event
// ownership — so eventId is non-null yet the action still 404s.
{ id: 13, recipient: 'kunde@example.com', type: 'gallery_ready', status: 'sent',
queuedAt: '2026-08-01T12:00:00Z', sentAt: '2026-08-01T12:00:05Z', error: null,
eventId: 99, stored: true, canAct: false },
],
};
/** The same payload as a super_admin sees it — the server stamps every row. */
const OVERVIEW_SUPER = {
...OVERVIEW,
emails: OVERVIEW.emails.map((e) => ({ ...e, canAct: true })),
};
let currentOverview: typeof OVERVIEW = OVERVIEW;
vi.mock('../../../../services/projects.service', () => ({
projectsService: {
overview: vi.fn(async () => currentOverview),
emailPreview: vi.fn(async () => ({ html: '<p>x</p>', subject: 's' })),
resendEmail: vi.fn(), cancelEmail: vi.fn(), retryEmail: vi.fn(), sendEmailNow: vi.fn(),
update: vi.fn(), assignEvent: vi.fn(),
},
}));
vi.mock('../../../../services/events.service', () => ({
eventsService: { getEvents: vi.fn(async () => []) },
}));
vi.mock('../../../../contexts/FeatureFlagsContext', () => ({
useFeatureFlags: () => ({ flags: { projects: true, quotes: true, contracts: true, bills: true } }),
}));
let mockPerms = { isSuperAdmin: false, permissions: ['events.view'] };
vi.mock('../../../../contexts/PermissionsContext', () => ({
usePermissions: () => ({
isSuperAdmin: mockPerms.isSuperAdmin,
hasPermission: (p: string) => mockPerms.isSuperAdmin || mockPerms.permissions.includes(p),
}),
}));
import { ProjectCockpitPage } from '../ProjectCockpitPage';
const renderPage = async () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<MemoryRouter initialEntries={['/admin/projects/1']}>
<Routes>
<Route path="/admin/projects/:id" element={<ProjectCockpitPage />} />
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
await waitFor(() => expect(screen.getAllByText(/Email/).length).toBeGreaterThan(0));
};
describe('ProjectCockpitPage email controls (#969)', () => {
beforeEach(() => {
mockPerms = { isSuperAdmin: false, permissions: ['events.view'] };
currentOverview = OVERVIEW;
});
it('super_admin sees controls on every row', async () => {
mockPerms = { isSuperAdmin: true, permissions: [] };
currentOverview = OVERVIEW_SUPER;
await renderPage();
// All three rows, Resend on all (super_admin implies email.send).
expect(screen.getAllByText('Preview')).toHaveLength(3);
expect(screen.getAllByText('Resend')).toHaveLength(3);
});
it('a scoped admin gets controls only on mail the server says it can act on', async () => {
mockPerms = { isSuperAdmin: false, permissions: ['events.view', 'email.send'] };
await renderPage();
// Only the owned event mail. The CRM row (no event) and the foreign-event
// row both 404, so neither offers anything.
expect(screen.getAllByText('Preview')).toHaveLength(1);
expect(screen.getAllByText('Resend')).toHaveLength(1);
});
it('an admin without email.send sees preview but no write actions (would 403)', async () => {
mockPerms = { isSuperAdmin: false, permissions: ['events.view'] };
await renderPage();
expect(screen.getAllByText('Preview')).toHaveLength(1);
expect(screen.queryByText('Resend')).toBeNull();
expect(screen.queryByText('Cancel')).toBeNull();
expect(screen.queryByText('Retry')).toBeNull();
expect(screen.queryByText('Send now')).toBeNull();
});
it('offers nothing when the server omits canAct (older backend)', async () => {
mockPerms = { isSuperAdmin: false, permissions: ['events.view', 'email.send'] };
currentOverview = {
...OVERVIEW,
emails: OVERVIEW.emails.map(({ canAct: _drop, ...e }) => e as typeof OVERVIEW.emails[number]),
};
await renderPage();
// Fail to hiding a live control rather than offering a dead one.
expect(screen.queryByText('Preview')).toBeNull();
expect(screen.queryByText('Resend')).toBeNull();
});
});
+8 -1
View File
@@ -23,8 +23,15 @@ const STATE_POLL_MS = 3000;
// Prefer the aspect-preserved preview (≤1920px) over the full original; fall
// back to the standard url. Always absolutised so it works whether the API is
// same-origin or an explicit absolute base.
//
// Deliberately never `hero_url` (#1015): that tier is cover-cropped to 16:9
// for gallery header banners, so with fit='contain' the show letterboxed an
// already-cropped frame — portrait photos lost their top and bottom and the
// "Black Bars (No crop)" setting looked broken. `slideshow_url` is the same
// aspect-preserved preview as `preview_url` but is always emitted, so the
// crop can't come back when lightbox previews are off (the default).
function photoSrc(photo: Photo): string {
return buildResourceUrl(photo.preview_url || photo.hero_url || photo.url);
return buildResourceUrl(photo.slideshow_url || photo.preview_url || photo.url);
}
// CSS `filter` applied directly to the image for filters that are pure tone
@@ -49,6 +49,10 @@ export interface ProjectEmail {
eventId: number | null;
/** true = exact HTML stored at send time; false = preview re-rendered. */
stored: boolean;
/** Authoritative: would the queued-mail routes accept an action on this row?
* False for CRM document mail (no event to own) and for event mail the
* caller does not own. Absent on older backends treated as false. */
canAct?: boolean;
}
export interface ProjectInvoice {
+5
View File
@@ -116,6 +116,11 @@ export interface Photo {
// ≤1920px JPEG; the lightbox prefers it over `url` for image photos
// and falls back to `url` when null (off, video, or not yet generated).
preview_url?: string | null;
// Aspect-preserved ≤1920px source for the fullscreen slideshow (#1015).
// Always set for image photos, unlike `preview_url` — the slideshow must
// never fall back to `hero_url`, which is a 16:9 centre crop and makes
// the "Black Bars (No crop)" fit letterbox an already-cropped frame.
slideshow_url?: string | null;
secure_url_template?: string;
download_url_template?: string;
requires_token?: boolean;