Compare commits

...

26 Commits

Author SHA1 Message Date
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
Paul Nothaft 0c73bf2cdc chore(stable): release 3.45.12 (#955)
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-02 20:25:40 +02:00
Paul Nothaft 2c7b5dfd02 fix(security): redact gallery share tokens from analytics tracking (GHSA-7m6c) (stable) (#953)
* fix(security): redact gallery share tokens from analytics page-view tracking (GHSA-7m6c)

* fix(security): codex round-1 — actually disable raw auto-tracking (GHSA-7m6c)

The previous patch was inert: App.tsx passed autoTrack:true (so Umami's
data-auto-track=false was never set) and the sanitized trackPageView had no
caller (useAnalytics sits outside <Router>), so the raw token URL still hit
the collector.

- Umami: drop autoTrack:true → data-auto-track=false; page views now come
  from a sanitized manual tracker.
- Rybbit: its initial-load auto pageview can't be intercepted client-side, so
  use native data-mask-patterns=['/gallery/**'] to strip the token on every
  auto-tracked view; skip manual tracking for it to avoid double counting.
- Mount <AnalyticsRouteTracker/> INSIDE <Router> so manual tracking runs.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-02 08:39:40 +02:00
Paul Nothaft 5d5db4e766 fix(security): authz/ownership gaps (token binding, auth revocation, feedback/customer ownership, token logging) (stable) (#951)
* fix(security): close authz/ownership gaps (secure-download binding, photo-auth+logout revocation, feedback/customer ownership, token logging)

* fix(security): codex round-1 — complete admin-token invalidation + preserve foreign assignments

- photoAuth: mirror adminAuth's active-admin lookup + iat<password_changed_at
  check in the admin branch, so a deactivated admin or a pre-password-change
  token can no longer fetch every photo (GHSA-x55x was only revoke+cutoff).
- adminAuth logout: revoke req.token (the token adminAuth authenticated with,
  cookie OR header) instead of header-only, and clear the auth cookie — a
  cookie-based logout previously left the JWT live (GHSA-cjqh).
- adminCustomers PUT /:id/events: preserve the customer's existing
  assignments to events the caller does NOT own, so a restricted admin can't
  revoke another admin's customer-event links via full-list replacement.

* fix(security): codex round-2 — don't 403 legit restricted-admin assignment edits

The Manage-galleries dialog submits the full initial assignment list, so a
restricted admin editing a customer that already has a foreign assignment hit
the denied.length 403 before the preservation logic ran. Reject only
NEWLY-supplied foreign/nonexistent ids; retain foreign ids the customer is
already assigned to (they can't be added or removed by a non-owner).

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-02 08:39:32 +02:00
Paul Nothaft e5dccf1664 fix(security): neutralize spreadsheet formulas in all CSV/export cell-writers (CSV injection cluster) (#949)
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-02 08:38:55 +02:00
Paul Nothaft bfafecedc7 fix(security): unauth share_token leak (HIGH) + restore path-traversal, logo file-read (stable) (#947)
* fix(security): stop unauth share_token leak + block restore path-traversal, logo-path file read, branding path keys

* test: update resolveLogoFile for the c7x5 containment (reject outside-storage absolute paths, keep inside)

* fix(security): codex round-1 — escape LIKE wildcards in share-link resolve, keep in-storage absolute logos, guard restore verification

- shareLinkService: escape %/_ in the link_partial LIKE fallback so an
  anonymous /resolve/____… wildcard can't match an arbitrary share_link and
  leak its bearer token (reopened GHSA-rh8r). Explicit ESCAPE for SQLite.
- resolveLogoFile: re-add the raw absolute candidate but keep it subject to
  the storage-root containment filter (GHSA-c7x5) so legit in-storage
  absolute logos resolve while /etc/passwd stays rejected.
- restoreService: apply the same pathEscapes guard in post-restore
  verification so a skipped traversal entry isn't fs.access'd/hashed.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-02 08:38:47 +02:00
Paul Nothaft 2c5a094c5c chore(stable): release 3.45.11 (#936)
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-01 17:38:06 +02:00
Paul Nothaft 2462ba6897 fix(security): close authorization/ownership gaps (token scope, mass-assignment, category hero, project docs) (stable) (#944)
* fix(security): close authorization/ownership gaps (token scope, mass-assignment, category hero, project docs)

* fix(security): block archive columns in event mass-assignment per review

* fix(security): comprehensive event mass-assignment denylist + deal-cascade cross-domain permission gate (codex r2)

* fix(security): case-insensitive complete event denylist + project_id + empty-update no-op (codex r3)

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-01 17:36:57 +02:00
Paul Nothaft 90275f88e9 fix(security): resolve DNS before vetting external hostnames (SSRF cluster) (stable) (#942)
* fix(security): resolve DNS before vetting external hostnames (SSRF cluster)

* fix(security): harden SSRF fix per review (rsync backup path, S3 config-save, webhook transient-DNS retry)

* fix(security): S3 endpoint validation on any endpoint update + no-connect on unresolved webhook host (codex r2)

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-01 17:36:51 +02:00
Paul Nothaft 34a7b1c013 fix(security): block guest access to hidden/client-only photos across bulk + secure routes (stable) (#940)
* fix(security): block guest access to hidden/client-only photos across bulk + secure routes

* fix(security): harden hidden-photo fix per review (stale ZIP cache, legacy token mint, SQLite bool, client rebuild)

* fix(security): invalidate ZIP cache on photo visibility/category change (codex r2)

* fix(security): recheck photo visibility at signed/secure serve time (TOCTOU) + invalidate ZIP on client visibility change (codex r3)

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-01 17:36:46 +02:00
Paul Nothaft 7419c68337 fix(security): bump sanitize-html to 2.17.5 (CVE-2026-53606) (stable) (#938)
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-01 17:36:40 +02:00
Paul Nothaft fc99e2b233 fix(uploads): prevent cross-photo contamination from filename collisions and non-atomic writes (#931) (stable) (#934)
* fix(uploads): prevent cross-photo contamination from filename collisions and non-atomic writes (#931)

* test: pin the suffixed photo filename format in the NFD pipeline suite (#931)

* chore(deps): promote p-limit to a direct dependency for the watermark limiter (#931)

* test: make the suffix-uniqueness check deterministic-in-practice (#931)

* fix(uploads): widen the anti-collision suffix to 48 bits (#931)

* fix(uploads): hide staging files from list() + share one watermark limiter process-wide (#931)

* fix(uploads): reclaim orphaned staging files + revalidate watermark settings in queued jobs (#931)

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-01 12:29:23 +02:00
Paul Nothaft 7974b9c6d7 chore(stable): release 3.45.10 (#923)
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-07-30 21:20:10 +02:00
Paul Nothaft 60cbda5b22 fix(security): close GHSA-g94x (cross-gallery photo read) + GHSA-pv6w (admin DB export) (stable) (#925)
* fix(security): close two access-control advisories (GHSA-g94x, GHSA-pv6w) (stable)

GHSA-g94x-8vv8-3c9f (HIGH) — the secure-image VIEW route
(/secure-images/:slug/secure/:photoId/:token) validated only the token
signature and took the gallery/photo from the URL, so a token minted on
any PUBLIC gallery read every other gallery's photos with no password
(its download sibling has verifyGalleryAccess; the view route can't —
it serves via <img src> with no header). Bind the token to its scope
instead: the URL photoId must equal the token's minted photoId (photos
belong to exactly one gallery, and minting is gallery-scoped), and the
gallery embedded in the token's sessionId must equal the URL gallery.

GHSA-pv6w-rj34-wj9v (MEDIUM) — GET /admin/backup/picpeak/export dumps
every table unredacted (bcrypt hashes, 2FA, SMTP/SSO/WhatsApp/webhook/S3
secrets) and was gated only by backup.create, which the built-in admin
role holds. Gate it behind super_admin, matching the restore side
(backup.restore, already admin-denied) and the masked config APIs.

Regression tests pin both: cross-gallery token reads 403 (photo and
gallery checks), backup export 403 for admin / passes for super_admin.

Stable port of #924. secureImages on stable has no reveal-mode block, so
only the token-binding checks are added; the backup export gate is
identical.

* fix(security): review follow-ups on the export gate (GHSA-pv6w)

- test: place the mocked export in its own mkdtemp dir. The route
  recursively deletes path.dirname(filePath) after download, so a stub
  in bare os.tmpdir() made the super_admin test wipe the whole temp
  root — other jest workers' DB files included (latent CI flake).
- ui: hide PicpeakExportCard from non-super_admins. The role keeps
  settings.view + backup.create, so after the gate its Download button
  always 403'd with a generic toast; gate the card on role super_admin
  to match the endpoint.

* fix(security): keep the token-mismatch audit values within varchar(20) (GHSA-g94x review)

image_access_logs.access_type is varchar(20) (migration 038), but
'token_gallery_mismatch' is 22 chars — on Postgres the audit write
threw value-too-long and logImageAccess swallowed it, so the security
event went unrecorded (the 403 still fired; log is best-effort). Shorten
to 'photo_mismatch' / 'gallery_mismatch' (14/16).

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-07-30 14:24:34 +02:00
Paul Nothaft a27d19b4d1 fix(admin): expose view/download counters in the admin photos list (#895 follow-up) (stable) (#915)
* fix(admin): expose view/download counters in the admin photos list (#895 follow-up) (stable)

st-ivan's re-test after #904: statistics panel and event summary now
agree, but the per-image Engagement column still shows 0. Root cause:
the admin photos LIST endpoint maps rows to an explicit response object
that includes like/comment/rating/favorite counts but never included
view_count or download_count — the grid reads photo.view_count ?? 0,
so the column showed 0 regardless of what the DB counted. This mapper,
not stale data, is also why per-image downloads always displayed 0 in
the original report.

Suite extended with a list-endpoint assertion (beacon + download, then
the admin list reflects 1/1 and untouched photos 0/0). The skip test now
neutralizes the route's background pre-zip build, whose async ENOENT
against the intentionally missing file could land mid-suite.

Includes the one-line chunkedUploadService unref from #911 so the test
suite can mount adminPhotos regardless of merge order (identical change,
merges cleanly either way).

* test: widen the fire-and-forget settle window (#895 follow-up)

The 100ms settle was marginal on loaded CI runners — the counter
increments are deliberately fire-and-forget, and the 909 PRs flaked on
exactly these assertions. 400ms keeps the suite fast while giving slow
runners room.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-07-30 12:15:38 +02:00
Paul Nothaft d68d84e5c8 fix(admin): serve videos with their real MIME type in the admin photo view (#908) (stable) (#911)
* fix(admin): serve videos with their real MIME type in the admin photo view (#908) (stable)

The admin view route built Content-Type from the filename extension —
image/<ext> — which is invalid for videos (image/mp4). The admin player
fetches this URL into a blob that inherits the type, and browsers
refuse to play a <video> blob labeled image/*: blank/grey preview,
while download (which already uses photo.mime_type) worked fine.

Stored mime_type now wins; videos without one fall back to video/mp4,
images to the extension, and extensionless files to image/jpeg instead
of the equally invalid bare 'image/'.

Also unrefs chunkedUploadService's module-level hourly cleanup interval:
it kept Jest from exiting for any suite requiring adminPhotos (it's why
adminPhotos.reference sits on the CI ignore list). Production behavior
unchanged — the HTTP listener keeps the process alive.

New adminPhotoContentType suite pins all four MIME cases.

* fix(admin): harden admin photo Content-Type resolution (#908 review round)

External review findings, all verified:

- The header is now ALWAYS image/* or video/*. photos.mime_type is
  never echoed verbatim unless it is a video/ type — the chunked-upload
  path stores the client-sent MIME unvalidated, so a stored text/html
  served inline under the app origin was a same-origin XSS hazard.
- MIME-less videos map from the extension via the shared
  EXTENSION_TO_MIME (.mov → video/quicktime, .webm → video/webm)
  instead of a blanket video/mp4 that would mislabel them.
- Images ignore the stored MIME entirely: migration 039 backfilled
  image/jpeg onto every legacy row (PNGs included), so trusting it
  would regress previously-correct extension-derived types. Extension
  wins, normalized (jpg → image/jpeg).

Suite extended to 8 MIME cases including the XSS guard and the
039-backfill immunity.

* fix(admin): validate stored video MIME as a full header-safe token (#908 review round 2)

A prefix check let malformed client-stored values through:
'video/mp4\r\nX: y' makes res.setHeader throw ERR_INVALID_CHAR — a
permanent 500 for that photo — and a bare 'video/' is an invalid type.
Strict /^video\/[\w.+-]+$/ now gates the stored value; anything else
falls back to the extension map. Two new tests pin both shapes.

* fix(admin): map-only image Content-Type — no raw extension interpolation (#908 review round 3)

image/${ext} could synthesize image/svg+xml (scriptable when served
inline) or header-invalid values from client-controlled chunked-upload
filenames. The shared EXTENSION_TO_MIME map is now the allowlist on the
image side too; unmapped extensions serve as image/jpeg — browsers
sniff image bytes in img/blob contexts, so a mislabel is harmless where
an injected type is not.

* fix(admin): own-property lookup in the extension MIME map (#908 review round)

A client-controlled filename ending in .constructor / .__proto__ /
.toString made EXTENSION_TO_MIME[ext] return an inherited Object.prototype
member (truthy), and the downstream extMime.startsWith threw —
a permanent 500 on the admin view for that photo instead of the JPEG /
mp4 fallback. hasOwnProperty-gated now; test pins both a .constructor
image and a .__proto__ video.

* fix(admin): honor safe stored image MIME for auto-imported formats (#908 review round 2)

My previous round made the image side map-only to dodge the migration
039 image/jpeg backfill and image/svg+xml — but that regressed the S3
auto-importer (STORAGE_AUTO_IMPORT), which stores correct types for
avif/bmp/tiff/heic whose extensions aren't in EXTENSION_TO_MIME. Those
now served as image/jpeg (JPEG-labelled non-JPEG bytes).

Precedence is now mapped-extension (still corrects the 039 backfill on
PNGs) -> stored MIME IF in a safe raster allowlist (avif/bmp/tiff/heic
+ the mapped ones) -> image/jpeg. Allowlist, not a regex: image/svg+xml
stays excluded (scriptable inline). Tests pin avif preserved and svg
degraded to jpeg.

* fix(admin): allow any header-safe raster MIME, deny svg/xml (#908 review round 3)

The round-2 hand-listed Set kept missing formats the S3 auto-importer
stores (apng/ico/jxl beyond avif/bmp/tiff). Replace it with a regex:
honor image/<token> EXCEPT the scriptable svg / *+xml family. Covers
every current and future raster type in one rule while still blocking
inline-scriptable svg and header injection. Tests pin apng + x-icon
preserved, svg still degraded to jpeg.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-07-30 12:14:58 +02:00
Paul Nothaft 6891769124 fix(admin): stop marking events expired up to 24h early (#909) (stable) (#917)
* fix(admin): stop marking events expired up to 24h early (#909) (stable)

differenceInDays truncates to whole days, so an event expiring in a few
hours returned 0 and three admin surfaces treated it as gone:

- EventsListPage: status chip said 'Expired' (days <= 0) while the
  public gallery — which compares real timestamps — correctly showed
  'expires in X hours'. This is the reporter's exact symptom.
- EventDetailsPage: same isExpired math on the detail view.
- AdminDashboard: the expiring-soon card showed '0 days left' on the
  final day.

Expired is now gated on the actual timestamp (expires_at <= now), and
the countdown chips use ceiling days so the last day reads '1 day
left' instead of flipping to Expired/0.

* fix(admin): drop already-expired events from the dashboard card (#909 review round)

The expiring-soon card ran Math.max(1, ceil(delta)), so an event that
expired while the dashboard sat open (its query isn't polled) showed
'1 day left' indefinitely from the stale cached row. Expired rows are
now filtered out before render; the delta is therefore always positive
and the clamp is gone.

* fix(admin): refresh expiry status live at the boundary (#909 review round 2)

Two review findings on the admin expiry surfaces:

- The dashboard 'expiring soon' card, list badges, and detail banner are
  all computed inline from Date.now() at render, so a page left open
  across an event's expiry kept showing 'active'/'1 day left' until an
  unrelated render — which for editor/viewer roles (no health poll)
  never happens.
- My round-1 client-side filter on the dashboard desynced the visible
  list from the cached total/stat ('no events expiring' beside 'view
  all N').

Both are fixed by new useExpiryRefresh: it fires once at the soonest
future expiry (setTimeout, overflow-guarded). The dashboard refetches
its expiring + stats queries — the backend already excludes expired
events, so rows/total/stats come back consistent (filter removed). The
list and detail pages bump a tick so the inline badges recompute. Hooks
are placed above the loading early-returns (rules-of-hooks is disabled
in eslint, so this was a latent crash otherwise).

* fix(admin): expiry-refresh precision + filtered refetch (#909 review round 3)

Three refinements to round-2's live-expiry work:

- useExpiryRefresh now re-arms past setTimeout's ~24.8-day overflow
  limit (capped wake-up that re-evaluates) instead of dropping the timer,
  so a page mounted for weeks still updates.
- The dashboard requests the expiring list ordered by expires_at asc, so
  the five shown rows ARE the soonest to expire — the timer schedules
  against the true next boundary even when >5 events are expiring
  (getEvents gains optional sortBy/sortOrder; backend already whitelists
  expires_at).
- EventsListPage refetches instead of only re-rendering at the boundary:
  under the 'expiring' filter the backend drops expired rows, so a plain
  tick would leave a stale 'Expired' row + total. refetch keeps rows and
  totals correct under every filter.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-07-30 12:14:54 +02:00
Paul Nothaft b32ba1ed6b ci: batch stable releases into one daily version (stable) (#920)
* ci: batch stable releases into one daily version (stable)

The stable release PR was auto-merged the instant it went green, so a
day with N bugfixes produced N patch releases (3.45.8 AND 3.45.9 on
2026-07-29 alone) — N upgrade notifications for stable users and N
full Docker build cycles.

Fixes now accumulate in release-please's rolling release PR and are
cut as ONE version per day by release-stable-daily.yml (18:00 UTC).
Approval/merge mechanics are unchanged from the inline step (#719):
approve as github-actions[bot], auto-merge as the PAT so the merge
triggers the tag-cutting run.

- Urgent fix? workflow_dispatch the daily job or merge the release PR
  by hand — the schedule is a default, not a gate.
- Beta is untouched: instant beta releases are load-bearing for
  same-day reporter verification.
- schedule only fires from the default branch; the stable copy of the
  new workflow is inert and exists to keep branches in sync.

* ci: harden the daily stable-release cut (review round) (stable)

Mirror of the #919 hardening — fork-PR head-name spoof (require
--base stable + same-repo head) and no longer swallowing the
auto-merge-enable failure on the sole automatic stable cut.

* ci: accept an immediately-merged release PR as success (review round 2) (stable)

Mirror of #919: MERGED state = success (the normal 18:00 case where
checks were already green and --auto merges immediately), pending
auto-merge = success, still-open-no-auto-merge = real failure.

* ci: read release-PR state + auto-merge in one snapshot (review round 3) (stable)

Mirror of #919 — collapse the two racing gh pr view calls into one.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-07-30 12:14:31 +02:00
117 changed files with 6538 additions and 398 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
+6 -26
View File
@@ -27,32 +27,12 @@ jobs:
manifest-file: .release-please-manifest.json
target-branch: stable
# Auto-approve + auto-merge the open stable release PR. See the beta
# workflow for the full rationale. Skipped on the release-cutting run and
# whenever no PAT is configured.
- name: Auto-approve and enable auto-merge on the release PR
if: ${{ steps.release.outputs.release_created != 'true' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_PAT: ${{ secrets.RELEASE_PLEASE_TOKEN }}
# No checkout in this job — set the repo explicitly so gh works
# without a git remote (same pattern as whatsnew, 2a5f0a8).
GH_REPO: ${{ github.repository }}
run: |
if [ -z "$RELEASE_PAT" ]; then
echo "RELEASE_PLEASE_TOKEN not set — skipping auto-merge (manual review still required)."
exit 0
fi
pr=$(gh pr list --head release-please--branches--stable --state open --json number --jq '.[0].number // empty')
if [ -n "$pr" ]; then
# Approve as github-actions[bot] (GITHUB_TOKEN, ≠ the PAT author) so it
# is a valid review; enable auto-merge as the PAT so the merge commit is
# attributed to a real identity and triggers the tag-cutting run (#719).
gh pr review "$pr" --approve --body "Automated approval — release-please version bump + changelog (#719)." || true
GH_TOKEN="$RELEASE_PAT" gh pr merge "$pr" --squash --auto || true
else
echo "No open release PR to auto-merge."
fi
# NOTE: stable release PRs are intentionally NOT auto-merged here
# anymore. Fixes accumulate in the rolling release PR and are cut as
# ONE patch version per day by release-stable-daily.yml (18:00 UTC,
# or on demand via workflow_dispatch / a manual merge of the release
# PR). Beta keeps instant releases — see release-please-beta.yml —
# because same-day reporter verification depends on it.
- name: Output Release Info
if: ${{ steps.release.outputs.release_created }}
@@ -0,0 +1,86 @@
name: Cut Stable Release (daily batch)
# Stable fixes accumulate in release-please's rolling release PR instead of
# each cutting its own patch version (the old per-merge auto-merge produced
# e.g. 3.45.8 AND 3.45.9 on the same day). This workflow merges the open
# stable release PR once a day, so a day of N bugfixes ships as ONE version
# with all N changelog entries — and one Docker build instead of N.
#
# - schedule only fires from the default branch (main); the stable copy of
# this file is inert and exists to keep the branches in sync.
# - Need a release NOW? Run this via workflow_dispatch, or merge the
# release PR by hand — the schedule is a default, not a gate.
# - Approval/merge mechanics mirror the old inline step (#719): approve as
# github-actions[bot] (GITHUB_TOKEN, a valid distinct reviewer), enable
# auto-merge as the PAT so the merge attributes to a real identity and
# triggers the tag-cutting run. --auto waits for green checks.
on:
schedule:
- cron: '0 18 * * *'
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
merge-stable-release-pr:
runs-on: ubuntu-latest
steps:
- name: Approve and enable auto-merge on the open stable release PR
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_PAT: ${{ secrets.RELEASE_PLEASE_TOKEN }}
# No checkout — set the repo explicitly so gh works without a
# git remote (same pattern as whatsnew, 2a5f0a8).
GH_REPO: ${{ github.repository }}
run: |
if [ -z "$RELEASE_PAT" ]; then
echo "RELEASE_PLEASE_TOKEN not set — skipping (manual review required)."
exit 0
fi
# Strict selection (review P1): this job runs daily even without a
# stable push, and `gh pr list --head` matches the branch NAME only
# — a fork PR can spoof `release-please--branches--stable`. Pin the
# base to stable AND require a same-repo head (isCrossRepository
# == false); a fork PR is cross-repository, so it can never be
# picked and auto-merged with the privileged PAT.
pr=$(gh pr list \
--base stable \
--head release-please--branches--stable \
--state open \
--json number,isCrossRepository \
--jq '[.[] | select(.isCrossRepository == false)] | .[0].number // empty')
if [ -z "$pr" ]; then
echo "No open same-repo stable release PR — nothing to cut today."
exit 0
fi
# Approve is tolerant — a pre-existing approval already satisfies
# branch protection and re-approving can return non-zero.
gh pr review "$pr" --approve --body "Automated approval — daily stable release batch (release-please version bump + changelog)." || echo "::warning::approve returned non-zero (PR may already be approved)"
# But the auto-merge enable is the load-bearing step: this scheduled
# job is the ONLY automatic stable cut, so DON'T swallow its failure
# (review P2) — an expired/under-scoped PAT would otherwise stop
# releases while the workflow stays green.
GH_TOKEN="$RELEASE_PAT" gh pr merge "$pr" --squash --auto
# `gh pr merge --auto` merges IMMEDIATELY when the required checks
# are already green — the normal case at 18:00, since the fixes
# merged hours earlier and CI passed. So success is EITHER the PR is
# already merged OR an auto-merge request is now pending; only a PR
# that is still open with no auto-merge request is a real failure
# (expired/under-scoped PAT) worth failing the job on (review round 2).
# One snapshot of both fields (review round 3): querying state and
# autoMergeRequest separately races — auto-merge can complete
# between the two calls, so the first sees OPEN and the second sees
# the request already cleared on the now-merged PR → false failure.
read -r state automerge < <(gh pr view "$pr" --json state,autoMergeRequest \
--jq '[.state, (.autoMergeRequest != null)] | @tsv')
if [ "$state" = "MERGED" ]; then
echo "Stable release PR #$pr merged immediately (checks were already green)."
elif [ "$automerge" = "true" ]; then
echo "Auto-merge enabled on stable release PR #$pr — merges when checks are green."
else
echo "::error::stable release PR #$pr is still open with no auto-merge — check RELEASE_PLEASE_TOKEN scope/expiry."
exit 1
fi
+1 -1
View File
@@ -1 +1 @@
{".":"3.45.9"}
{".":"3.45.13"}
+45
View File
@@ -5,6 +5,51 @@ 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.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)
### Bug Fixes
* **security:** authz/ownership gaps (token binding, auth revocation, feedback/customer ownership, token logging) (stable) ([#951](https://github.com/PicPeak/picpeak/issues/951)) ([5d5db4e](https://github.com/PicPeak/picpeak/commit/5d5db4e766eee23a6678b399cdb9cc449fcea198))
* **security:** neutralize spreadsheet formulas in all CSV/export cell-writers (CSV injection cluster) ([#949](https://github.com/PicPeak/picpeak/issues/949)) ([e5dccf1](https://github.com/PicPeak/picpeak/commit/e5dccf166419bb571b052990aada14801e16ac79))
* **security:** redact gallery share tokens from analytics tracking (GHSA-7m6c) (stable) ([#953](https://github.com/PicPeak/picpeak/issues/953)) ([2c7b5df](https://github.com/PicPeak/picpeak/commit/2c7b5dfd020ac1fb2acdb7667998b2f373ce99d9))
* **security:** unauth share_token leak (HIGH) + restore path-traversal, logo file-read (stable) ([#947](https://github.com/PicPeak/picpeak/issues/947)) ([bfafece](https://github.com/PicPeak/picpeak/commit/bfafecedc755790374565281287b9777ae0f5315))
## [3.45.11](https://github.com/PicPeak/picpeak/compare/v3.45.10...v3.45.11) (2026-08-01)
### Bug Fixes
* **security:** block guest access to hidden/client-only photos across bulk + secure routes (stable) ([#940](https://github.com/PicPeak/picpeak/issues/940)) ([34a7b1c](https://github.com/PicPeak/picpeak/commit/34a7b1c0137cf4cba53f17270f619ce9755c4c98))
* **security:** bump sanitize-html to 2.17.5 (CVE-2026-53606) (stable) ([#938](https://github.com/PicPeak/picpeak/issues/938)) ([7419c68](https://github.com/PicPeak/picpeak/commit/7419c683375d12650c448caa47ffe0444a7e1458))
* **security:** close authorization/ownership gaps (token scope, mass-assignment, category hero, project docs) (stable) ([#944](https://github.com/PicPeak/picpeak/issues/944)) ([2462ba6](https://github.com/PicPeak/picpeak/commit/2462ba6897c93b3f0834d60cc2e6827a45487062))
* **security:** resolve DNS before vetting external hostnames (SSRF cluster) (stable) ([#942](https://github.com/PicPeak/picpeak/issues/942)) ([90275f8](https://github.com/PicPeak/picpeak/commit/90275f88e9af523ba3cc254cef2ca6f48a2a4129))
* **uploads:** prevent cross-photo contamination from filename collisions and non-atomic writes ([#931](https://github.com/PicPeak/picpeak/issues/931)) (stable) ([#934](https://github.com/PicPeak/picpeak/issues/934)) ([fc99e2b](https://github.com/PicPeak/picpeak/commit/fc99e2b233b4a7c81c410f7d557e7dff270437bd))
## [3.45.10](https://github.com/PicPeak/picpeak/compare/v3.45.9...v3.45.10) (2026-07-30)
### Bug Fixes
* **admin:** expose view/download counters in the admin photos list ([#895](https://github.com/PicPeak/picpeak/issues/895) follow-up) (stable) ([#915](https://github.com/PicPeak/picpeak/issues/915)) ([a27d19b](https://github.com/PicPeak/picpeak/commit/a27d19b4d147c33166b47a6bac12b8d1be32daf1))
* **admin:** serve videos with their real MIME type in the admin photo view ([#908](https://github.com/PicPeak/picpeak/issues/908)) (stable) ([#911](https://github.com/PicPeak/picpeak/issues/911)) ([d68d84e](https://github.com/PicPeak/picpeak/commit/d68d84e5c8cfcd123c95e474d2b87d153764f710))
* **admin:** stop marking events expired up to 24h early ([#909](https://github.com/PicPeak/picpeak/issues/909)) (stable) ([#917](https://github.com/PicPeak/picpeak/issues/917)) ([6891769](https://github.com/PicPeak/picpeak/commit/6891769124f77d5af1bbe8eb0932a86753d89e7d))
* **security:** close GHSA-g94x (cross-gallery photo read) + GHSA-pv6w (admin DB export) (stable) ([#925](https://github.com/PicPeak/picpeak/issues/925)) ([60cbda5](https://github.com/PicPeak/picpeak/commit/60cbda5b2228e0bddf5356eae279d9e7916e9ac2))
## [3.45.9](https://github.com/PicPeak/picpeak/compare/v3.45.8...v3.45.9) (2026-07-29)
+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,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,238 @@
/**
* Admin photo view route Content-Type (#908).
*
* The route built `image/<ext>` from the filename, producing invalid
* types like image/mp4 for videos. AdminAuthenticatedVideo fetches this
* URL into a blob whose type inherits the header, and browsers refuse to
* play a <video> blob labeled image/* — blank/grey admin video preview.
*
* Pins (incl. external-review hardening):
* - the header is ALWAYS image/* or video/*: a stored non-media MIME
* (chunked uploads store the client-sent type unvalidated) is never
* echoed — text/html inline under the app origin would be XSS
* - stored video/ MIME wins; MIME-less videos map from the extension
* (.mov → video/quicktime), unknown video extensions get video/mp4
* - images IGNORE the stored MIME (migration 039 backfilled image/jpeg
* onto every legacy row, PNGs included) and use the extension,
* normalized (jpg → image/jpeg); extensionless files get image/jpeg
*/
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-admin-ct-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'admin-ct-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-admin-ct-storage-'));
const request = require('supertest');
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const SLUG = 'admin-ct-test-event';
describe('admin photo view Content-Type (#908)', () => {
let db;
let cleanup;
let app;
let eventId;
let adminToken;
const addPhoto = async (filename, extra = {}) => {
const dir = path.join(process.env.STORAGE_PATH, 'events/active', SLUG);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, filename), Buffer.from(`bytes-${filename}`));
const r = await db('photos').insert({
event_id: eventId,
filename,
path: `${SLUG}/${filename}`,
type: 'individual',
uploaded_at: new Date().toISOString(),
...extra,
}).returning('id');
return r[0]?.id ?? r[0];
};
const getPhotoRes = (photoId) => request(app)
.get(`/api/admin/photos/${eventId}/photo/${photoId}`)
.set('Authorization', `Bearer ${adminToken}`);
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const inserted = await db('events').insert({
slug: SLUG,
event_type: 'wedding',
event_name: 'Admin CT 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: 'admin-ct-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 superRole = await db('roles').where({ name: 'super_admin' }).first();
const [rootId] = await db('admin_users').insert({
username: 'admin-ct-admin',
email: 'admin-ct-admin@example.com',
password_hash: await bcrypt.hash('AdminCt123', 4),
role_id: superRole.id,
is_active: 1,
created_at: new Date(),
updated_at: new Date(),
}).returning('id').then((r) => [r[0]?.id || r[0]]);
adminToken = jwt.sign(
{ id: rootId, username: 'admin-ct-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/photos', require('../../src/routes/adminPhotos'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('serves a video with its stored mime_type, not image/<ext>', async () => {
const id = await addPhoto('clip.mp4', { media_type: 'video', mime_type: 'video/mp4' });
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('video/mp4');
});
it('maps MIME-less videos from their extension (.mov → video/quicktime)', async () => {
const id = await addPhoto('clip-nomime.mov', { media_type: 'video' });
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('video/quicktime');
});
it('falls back to video/mp4 for a video with an unknown extension', async () => {
const id = await addPhoto('clip-unknown.xyz', { media_type: 'video' });
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('video/mp4');
});
it('rejects malformed video/ MIME values that would break setHeader', async () => {
// Header-invalid chars in the stored value must not 500 the route —
// fall back to the extension map instead.
const id = await addPhoto('crlf.mp4', {
media_type: 'video',
mime_type: 'video/mp4\r\nX-Evil: 1',
});
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('video/mp4');
expect(res.headers['x-evil']).toBeUndefined();
const bare = await addPhoto('bare.webm', { media_type: 'video', mime_type: 'video/' });
const res2 = await getPhotoRes(bare);
expect(res2.status).toBe(200);
expect(res2.headers['content-type']).toBe('video/webm');
});
it('preserves an auto-imported avif via the safe stored-MIME allowlist', async () => {
// .avif isn't in EXTENSION_TO_MIME; s3AutoImporter stores image/avif.
// Map-only would mislabel it image/jpeg — the allowlist keeps it.
const id = await addPhoto('imported.avif', { mime_type: 'image/avif' });
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('image/avif');
});
it('preserves other importer raster types too (apng, x-icon)', async () => {
const apng = await addPhoto('anim.apng', { mime_type: 'image/apng' });
expect((await getPhotoRes(apng)).headers['content-type']).toBe('image/apng');
const ico = await addPhoto('fav.ico', { mime_type: 'image/x-icon' });
expect((await getPhotoRes(ico)).headers['content-type']).toBe('image/x-icon');
});
it('does NOT honor a stored scriptable image type (image/svg+xml)', async () => {
// svg is inline-scriptable and must never be echoed — allowlist excludes it.
const id = await addPhoto('vector.svg', { mime_type: 'image/svg+xml' });
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('image/jpeg');
});
it('never echoes a stored non-media MIME type (inline XSS guard)', async () => {
const id = await addPhoto('evil.png', { mime_type: 'text/html' });
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('image/png');
});
it('ignores the migration-039 image/jpeg backfill on legacy PNG rows', async () => {
const id = await addPhoto('legacy.png', { mime_type: 'image/jpeg' });
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('image/png');
});
it('normalizes jpg to the canonical image/jpeg', async () => {
const id = await addPhoto('shot.jpg');
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('image/jpeg');
});
it('keeps the extension fallback for images without a stored mime_type', async () => {
const id = await addPhoto('shot.png');
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('image/png');
});
it('handles Object.prototype key extensions without a 500 (.constructor)', async () => {
// The extension-to-MIME lookup must be own-property only — a raw
// index access returns an inherited function for these keys and the
// downstream startsWith throws. Serve image/jpeg instead of 500.
const id = await addPhoto('payload.constructor');
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('image/jpeg');
const id2 = await addPhoto('payload.__proto__', { media_type: 'video' });
const res2 = await getPhotoRes(id2);
expect(res2.status).toBe(200);
expect(res2.headers['content-type']).toBe('video/mp4');
});
it('does not synthesize types from unmapped image extensions', async () => {
// Raw interpolation would produce image/svg+xml (scriptable inline)
// or arbitrary strings from client-controlled filenames — the shared
// map is the allowlist, everything else is served as image/jpeg.
const svg = await addPhoto('vector.svg+xml');
const res = await getPhotoRes(svg);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('image/jpeg');
const weird = await addPhoto('weird.xyz');
const res2 = await getPhotoRes(weird);
expect(res2.status).toBe(200);
expect(res2.headers['content-type']).toBe('image/jpeg');
});
it('extensionless files get image/jpeg, never a bare image/', async () => {
const id = await addPhoto('noext');
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('image/jpeg');
});
});
@@ -0,0 +1,211 @@
/**
* Authorization / ownership gaps (GHSA permission cluster):
* - jm7j: API-token list must scope to the caller (non-super sees only own)
* - gprq: API-token revoke must be owner-or-super_admin
* - 3rqx: event update must not mass-assign identity/secret columns
* - j2f4: category hero must belong to that category
*/
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-authz-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'authz-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-authz-storage-'));
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const bcrypt = require('bcrypt');
const {
bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken,
} = require('../integration/helpers/crmDb');
describe('authorization / ownership gaps', () => {
let db; let cleanup; let app;
let superId; let superTok; let adminId; let adminTok;
const grantPermissionToRole = async (roleName, permName) => {
const role = await db('roles').where({ name: roleName }).first();
const perm = await db('permissions').where({ name: permName }).first();
const exists = await db('role_permissions')
.where({ role_id: role.id, permission_id: perm.id }).first();
if (!exists) {
await db('role_permissions').insert({ role_id: role.id, permission_id: perm.id });
}
};
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ adminId: superId } = await seedMinimal(db));
await assignAdminRole(db, superId, 'super_admin');
superTok = mintAdminToken(superId);
const pass = await bcrypt.hash('x', 4);
const ins = await db('admin_users').insert({
username: 'plain-admin', email: 'plain@example.com',
password_hash: pass, must_change_password: false, created_at: new Date(),
}).returning('id');
adminId = ins[0]?.id ?? ins[0];
await assignAdminRole(db, adminId, 'admin');
// Grant settings.edit to the admin role BEFORE any request populates the
// 60s permission cache, so the revoke test exercises the ownership check
// (404) rather than the missing-permission gate (403). This models a
// custom role that carries settings.edit — the scenario GHSA-gprq needs.
await grantPermissionToRole('admin', 'settings.edit');
adminTok = mintAdminToken(adminId);
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/admin/api-tokens', require('../../src/routes/adminApiTokens'));
app.use('/api/admin/events', require('../../src/routes/adminEvents'));
app.use('/api/admin/categories', require('../../src/routes/adminCategories'));
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
res.status(err.statusCode || err.status || 500).json({ error: err.message, code: err.code });
});
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
const auth = (req, tok) => req.set('Authorization', `Bearer ${tok}`);
describe('API tokens (jm7j / gprq)', () => {
let superTokenId;
beforeAll(async () => {
const res = await auth(request(app).post('/api/admin/api-tokens'), superTok)
.send({ name: 'super-token', scopes: ['read'] });
expect(res.status).toBe(201);
superTokenId = res.body.id;
});
it('non-super admin does not see another admin\'s tokens in the list', async () => {
const res = await auth(request(app).get('/api/admin/api-tokens'), adminTok);
expect(res.status).toBe(200);
expect(res.body.find((t) => t.id === superTokenId)).toBeUndefined();
});
it('super_admin sees all tokens', async () => {
const res = await auth(request(app).get('/api/admin/api-tokens'), superTok);
expect(res.status).toBe(200);
expect(res.body.find((t) => t.id === superTokenId)).toBeDefined();
});
it('a non-owner (with settings.edit) cannot revoke another admin\'s token', async () => {
const res = await auth(request(app).delete(`/api/admin/api-tokens/${superTokenId}`), adminTok);
expect(res.status).toBe(404);
const row = await db('api_tokens').where({ id: superTokenId }).first();
expect(row.revoked_at).toBeFalsy();
});
it('the owner can revoke their own token', async () => {
const res = await auth(request(app).delete(`/api/admin/api-tokens/${superTokenId}`), superTok);
expect(res.status).toBe(200);
});
});
describe('event update mass-assignment (3rqx)', () => {
it('ignores identity/secret columns in the request body', async () => {
const seedShareToken = 'orig-share-token';
const ins = await db('events').insert({
slug: 'authz-mass-assign', event_type: 'wedding', event_name: 'Before',
event_date: '2026-08-01', host_email: 'h@example.com', admin_email: 'a@example.com',
password_hash: 'orig-hash', share_link: '/gallery/authz/share', share_token: seedShareToken, expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0, created_by: superId,
created_at: new Date().toISOString(),
}).returning('id');
const eventId = ins[0]?.id ?? ins[0];
const res = await auth(request(app).put(`/api/admin/events/${eventId}`), superTok).send({
event_name: 'After',
created_by: 99999,
slug: 'hijacked-slug',
share_token: 'hijacked-token',
password_hash: 'hijacked-hash',
is_archived: 1,
archive_path: '/hijacked/archive/path',
hero_logo_path: '/etc/passwd',
is_draft: 1,
project_id: 99999,
// Case-variant keys — SQLite matches columns case-insensitively.
Password_Hash: 'case-hijack-hash',
Created_By: 88888,
});
expect(res.status).toBe(200);
const row = await db('events').where({ id: eventId }).first();
expect(row.event_name).toBe('After'); // legit field applied
expect(row.created_by).toBe(superId); // ownership untouched (+ case-variant)
expect(row.slug).toBe('authz-mass-assign'); // routing identity untouched
expect(row.share_token).toBe(seedShareToken); // secret untouched
expect(row.password_hash).toBe('orig-hash'); // secret untouched (+ case-variant)
expect(row.is_archived).toBeFalsy(); // archive lifecycle untouched
expect(row.archive_path).toBeFalsy(); // forged archive path rejected
expect(row.hero_logo_path).toBeFalsy(); // fs.unlink primitive blocked
expect(row.is_draft).toBeFalsy(); // publish workflow not bypassed
expect(row.project_id).toBeFalsy(); // server-managed relationship untouched
});
it('returns 200 (no-op) when the body contains only protected fields', async () => {
const ins = await db('events').insert({
slug: 'authz-empty-update', event_type: 'wedding', event_name: 'Keep',
event_date: '2026-08-01', host_email: 'h@example.com', admin_email: 'a@example.com',
password_hash: 'x', share_link: '/gallery/authz-empty/share', share_token: 'authz-empty-share',
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0, created_by: superId,
created_at: new Date().toISOString(),
}).returning('id');
const id = ins[0]?.id ?? ins[0];
// Body reduces to {} after the denylist — must not 500 (Knex rejects
// .update({})).
const res = await auth(request(app).put(`/api/admin/events/${id}`), superTok)
.send({ created_by: 1, slug: 'x', is_archived: 1 });
expect(res.status).toBe(200);
const row = await db('events').where({ id }).first();
expect(row.event_name).toBe('Keep');
});
});
describe('category hero cross-category (j2f4)', () => {
it('rejects a hero photo that is not in the category', async () => {
const evIns = await db('events').insert({
slug: 'authz-cat', event_type: 'wedding', event_name: 'Cat Event',
event_date: '2026-08-01', host_email: 'h@example.com', admin_email: 'a@example.com',
password_hash: 'x', share_link: '/gallery/authz-cat/share', share_token: 'authz-cat-share', expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0, created_by: superId,
created_at: new Date().toISOString(),
}).returning('id');
const evId = evIns[0]?.id ?? evIns[0];
const mkCat = async (name) => {
const c = await db('photo_categories').insert({
event_id: evId, name, slug: name.toLowerCase(), created_at: new Date().toISOString(),
}).returning('id');
return c[0]?.id ?? c[0];
};
const cat1 = await mkCat('Cat1');
const cat2 = await mkCat('Cat2');
const pIns = await db('photos').insert({
event_id: evId, filename: 'p.jpg', path: 'authz-cat/p.jpg', type: 'individual',
category_id: cat1, uploaded_at: new Date().toISOString(),
}).returning('id');
const photoInCat1 = pIns[0]?.id ?? pIns[0];
// Pointing cat2's hero at a photo that lives in cat1 must be refused.
const bad = await auth(request(app).put(`/api/admin/categories/${cat2}/hero`), superTok)
.send({ hero_photo_id: photoInCat1 });
expect(bad.status).toBe(404);
// The photo's own category accepts it.
const ok = await auth(request(app).put(`/api/admin/categories/${cat1}/hero`), superTok)
.send({ hero_photo_id: photoInCat1 });
expect(ok.status).toBe(200);
});
});
});
@@ -0,0 +1,95 @@
/**
* Full-instance export is super_admin only (GHSA-pv6w-rj34-wj9v).
*
* GET /api/admin/backup/picpeak/export dumps every table unredacted (bcrypt
* hashes, 2FA, SMTP/SSO/WhatsApp/webhook/S3 secrets). It was gated only by
* requirePermission('backup.create'), which the built-in `admin` role holds —
* so any non-super_admin admin could download the whole database. Pins that
* `admin` now gets 403 and `super_admin` passes the gate.
*/
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-bkexport-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'bkexport-test-secret';
// The export otherwise walks the whole DB and writes a zip — stub it so the
// super_admin happy path is fast and deterministic; the gate is what's tested.
// The route deletes path.dirname(filePath) recursively after download, so the
// stub MUST live in its own dir — a bare os.tmpdir() file would make the route
// wipe the whole temp root (and other jest workers' DB files).
const mockExportDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-export-stub-'));
const mockExportPath = path.join(mockExportDir, 'export.picpeak');
fs.writeFileSync(mockExportPath, 'stub');
jest.mock('../../src/services/picpeakExportService', () => ({
createPicpeak: jest.fn(async () => ({ filePath: mockExportPath })),
}));
const request = require('supertest');
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
describe('backup export super_admin gate (GHSA-pv6w)', () => {
let db;
let cleanup;
let app;
let adminToken; let superToken;
const mkUser = 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 jwt.sign(
{ id, username, type: 'admin', role: roleName, loginTime: Date.now() },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' },
);
};
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
adminToken = await mkUser('limited-admin', 'admin');
superToken = await mkUser('root-admin', 'super_admin');
app = express();
app.use(express.json());
app.use('/api/admin/backup', require('../../src/routes/adminBackup'));
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
fs.rmSync(mockExportDir, { recursive: true, force: true });
});
it('denies the built-in admin role (was: full DB dump)', async () => {
const res = await request(app)
.get('/api/admin/backup/picpeak/export')
.set('Authorization', `Bearer ${adminToken}`);
expect(res.status).toBe(403);
});
it('allows super_admin', async () => {
const res = await request(app)
.get('/api/admin/backup/picpeak/export')
.set('Authorization', `Bearer ${superToken}`);
expect(res.status).not.toBe(403);
expect(res.status).toBeLessThan(500);
});
});
@@ -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,101 @@
/**
* GHSA-2qc2 / GHSA-32h4 / GHSA-3335 — feedback moderation, deletion, and the
* pending-moderation list are by-feedback-id (or global) and lacked ownership
* scoping, so a restricted editor could act on / enumerate feedback for events
* it does not own. super_admin keeps global access.
*/
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-fbown-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'fbown-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-fbown-storage-'));
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const bcrypt = require('bcrypt');
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
describe('feedback ownership scoping', () => {
let db; let cleanup; let app;
let superTok; let editorTok; let editorId;
let foreignFeedbackId;
const auth = (req, tok) => req.set('Authorization', `Bearer ${tok}`);
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
const { adminId: superId } = await seedMinimal(db);
await assignAdminRole(db, superId, 'super_admin');
superTok = mintAdminToken(superId);
const ins = await db('admin_users').insert({
username: 'editor', email: 'editor@example.com',
password_hash: await bcrypt.hash('x', 4), must_change_password: false, created_at: new Date(),
}).returning('id');
editorId = ins[0]?.id ?? ins[0];
await assignAdminRole(db, editorId, 'editor');
editorTok = mintAdminToken(editorId);
// Event owned by super_admin (NOT the editor).
const ev = await db('events').insert({
slug: 'fbown-foreign', event_type: 'wedding', event_name: 'Foreign',
event_date: '2026-08-01', host_email: 'h@e.com', admin_email: 'a@e.com',
password_hash: 'x', share_link: '/g/fbown/s', share_token: 'fbown-share',
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0, created_by: superId,
created_at: new Date().toISOString(),
}).returning('id');
const eventId = ev[0]?.id ?? ev[0];
const ph = await db('photos').insert({
event_id: eventId, filename: 'p.jpg', path: 'fbown-foreign/p.jpg', type: 'individual',
uploaded_at: new Date().toISOString(),
}).returning('id');
const photoId = ph[0]?.id ?? ph[0];
const fb = await db('photo_feedback').insert({
photo_id: photoId, event_id: eventId, feedback_type: 'comment',
comment_text: 'hi', is_approved: 0, is_hidden: 0, created_at: new Date().toISOString(),
}).returning('id');
foreignFeedbackId = fb[0]?.id ?? fb[0];
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/admin/feedback', require('../../src/routes/adminFeedback'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('editor cannot moderate feedback on an event it does not own (404)', async () => {
const res = await auth(request(app).put(`/api/admin/feedback/feedback/${foreignFeedbackId}/approve`), editorTok);
expect(res.status).toBe(404);
const row = await db('photo_feedback').where({ id: foreignFeedbackId }).first();
expect([false, 0]).toContain(row.is_approved); // untouched
});
it('editor cannot delete foreign feedback, row survives', async () => {
const res = await auth(request(app).delete(`/api/admin/feedback/feedback/${foreignFeedbackId}`), editorTok);
// Denied either at the events.delete permission layer (editor lacks it →
// 403) or the ownership layer (404) — both must leave the row intact.
expect([403, 404]).toContain(res.status);
expect(await db('photo_feedback').where({ id: foreignFeedbackId }).first()).toBeDefined();
});
it('editor sees no foreign feedback in pending-moderation', async () => {
const res = await auth(request(app).get('/api/admin/feedback/feedback/pending-moderation'), editorTok);
expect(res.status).toBe(200);
expect(res.body.find((f) => f.id === foreignFeedbackId)).toBeUndefined();
});
it('super_admin CAN moderate and see it', async () => {
const pending = await auth(request(app).get('/api/admin/feedback/feedback/pending-moderation'), superTok);
expect(pending.body.find((f) => f.id === foreignFeedbackId)).toBeDefined();
const res = await auth(request(app).put(`/api/admin/feedback/feedback/${foreignFeedbackId}/approve`), superTok);
expect(res.status).toBe(200);
});
});
@@ -0,0 +1,87 @@
/**
* GHSA-rh8r-7x3h-36rv — the unauthenticated GET /api/gallery/resolve/:identifier
* must NOT return a gallery's secret share_token (nor the share links that
* embed it) for a bare *slug* lookup. Slugs appear in gallery URLs and are
* guessable; handing back the secret turns a known slug into share-link
* access to a no-password gallery. The token is only returned when the caller
* resolved via the token / full share link (i.e. already holds 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-resolve-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'resolve-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-resolve-storage-'));
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const SLUG = 'resolve-test-event';
const SHARE_TOKEN = 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6';
describe('GET /api/gallery/resolve/:identifier (GHSA-rh8r)', () => {
let db; let cleanup; let app;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
await db('events').insert({
slug: SLUG,
event_type: 'wedding',
event_name: 'Resolve Test',
event_date: '2026-08-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `/gallery/${SLUG}/${SHARE_TOKEN}`,
share_token: SHARE_TOKEN,
require_password: 0, // no-password → the token IS the access credential
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0,
created_at: new Date().toISOString(),
});
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('does NOT leak the share_token (or share links) for a bare slug lookup', async () => {
const res = await request(app).get(`/api/gallery/resolve/${SLUG}`);
expect(res.status).toBe(200);
expect(res.body.slug).toBe(SLUG);
expect(res.body.matchType).toBe('slug');
// The secret must be absent — and must not sneak out via the share links.
expect(res.body.token).toBeUndefined();
expect(res.body.share_link).toBeUndefined();
expect(res.body.share_url).toBeUndefined();
expect(JSON.stringify(res.body)).not.toContain(SHARE_TOKEN);
});
it('DOES return the token when the caller already resolved via the token', async () => {
const res = await request(app).get(`/api/gallery/resolve/${SHARE_TOKEN}`);
expect(res.status).toBe(200);
expect(res.body.token).toBe(SHARE_TOKEN);
expect(res.body.matchType).toMatch(/token/);
});
it('does NOT leak the token via SQL LIKE wildcards in the link_partial fallback', async () => {
// Before the escaping fix, an anonymous request of 32 underscores matched
// any share_link ending in a 32-char token (`_` = single-char wildcard),
// resolved as matchType 'link_partial', and handed back the bearer token.
// The share_token here has no underscores, so an escaped LIKE must miss.
const res = await request(app).get(`/api/gallery/resolve/${'_'.repeat(SHARE_TOKEN.length)}`);
expect(res.status).toBe(404);
expect(res.body.token).toBeUndefined();
expect(JSON.stringify(res.body)).not.toContain(SHARE_TOKEN);
});
});
@@ -0,0 +1,240 @@
/**
* Hidden/client-only photo access control across the bulk + secure photo
* routes (GHSA cluster: fpwq / ghf8 / 3jvw / 9cc4 / 2hqg / jc22).
*
* A photo with visibility='hidden' is client-only. The main photo-list and
* single-photo download/view routes enforced this, but the bulk-download,
* protected-image, and secure-image routes shipped without the check —
* letting an ordinary guest reach hidden photos. These tests pin that
* guests are refused and PIN-clients (accessLevel='client') still succeed.
*/
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-hidden-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'hidden-photo-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-hidden-storage-'));
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const SLUG = 'hidden-photo-test-event';
describe('hidden-photo access control (GHSA cluster)', () => {
let db;
let cleanup;
let app;
let eventId;
let visibleId;
let hiddenId;
const guestToken = () => jwt.sign(
{ eventId, eventSlug: SLUG, type: 'gallery' },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
const clientToken = () => jwt.sign(
{ eventId, eventSlug: SLUG, type: 'gallery', accessLevel: 'client' },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const inserted = await db('events').insert({
slug: SLUG,
event_type: 'wedding',
event_name: 'Hidden Photo 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: 'hidden-photo-share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0, allow_downloads: 1,
created_at: new Date().toISOString(),
}).returning('id');
eventId = inserted[0]?.id ?? inserted[0];
const photoDir = path.join(process.env.STORAGE_PATH, 'events/active', SLUG);
fs.mkdirSync(photoDir, { recursive: true });
// A real 1x1 PNG so the protected /view route's Sharp processing path
// succeeds (fake bytes 500 on metadata()). Content, not extension,
// drives Sharp's format detection.
const PNG_1x1 = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M8AAAMCAQGV2rY9AAAAAElFTkSuQmCC',
'base64'
);
const mkPhoto = async (filename, visibility) => {
fs.writeFileSync(path.join(photoDir, filename), PNG_1x1);
const p = await db('photos').insert({
event_id: eventId,
filename,
path: `${SLUG}/${filename}`,
type: 'individual',
visibility,
uploaded_at: new Date().toISOString(),
}).returning('id');
return p[0]?.id ?? p[0];
};
visibleId = await mkPhoto('visible.jpg', 'visible');
hiddenId = await mkPhoto('hidden.jpg', 'hidden');
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/gallery', require('../../src/routes/gallery'));
app.use('/api/images', require('../../src/routes/protectedImages'));
app.use('/api/secure-images', require('../../src/routes/secureImages'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
describe('download-selected (GHSA-ghf8, medium)', () => {
it('omits a hidden photo for a guest even when its id is requested', async () => {
const res = await request(app)
.post(`/api/gallery/${SLUG}/download-selected`)
.set('Authorization', `Bearer ${guestToken()}`)
.send({ photo_ids: [visibleId, hiddenId] });
// The visible photo still zips; the hidden one is filtered out. If
// only the hidden id were requested, the filter empties the set → 404.
expect(res.status).toBe(200);
const solo = await request(app)
.post(`/api/gallery/${SLUG}/download-selected`)
.set('Authorization', `Bearer ${guestToken()}`)
.send({ photo_ids: [hiddenId] });
expect(solo.status).toBe(404);
});
it('includes the hidden photo for a client', async () => {
const res = await request(app)
.post(`/api/gallery/${SLUG}/download-selected`)
.set('Authorization', `Bearer ${clientToken()}`)
.send({ photo_ids: [hiddenId] });
expect(res.status).toBe(200);
});
});
describe('download-all (GHSA-fpwq, medium)', () => {
it('streams for a guest without erroring (hidden photos filtered)', async () => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/download-all`)
.set('Authorization', `Bearer ${guestToken()}`);
expect(res.status).toBe(200);
});
});
describe('protected-image view (GHSA-9cc4)', () => {
it('403s a hidden photo for a guest', async () => {
const res = await request(app)
.get(`/api/images/${SLUG}/photo/${hiddenId}/view`)
.set('Authorization', `Bearer ${guestToken()}`);
expect(res.status).toBe(403);
});
it('serves a visible photo for a guest', async () => {
const res = await request(app)
.get(`/api/images/${SLUG}/photo/${visibleId}/view`)
.set('Authorization', `Bearer ${guestToken()}`);
expect(res.status).toBe(200);
});
it('serves a hidden photo for a client', async () => {
const res = await request(app)
.get(`/api/images/${SLUG}/photo/${hiddenId}/view`)
.set('Authorization', `Bearer ${clientToken()}`);
expect(res.status).toBe(200);
});
});
describe('signed-URL mint (GHSA-3jvw)', () => {
it('403s minting a signed URL for a hidden photo as a guest', async () => {
const res = await request(app)
.post(`/api/images/${SLUG}/photo/${hiddenId}/generate-url`)
.set('Authorization', `Bearer ${guestToken()}`);
expect(res.status).toBe(403);
});
it('mints for a client', async () => {
const res = await request(app)
.post(`/api/images/${SLUG}/photo/${hiddenId}/generate-url`)
.set('Authorization', `Bearer ${clientToken()}`);
expect(res.status).toBe(200);
expect(res.body.url).toContain('/signed/');
});
});
describe('legacy secure-token mint (protectedImages generate-secure-token)', () => {
it('403s a hidden photo for a guest', async () => {
const res = await request(app)
.post(`/api/images/${SLUG}/photo/${hiddenId}/generate-secure-token`)
.set('Authorization', `Bearer ${guestToken()}`);
expect(res.status).toBe(403);
});
it('mints for a client', async () => {
const res = await request(app)
.post(`/api/images/${SLUG}/photo/${hiddenId}/generate-secure-token`)
.set('Authorization', `Bearer ${clientToken()}`);
expect(res.status).toBe(200);
expect(res.body.token).toBeDefined();
});
});
describe('secure-token mint (GHSA-2hqg)', () => {
it('403s minting a secure token for a hidden photo as a guest', async () => {
const res = await request(app)
.post(`/api/secure-images/${SLUG}/generate-token`)
.set('Authorization', `Bearer ${guestToken()}`)
.send({ photoId: hiddenId });
expect(res.status).toBe(403);
});
it('mints for a client', async () => {
const res = await request(app)
.post(`/api/secure-images/${SLUG}/generate-token`)
.set('Authorization', `Bearer ${clientToken()}`)
.send({ photoId: hiddenId });
expect(res.status).toBe(200);
expect(res.body.token).toBeDefined();
});
});
// A capability minted while a photo is visible must stop serving once the
// photo is hidden — unless minted by a client (clientBypass in the token).
describe('signed-URL TOCTOU (hidden AFTER minting)', () => {
afterEach(async () => {
await db('photos').where({ id: visibleId }).update({ visibility: 'visible' });
});
it("a guest's pre-minted signed URL stops serving once the photo is hidden", async () => {
const mint = await request(app)
.post(`/api/images/${SLUG}/photo/${visibleId}/generate-url`)
.set('Authorization', `Bearer ${guestToken()}`);
expect(mint.status).toBe(200);
const url = mint.body.url;
// Still visible → serves.
expect((await request(app).get(url)).status).toBe(200);
// Hide it → the guest token (no clientBypass) must now be refused.
await db('photos').where({ id: visibleId }).update({ visibility: 'hidden' });
expect((await request(app).get(url)).status).toBe(403);
});
it("a client's pre-minted signed URL keeps serving after the photo is hidden", async () => {
const mint = await request(app)
.post(`/api/images/${SLUG}/photo/${visibleId}/generate-url`)
.set('Authorization', `Bearer ${clientToken()}`);
expect(mint.status).toBe(200);
const url = mint.body.url;
await db('photos').where({ id: visibleId }).update({ visibility: 'hidden' });
expect((await request(app).get(url)).status).toBe(200);
});
});
});
@@ -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) });
});
});
@@ -52,7 +52,7 @@ describe('photo engagement counters (#895)', () => {
const getPhoto = async (id) => db('photos').where('id', id).first();
// The counter writes are fire-and-forget on purpose — give the event
// loop a beat before asserting.
const settle = () => new Promise((r) => setTimeout(r, 100));
const settle = () => new Promise((r) => setTimeout(r, 400));
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
@@ -115,6 +115,7 @@ describe('photo engagement counters (#895)', () => {
app.use(cookieParser());
app.use('/api/gallery', require('../../src/routes/gallery'));
app.use('/api/admin/events', require('../../src/routes/adminEvents'));
app.use('/api/admin/photos', require('../../src/routes/adminPhotos'));
}, 120000);
afterAll(async () => {
@@ -200,6 +201,13 @@ describe('photo engagement counters (#895)', () => {
// Own event so the on-the-fly archiver path is guaranteed — the
// main event may have a cached zip from the previous test's
// background generation, and racing its build/invalidate hangs.
// The route also fires a background pre-zip build after streaming;
// against this event's intentionally missing file it crashes with
// an async ENOENT that jest attributes to whatever test is running
// by then — neutralize it, it's not under test here.
const downloadZipService = require('../../src/services/downloadZipService');
const generateZipSpy = jest.spyOn(downloadZipService, 'generateZip')
.mockResolvedValue({ success: false, error: 'disabled in test' });
const slug2 = `${SLUG}-skip`;
const ev = await db('events').insert({
slug: slug2,
@@ -248,6 +256,33 @@ describe('photo engagement counters (#895)', () => {
expect((await db('photos').where('id', ids2[0]).first()).download_count).toBe(1);
// photo-1's source was missing → skipped from the zip → not counted
expect((await db('photos').where('id', ids2[1]).first()).download_count).toBe(0);
generateZipSpy.mockRestore();
});
});
describe('admin photos list exposes the counters (#895 follow-up)', () => {
it('returns view_count and download_count so the Engagement column can render them', async () => {
// The list mapper builds an explicit object — before this fix it
// omitted both fields, so the admin table showed 0 forever even
// though the DB counted correctly.
await request(app)
.post(`/api/gallery/${SLUG}/photo/${photoIds[0]}/view`)
.set('Authorization', `Bearer ${galleryToken()}`);
await request(app)
.get(`/api/gallery/${SLUG}/download/${photoIds[0]}`)
.set('Authorization', `Bearer ${galleryToken()}`);
await settle();
const res = await request(app)
.get(`/api/admin/photos/${eventId}/photos`)
.set('Authorization', `Bearer ${adminToken}`);
expect(res.status).toBe(200);
const row = res.body.photos.find((p) => p.id === photoIds[0]);
expect(row.view_count).toBe(1);
expect(row.download_count).toBe(1);
const untouched = res.body.photos.find((p) => p.id === photoIds[1]);
expect(untouched.view_count).toBe(0);
expect(untouched.download_count).toBe(0);
});
});
@@ -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,136 @@
/**
* Secure-image view route token binding (GHSA-g94x-8vv8-3c9f).
*
* The view route GET /api/secure-images/:slug/secure/:photoId/:token serves
* via <img src> with the token in the URL, so it can't carry a gallery-token
* header like the download sibling. Before the fix it validated only the
* token signature and took the gallery/photo from the URL — so a token minted
* on any PUBLIC gallery read every other gallery's photos with no password.
*
* Pins that the route now enforces the scope inside the token:
* - the URL photoId must equal the token's minted photoId
* - the gallery embedded in the token's sessionId must equal the URL gallery
* A token minted on gallery A cannot read gallery B under either check; a
* token used on its own gallery+photo passes the binding.
*/
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-secimg-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'secimg-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-secimg-storage-'));
// Stub the anti-bot/rate-limit middleware so the fingerprint is deterministic
// — the token below is minted with the same fingerprint, so verifySecureToken
// passes and the binding logic under test is what decides the outcome.
jest.mock('../../src/middleware/secureImageMiddleware', () => ({
secureImageAccess: (req, _res, next) => {
req.clientInfo = { fingerprint: 'test-fp', ip: '127.0.0.1', userAgent: 'jest' };
next();
},
getSecurityStatus: (_req, res) => res.json({ ok: true }),
}));
const request = require('supertest');
const express = require('express');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const secureImageService = require('../../src/services/secureImageService');
describe('secure-image view route token binding (GHSA-g94x)', () => {
let db;
let cleanup;
let app;
let galleryA; let galleryB;
let photoA; let photoB;
const mkEvent = async (slug, requirePassword) => {
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',
require_password: requirePassword ? 1 : 0,
share_link: `/gallery/${slug}/share`,
share_token: `${slug}-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');
return r[0]?.id ?? r[0];
};
const mkPhoto = async (eventId, slug, filename) => {
const dir = path.join(process.env.STORAGE_PATH, 'events/active', slug);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, filename), Buffer.from('img'));
const r = await db('photos').insert({
event_id: eventId,
filename,
path: `${slug}/${filename}`,
type: 'individual',
uploaded_at: new Date().toISOString(),
}).returning('id');
return r[0]?.id ?? r[0];
};
// Mint a token exactly as the mint route does — bound to (photoId, gallery
// sessionId, fingerprint) — bypassing the anti-bot HTTP path.
const mint = (photoId, eventId) => secureImageService.generateSecureToken(
photoId,
`gallery_public_${eventId}_${Date.now()}`,
{ clientFingerprint: 'test-fp', maxUses: 100, expiresIn: 3600 },
);
const view = (slug, photoId, token) => request(app)
.get(`/api/secure-images/${slug}/secure/${photoId}/${token}`);
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
galleryA = await mkEvent('secimg-public-a', false); // public — token source
galleryB = await mkEvent('secimg-private-b', true); // password-protected — victim
photoA = await mkPhoto(galleryA, 'secimg-public-a', 'a.jpg');
photoB = await mkPhoto(galleryB, 'secimg-private-b', 'b.jpg');
app = express();
app.use(express.json());
app.use('/api/secure-images', require('../../src/routes/secureImages'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('rejects a gallery-A token used against gallery B (cross-photo)', async () => {
const token = mint(photoA, galleryA);
const res = await view('secimg-private-b', photoB, token);
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/not valid for this photo/i);
});
it('rejects a gallery-A token replayed on gallery B with A\'s photoId', async () => {
const token = mint(photoA, galleryA);
// URL photoId matches the token, so the photo check passes — the gallery
// check (sessionId gallery A != URL gallery B) must catch it.
const res = await view('secimg-private-b', photoA, token);
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/not valid for this gallery/i);
});
it('lets a token read its own gallery + photo (binding passes)', async () => {
const token = mint(photoA, galleryA);
const res = await view('secimg-public-a', photoA, token);
// Binding passes; serving may 200/404/500 depending on the pipeline, but
// it must NOT be rejected as a token mismatch.
expect(res.status).not.toBe(403);
});
});
@@ -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,138 @@
/**
* 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' });
});
});
@@ -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');
});
});
Binary file not shown.
@@ -0,0 +1,128 @@
/**
* DNS-resolving SSRF guard (GHSA SSRF cluster: webhook / S3 / rsync / SMTP /
* IMAP). The literal isPrivateIP check can't see that a public-looking
* hostname resolves to an internal/metadata IP; isHostAllowed resolves the
* name and vets every A/AAAA record.
*/
jest.mock('dns', () => {
const actual = jest.requireActual('dns');
return { ...actual, promises: { ...actual.promises, lookup: jest.fn() } };
});
const dns = require('dns');
const {
isHostAllowed,
validateExternalUrlAsync,
classifyHost,
} = require('../../src/utils/networkValidation');
const lookup = dns.promises.lookup;
describe('classifyHost', () => {
beforeEach(() => lookup.mockReset());
it('distinguishes private, unresolved, ok, and invalid', async () => {
lookup.mockResolvedValue([{ address: '10.0.0.5', family: 4 }]);
expect(await classifyHost('evil.example')).toBe('private');
lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
expect(await classifyHost('example.com')).toBe('ok');
lookup.mockRejectedValue(new Error('EAI_AGAIN'));
expect(await classifyHost('blip.example')).toBe('unresolved');
lookup.mockResolvedValue([]);
expect(await classifyHost('empty.example')).toBe('unresolved');
expect(await classifyHost('')).toBe('invalid');
expect(await classifyHost('10.0.0.1')).toBe('private'); // literal, no lookup
});
});
describe('isHostAllowed', () => {
beforeEach(() => lookup.mockReset());
it('rejects a public hostname that resolves to a private IP', async () => {
lookup.mockResolvedValue([{ address: '10.0.0.5', family: 4 }]);
expect(await isHostAllowed('evil.example.com')).toBe(false);
});
it('rejects when the hostname resolves to the cloud metadata IP', async () => {
lookup.mockResolvedValue([{ address: '169.254.169.254', family: 4 }]);
expect(await isHostAllowed('metadata-rebind.example')).toBe(false);
});
it('rejects when ANY resolved address is private (rebinding / mixed records)', async () => {
lookup.mockResolvedValue([
{ address: '93.184.216.34', family: 4 },
{ address: '169.254.169.254', family: 4 },
]);
expect(await isHostAllowed('rebind.example')).toBe(false);
});
it('allows a hostname that resolves only to public IPs', async () => {
lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
expect(await isHostAllowed('example.com')).toBe(true);
});
it('fails closed when resolution errors', async () => {
lookup.mockRejectedValue(new Error('ENOTFOUND'));
expect(await isHostAllowed('nxdomain.invalid')).toBe(false);
});
it('fails closed on an empty resolution', async () => {
lookup.mockResolvedValue([]);
expect(await isHostAllowed('empty.example')).toBe(false);
});
it('rejects literal private IPs and blocked names without resolving', async () => {
expect(await isHostAllowed('127.0.0.1')).toBe(false);
expect(await isHostAllowed('10.0.0.1')).toBe(false);
expect(await isHostAllowed('localhost')).toBe(false);
expect(await isHostAllowed('metadata.google.internal')).toBe(false);
expect(await isHostAllowed('foo.internal')).toBe(false);
expect(lookup).not.toHaveBeenCalled();
});
it('allows a public IP literal without resolving', async () => {
expect(await isHostAllowed('93.184.216.34')).toBe(true);
expect(lookup).not.toHaveBeenCalled();
});
it('rejects empty / non-string input', async () => {
expect(await isHostAllowed('')).toBe(false);
expect(await isHostAllowed(null)).toBe(false);
});
});
describe('validateExternalUrlAsync', () => {
beforeEach(() => lookup.mockReset());
it('rejects a URL whose host resolves to a private address', async () => {
lookup.mockResolvedValue([{ address: '10.1.2.3', family: 4 }]);
const r = await validateExternalUrlAsync('https://evil.example/hook');
expect(r.valid).toBe(false);
});
it('accepts a URL whose host resolves public', async () => {
lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
expect((await validateExternalUrlAsync('https://example.com/hook')).valid).toBe(true);
});
it('rejects a malformed URL', async () => {
expect((await validateExternalUrlAsync('not a url')).valid).toBe(false);
});
it('reports reason=unresolved for a transient lookup failure (retryable)', async () => {
lookup.mockRejectedValue(new Error('EAI_AGAIN'));
const r = await validateExternalUrlAsync('https://blip.example/hook');
expect(r.valid).toBe(false);
expect(r.reason).toBe('unresolved');
});
it('reports reason=private for a resolved-private host (permanent)', async () => {
lookup.mockResolvedValue([{ address: '169.254.169.254', family: 4 }]);
const r = await validateExternalUrlAsync('https://rebind.example/hook');
expect(r.valid).toBe(false);
expect(r.reason).toBe('private');
});
});
@@ -0,0 +1,40 @@
/**
* Unit tests for the shared hidden-photo access-control helper.
*
* Pins the rule that ordinary gallery guests never receive photos with
* visibility='hidden' (NULL = visible), while PIN-clients see everything.
*/
const {
canSeeHiddenPhotos,
isPhotoHiddenFromViewer,
} = require('../../src/utils/photoVisibility');
describe('canSeeHiddenPhotos', () => {
it('is true only for the client access level', () => {
expect(canSeeHiddenPhotos('client')).toBe(true);
expect(canSeeHiddenPhotos('guest')).toBe(false);
expect(canSeeHiddenPhotos('slideshow')).toBe(false);
expect(canSeeHiddenPhotos(undefined)).toBe(false);
});
});
describe('isPhotoHiddenFromViewer', () => {
it('blocks a hidden photo from guests', () => {
expect(isPhotoHiddenFromViewer({ visibility: 'hidden' }, 'guest')).toBe(true);
expect(isPhotoHiddenFromViewer({ visibility: 'hidden' }, 'slideshow')).toBe(true);
});
it('lets clients see hidden photos', () => {
expect(isPhotoHiddenFromViewer({ visibility: 'hidden' }, 'client')).toBe(false);
});
it('treats visible and NULL visibility as viewable by everyone', () => {
expect(isPhotoHiddenFromViewer({ visibility: 'visible' }, 'guest')).toBe(false);
expect(isPhotoHiddenFromViewer({ visibility: null }, 'guest')).toBe(false);
expect(isPhotoHiddenFromViewer({}, 'guest')).toBe(false);
});
it('is null-safe', () => {
expect(isPhotoHiddenFromViewer(null, 'guest')).toBe(false);
});
});
@@ -99,10 +99,22 @@ describe('resolveLogoFile', () => {
}
});
it('treats absolute paths as-is when they exist', async () => {
it('rejects an absolute path OUTSIDE the storage roots (GHSA-c7x5)', async () => {
// The raw-absolute candidate was an arbitrary-file-read primitive
// (logo_path: '/etc/passwd' → rasterised into a PDF). Absolute paths
// outside the storage roots are now dropped even if they exist.
existsSpy.mockImplementation((p) => p === '/abs/path/logo.png');
getAppSetting.mockResolvedValue(null);
const out = await resolveLogoFile({ logo_path: '/abs/path/logo.png' });
expect(out).toBe('/abs/path/logo.png');
expect(out).toBeNull();
});
it('still accepts an absolute path INSIDE the storage root', async () => {
// The legitimate case: multer stores the uploaded logo under
// storage/uploads/logos with an absolute path — that stays resolvable.
existsSpy.mockImplementation((p) => p === '/app/storage/uploads/logos/logo.png');
getAppSetting.mockResolvedValue(null);
const out = await resolveLogoFile({ logo_path: '/app/storage/uploads/logos/logo.png' });
expect(out).toBe('/app/storage/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() {};
+35 -39
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-backend",
"version": "3.45.6",
"version": "3.45.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-backend",
"version": "3.45.6",
"version": "3.45.10",
"dependencies": {
"@aws-sdk/client-s3": "^3.850.0",
"@aws-sdk/lib-storage": "^3.850.0",
@@ -42,13 +42,14 @@
"node-stream-zip": "^1.15.0",
"nodemailer": "^9.0.1",
"otplib": "^12.0.1",
"p-limit": "^3.1.0",
"pdf-lib": "^1.17.1",
"pdfkit": "^0.17.2",
"pg": "^8.16.3",
"postcss": "8.5.18",
"qrcode": "^1.5.4",
"react-i18next": "^15.6.0",
"sanitize-html": "^2.17.0",
"sanitize-html": "2.17.5",
"sharp": "0.35.3",
"sqlite3": "^5.1.6",
"swagger-jsdoc": "^6.2.8",
@@ -5226,6 +5227,12 @@
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
"license": "MIT"
},
"node_modules/dayjs": {
"version": "1.11.21",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
"integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -6785,19 +6792,7 @@
"url": "https://github.com/sponsors/KillyMXI"
}
},
"node_modules/html-to-text/node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/html-to-text/node_modules/htmlparser2": {
"node_modules/htmlparser2": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
@@ -6816,23 +6811,16 @@
"entities": "^7.0.1"
}
},
"node_modules/htmlparser2": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.0.1",
"entities": "^4.4.0"
"node_modules/htmlparser2/node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/http-cache-semantics": {
@@ -8232,6 +8220,15 @@
"integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==",
"license": "MIT"
},
"node_modules/launder": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/launder/-/launder-1.7.1.tgz",
"integrity": "sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==",
"license": "MIT",
"dependencies": {
"dayjs": "^1.11.7"
}
},
"node_modules/lazystream": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz",
@@ -9558,7 +9555,6 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"yocto-queue": "^0.1.0"
@@ -10846,15 +10842,16 @@
"license": "MIT"
},
"node_modules/sanitize-html": {
"version": "2.17.0",
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.0.tgz",
"integrity": "sha512-dLAADUSS8rBwhaevT12yCezvioCA+bmUTPH/u57xKPT8d++voeYE6HeluA/bPbQ15TwDBG2ii+QZIEmYx8VdxA==",
"version": "2.17.5",
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.5.tgz",
"integrity": "sha512-ZmU1joGRrvoyctKIiuwUxqR6moLoU2Wk+2bMccN6f7UwhAmwYDvWziqPxRDDN2Qip62NqnIrVrT9akbL6Wretg==",
"license": "MIT",
"dependencies": {
"deepmerge": "^4.2.2",
"escape-string-regexp": "^4.0.0",
"htmlparser2": "^8.0.0",
"htmlparser2": "^10.1.0",
"is-plain-object": "^5.0.0",
"launder": "^1.7.1",
"parse-srcset": "^1.0.2",
"postcss": "^8.3.11"
}
@@ -12590,7 +12587,6 @@
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "picpeak-backend",
"version": "3.45.9",
"version": "3.45.13",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"engines": {
@@ -51,13 +51,14 @@
"node-stream-zip": "^1.15.0",
"nodemailer": "^9.0.1",
"otplib": "^12.0.1",
"p-limit": "^3.1.0",
"pdf-lib": "^1.17.1",
"pdfkit": "^0.17.2",
"pg": "^8.16.3",
"postcss": "8.5.18",
"qrcode": "^1.5.4",
"react-i18next": "^15.6.0",
"sanitize-html": "^2.17.0",
"sanitize-html": "2.17.5",
"sharp": "0.35.3",
"sqlite3": "^5.1.6",
"swagger-jsdoc": "^6.2.8",
+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,
};
+28 -1
View File
@@ -3,6 +3,7 @@ const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { getGalleryTokenFromRequest } = require('../utils/tokenUtils');
const { isTokenRevoked } = require('../utils/tokenRevocation');
const logger = require('../utils/logger');
async function photoAuth(req, res, next) {
@@ -89,7 +90,33 @@ async function photoAuth(req, res, next) {
// Check if it's an admin token (admins can view all photos)
if (decoded.type === 'admin') {
// For both thumbnails and photos with admin token, allow access
// Enforce the same revocation / session-cutoff invalidation that
// adminAuth does — otherwise a validly-signed admin JWT keeps
// serving photos after logout, password change, or explicit
// revocation (GHSA-x55x).
if (await isTokenRevoked(decoded)) {
return res.status(401).json({ error: 'Session expired' });
}
// adminAuth also (a) rejects tokens for a now-deactivated admin and
// (b) rejects any token minted before the admin's last password
// change. Token revocation alone doesn't cover those, so without
// these two checks a stale or pre-password-change admin token still
// fetches every photo.
const admin = await db('admin_users')
.where({ id: decoded.id, is_active: formatBoolean(true) })
.select('id', 'password_changed_at')
.first();
if (!admin) {
return res.status(401).json({ error: 'Session expired' });
}
if (admin.password_changed_at) {
const passwordChangedSeconds = Math.floor(
new Date(admin.password_changed_at).getTime() / 1000
);
if (decoded.iat < passwordChangedSeconds) {
return res.status(401).json({ error: 'Session expired' });
}
}
return next();
}
} catch (err) {
+14 -1
View File
@@ -19,7 +19,10 @@ const router = express.Router();
// the plaintext, never recoverable after creation.
router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const tokens = await db('api_tokens')
// Scope to the caller's own tokens unless super_admin — the previous
// query returned every admin's token metadata (name/preview/scopes/
// owner) to any settings.view holder (GHSA-jm7j).
const tokensQuery = db('api_tokens')
.leftJoin('admin_users', 'admin_users.id', 'api_tokens.created_by')
.select(
'api_tokens.id',
@@ -33,6 +36,10 @@ router.get('/', adminAuth, requirePermission('settings.view'), async (req, res)
'admin_users.username as owner_username'
)
.orderBy('api_tokens.created_at', 'desc');
if (req.admin.roleName !== 'super_admin') {
tokensQuery.where('api_tokens.created_by', req.admin.id);
}
const tokens = await tokensQuery;
res.json(tokens);
} catch (error) {
logger.error('Failed to list API tokens', { error: error.message });
@@ -101,6 +108,12 @@ router.delete('/:id', adminAuth, requirePermission('settings.edit'), async (req,
const { id } = req.params;
const row = await db('api_tokens').where({ id }).first();
if (!row) return res.status(404).json({ error: 'Token not found' });
// Only the token's owner (or a super_admin) may revoke it — otherwise
// any settings.edit holder could revoke another admin's tokens
// (GHSA-gprq). 404 rather than 403 so a non-owner can't probe token ids.
if (req.admin.roleName !== 'super_admin' && row.created_by !== req.admin.id) {
return res.status(404).json({ error: 'Token not found' });
}
if (row.revoked_at) return res.status(400).json({ error: 'Token already revoked' });
await db('api_tokens').where({ id }).update({ revoked_at: new Date() });
+13 -4
View File
@@ -8,7 +8,7 @@ const { endSession } = require('../middleware/sessionTimeout');
const { validatePasswordStrength } = require('../utils/passwordGenerator');
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
const { NotFoundError, ConflictError, ValidationError } = require('../utils/errors');
const { setAdminAuthCookie } = require('../utils/tokenUtils');
const { setAdminAuthCookie, clearAdminAuthCookie } = require('../utils/tokenUtils');
const { IDENTITY_PRESERVING_NORMALIZE_EMAIL } = require('../utils/emailNormalization');
const mfaService = require('../services/mfaService');
const router = express.Router();
@@ -168,12 +168,21 @@ router.post('/change-password', [
// Logout
router.post('/logout', adminAuth, handleAsync(async (req, res) => {
// Get token from header
const token = req.headers.authorization?.split(' ')[1];
// Use the token adminAuth actually authenticated with (req.token) — it may
// have come from the admin_token cookie, not the Authorization header. The
// old header-only read skipped revocation entirely for cookie-based logout,
// leaving the JWT valid until expiry while reporting a successful logout.
const token = req.token;
if (token) {
// End the session
// End the in-memory session AND revoke the JWT (GHSA-cjqh) — the token
// is otherwise valid until expiry, so photoAuth/adminAuth would keep
// honouring it after logout. isTokenRevoked() checks this store.
endSession(token);
const { revokeToken } = require('../utils/tokenRevocation');
await revokeToken(token, 'logout');
}
// Clear the auth cookie so the browser stops sending the (now revoked) JWT.
clearAdminAuthCookie(res);
// Log activity
await logActivity('admin_logout',
+33 -7
View File
@@ -1,7 +1,7 @@
const express = require('express');
const { db } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { requirePermission, requireSuperAdmin } = require('../middleware/permissions');
const { clearAdminAuthCookie } = require('../utils/tokenUtils');
const { revokeToken } = require('../utils/tokenRevocation');
const { triggerManualBackup, getBackupStatus, cleanupOldBackupRuns, getBackupManifest, validateBackupManifest } = require('../services/backupService');
@@ -57,14 +57,33 @@ router.put('/config', adminAuth, requirePermission('backup.create'), async (req,
}
break;
case 's3':
if (!updates.backup_s3_endpoint || !updates.backup_s3_bucket ||
if (!updates.backup_s3_endpoint || !updates.backup_s3_bucket ||
!updates.backup_s3_access_key || !updates.backup_s3_secret_key) {
return res.status(400).json({ error: 'S3 backup requires endpoint, bucket, and credentials' });
}
break;
}
}
// SSRF: validate an S3 endpoint whenever one is supplied — NOT only when
// the payload also flips backup_destination_type to 's3'. The PUT
// persists every backup_* field independently, so with S3 already
// selected a caller could PATCH just backup_s3_endpoint to a
// private-resolving host; the management ops (manifest, bucket/file
// browse, cleanup, test-upload) then connect without going through
// testConnection. Prod-only; dev points at localhost MinIO deliberately.
if (process.env.NODE_ENV === 'production'
&& updates.backup_s3_endpoint && updates.backup_s3_endpoint !== '••••••••') {
const rawEndpoint = updates.backup_s3_endpoint;
const withProto = /^https?:\/\//.test(rawEndpoint) ? rawEndpoint : `https://${rawEndpoint}`;
let epHost = null;
try { epHost = new URL(withProto).hostname; } catch { epHost = null; }
const { isHostAllowed } = require('../utils/networkValidation');
if (!epHost || !(await isHostAllowed(epHost))) {
return res.status(400).json({ error: 'S3 endpoint resolves to a private or internal network address' });
}
}
// Update settings
for (const [key, value] of Object.entries(updates)) {
if (key.startsWith('backup_')) {
@@ -139,7 +158,12 @@ router.post('/run', adminAuth, requirePermission('backup.create'), async (req, r
// SECURITY: the file contains plaintext secrets (SMTP password, admin password
// hashes, API keys). The download UI must warn before offering it. We surface
// the flag as a response header too so the client can double-confirm.
router.get('/picpeak/export', adminAuth, requirePermission('backup.create'), async (req, res) => {
// Full-instance export dumps every table unredacted — bcrypt password
// hashes, 2FA columns, and all integration secrets (SMTP/SSO/WhatsApp/
// webhook/S3) in cleartext. The built-in `admin` role holds backup.create,
// but is denied this data everywhere else (config APIs mask secrets as
// ********). Gate the raw dump behind super_admin (GHSA-pv6w-rj34-wj9v).
router.get('/picpeak/export', adminAuth, requireSuperAdmin(), async (req, res) => {
const fsSync = require('fs');
try {
const includePhotos = req.query.includePhotos === 'true' || req.query.includePhotos === '1';
@@ -351,9 +375,11 @@ router.post('/test-connection', adminAuth, requirePermission('backup.create'), a
break;
}
// SSRF protection: block connections to private/internal addresses
const { isPrivateIP } = require('../utils/networkValidation');
if (isPrivateIP(host)) {
// SSRF protection: resolve the host and block any private/internal
// address. ssh does its own DNS at connect time, so a literal-only
// check let a hostname resolving to an internal IP through (#GHSA-4jh8).
const { isHostAllowed } = require('../utils/networkValidation');
if (!(await isHostAllowed(host))) {
res.json({ success: false, message: 'Host cannot be a private or internal network address' });
break;
}
+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),
+19 -4
View File
@@ -141,8 +141,18 @@ router.put('/:id', adminAuth, requirePermission('settings.edit'), [
.trim()
};
// Update hero_photo_id if provided (including null to clear it)
// Update hero_photo_id if provided (including null to clear it). A
// non-null hero must belong to this category (GHSA-j2f4) — the general
// update path previously wrote it with no membership check at all.
if (Object.prototype.hasOwnProperty.call(req.body, 'hero_photo_id')) {
if (hero_photo_id) {
const heroPhoto = await db('photos')
.where({ id: hero_photo_id, category_id: id })
.first();
if (!heroPhoto) {
return res.status(404).json({ error: 'Photo not found in this category' });
}
}
updateData.hero_photo_id = hero_photo_id || null;
}
@@ -192,11 +202,16 @@ router.put('/:id/hero', adminAuth, requirePermission('settings.edit'), [
return res.status(404).json({ error: 'Category not found' });
}
// If hero_photo_id is provided, verify it belongs to a photo in this category
// If hero_photo_id is provided, verify the photo actually belongs to
// THIS category — checking existence alone let an admin point a
// category's hero at a photo from a different category or event
// (GHSA-j2f4).
if (hero_photo_id) {
const photo = await db('photos').where('id', hero_photo_id).first();
const photo = await db('photos')
.where({ id: hero_photo_id, category_id: id })
.first();
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
return res.status(404).json({ error: 'Photo not found in this category' });
}
}
+40 -2
View File
@@ -11,6 +11,8 @@ const { body, param, query } = require('express-validator');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { requireFeatureFlag } = require('../middleware/requireFeatureFlag');
const { filterOwnedEventIds } = require('../middleware/ownership');
const { db } = require('../database/db');
// Hour-entry routes are gated by the hoursLogging master so a direct API hit
// can't read/edit/delete/bill logged hours while the feature is off (the
@@ -528,9 +530,45 @@ router.put('/:id/events', [
body('event_ids.*').isInt({ min: 1 }),
], handleAsync(async (req, res) => {
validateRequest(req);
const customerId = parseInt(req.params.id, 10);
const submitted = req.body.event_ids.map(Number);
// The customer's CURRENT assignments. The "Manage galleries" dialog submits
// the full initial list back — including any events owned by OTHER admins —
// so we need this to tell "retain an existing foreign assignment" apart from
// "newly grant a foreign event".
const existingEventIds = (await db('event_customer_assignments')
.where('customer_account_id', customerId)
.pluck('event_id')).map(Number);
const existingSet = new Set(existingEventIds);
// Events the caller may act on (GHSA-xr6x). A denied id is only acceptable
// when the customer ALREADY has that assignment (a foreign event the caller
// is merely keeping); a denied id that isn't already assigned is a fresh
// attempt to mint access to a foreign/nonexistent event → reject.
const { allowed } = await filterOwnedEventIds(req.admin, submitted);
const allowedSet = new Set(allowed.map(Number));
const illegalNew = submitted.filter((id) => !allowedSet.has(id) && !existingSet.has(id));
if (illegalNew.length) {
return res.status(403).json({ error: 'One or more events are not yours to assign' });
}
// setAssignmentsForCustomer replaces the FULL assignment list, deleting any
// existing row not in the submitted set. A restricted admin must not be able
// to revoke another admin's customer↔event links that way, so always retain
// the customer's existing assignments to events the caller does NOT own —
// regardless of whether the client echoed them back. super_admin owns
// everything, so nothing is force-preserved for them.
let finalEventIds = allowed.map(Number);
if (req.admin.roleName !== 'super_admin' && existingEventIds.length) {
const { allowed: ownedExisting } = await filterOwnedEventIds(req.admin, existingEventIds);
const ownedExistingSet = new Set(ownedExisting.map(Number));
const foreignExisting = existingEventIds.filter((id) => !ownedExistingSet.has(id));
finalEventIds = [...new Set([...finalEventIds, ...foreignExisting])];
}
const result = await customerAccountsService.setAssignmentsForCustomer(
parseInt(req.params.id, 10),
req.body.event_ids,
customerId,
finalEventIds,
req.admin.id,
);
successResponse(res, result);
+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) {
+16 -14
View File
@@ -66,9 +66,11 @@ router.post('/config', [
tls_reject_unauthorized
} = req.body;
// Validate SMTP host is not a private/internal address (SSRF protection)
const { isPrivateIP } = require('../utils/networkValidation');
if (isPrivateIP(smtp_host)) {
// Validate SMTP host is not a private/internal address (SSRF protection).
// Resolves DNS so a public-looking hostname pointing at an internal IP
// is caught, not just literal private addresses (#GHSA-ch64).
const { isHostAllowed } = require('../utils/networkValidation');
if (!(await isHostAllowed(smtp_host))) {
return res.status(400).json({ error: 'SMTP host cannot point to a private or internal network address' });
}
@@ -152,8 +154,8 @@ router.post('/incoming-config', [
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
const { imap_host, imap_port, imap_secure, imap_user, imap_pass, imap_folder } = req.body;
const { isPrivateIP } = require('../utils/networkValidation');
if (isPrivateIP(imap_host)) {
const { isHostAllowed } = require('../utils/networkValidation');
if (!(await isHostAllowed(imap_host))) {
return res.status(400).json({ error: 'IMAP host cannot point to a private or internal network address' });
}
const existing = await db('email_configs').first();
@@ -183,8 +185,8 @@ router.post('/incoming-config/folders', adminAuth, requirePermission('email.view
try {
const { imap_host, imap_port, imap_secure, imap_user, imap_pass } = req.body || {};
if (imap_host) {
const { isPrivateIP } = require('../utils/networkValidation');
if (isPrivateIP(imap_host)) {
const { isHostAllowed } = require('../utils/networkValidation');
if (!(await isHostAllowed(imap_host))) {
return res.status(400).json({ error: 'IMAP host cannot point to a private or internal network address' });
}
}
@@ -205,8 +207,8 @@ router.post('/incoming-config/test', adminAuth, requirePermission('email.view'),
try {
const { imap_host, imap_port, imap_secure, imap_user, imap_pass, imap_folder } = req.body || {};
if (imap_host) {
const { isPrivateIP } = require('../utils/networkValidation');
if (isPrivateIP(imap_host)) {
const { isHostAllowed } = require('../utils/networkValidation');
if (!(await isHostAllowed(imap_host))) {
return res.status(400).json({ error: 'IMAP host cannot point to a private or internal network address' });
}
}
@@ -385,11 +387,11 @@ router.post('/accounts', adminAuth, messagingGate, requirePermission('email.edit
if (!b.account_key) return res.status(400).json({ error: 'account_key is required' });
// SSRF guard — mirror /config + /incoming-config: neither the IMAP nor the
// SMTP host may point at a private/internal address.
const { isPrivateIP } = require('../utils/networkValidation');
if (b.imap_host && isPrivateIP(b.imap_host)) {
const { isHostAllowed } = require('../utils/networkValidation');
if (b.imap_host && !(await isHostAllowed(b.imap_host))) {
return res.status(400).json({ error: 'IMAP host cannot point to a private or internal network address' });
}
if (b.smtp_host && isPrivateIP(b.smtp_host)) {
if (b.smtp_host && !(await isHostAllowed(b.smtp_host))) {
return res.status(400).json({ error: 'SMTP host cannot point to a private or internal network address' });
}
const patch = {
@@ -434,8 +436,8 @@ router.post('/accounts', adminAuth, messagingGate, requirePermission('email.edit
router.post('/accounts/test', adminAuth, messagingGate, requirePermission('email.view'), async (req, res) => {
try {
const b = req.body || {};
const { isPrivateIP } = require('../utils/networkValidation');
if (b.imap_host && isPrivateIP(b.imap_host)) {
const { isHostAllowed } = require('../utils/networkValidation');
if (b.imap_host && !(await isHostAllowed(b.imap_host))) {
return res.status(400).json({ error: 'IMAP host cannot point to a private or internal network address' });
}
let pass = b.imap_pass;
+67 -8
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,12 +1267,59 @@ 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() });
}
const { id } = req.params;
const updates = { ...req.body };
// Strip identity/provenance/secret columns from the mass-assigned
// body (GHSA-3rqx). The handler spreads req.body straight into the
// events UPDATE, so without this an events.edit holder could rewrite
// ownership (created_by), routing identity (slug/share_link), the
// share/client tokens, or the password hashes directly. Plaintext
// `password`/`client_password` inputs are NOT stripped — those are the
// supported way to change credentials and get hashed below; the
// tokens are regenerated internally where needed.
// The handler spreads req.body straight into the events UPDATE, so any
// column an events.edit holder names is writable unless blocked here.
// This is a COMPLETE deny-set of every server-managed / permission-gated
// events column (enumerated from the schema); everything else is a
// legitimate edit-form field and passes through, including input-only
// keys (password/client_password) the handler transforms below. New
// server-managed columns MUST be added here. (codex review — GHSA-3rqx.)
const IMMUTABLE_EVENT_COLUMNS = [
// Identity / provenance
'id', 'created_by', 'created_at', 'updated_at', 'slug',
// Routing + share/client tokens (generated at create / internally)
'share_link', 'share_token', 'client_share_token', 'show_share_token',
// Secrets (set via the plaintext password/client_password inputs)
'password_hash', 'client_password_hash',
// Server-consumed file paths — e.g. DELETE /:id/logo fs.unlink()s
// hero_logo_path, so a forged value is an arbitrary-delete primitive.
'hero_logo_path', 'hero_logo_url', 'archive_path', 'download_zip_path',
// Server-managed timestamps
'download_zip_generated_at', 'archived_at', 'revealed_at', 'event_reminder_sent_at',
// Lifecycle — governed by dedicated permission-gated routes
// (events.archive/restore, publish, activate/deactivate), not events.edit.
'is_archived', 'is_draft', 'is_active',
// Relationships — managed by projectService.assignEvent + its
// customer-consistency checks, and events.edit ≠ quotes/contracts perms.
'project_id', 'quote_id',
// Legacy mirrors — rejected explicitly below in favour of customer_*.
'host_name', 'host_email',
];
// Case-insensitive match: SQLite treats quoted identifiers
// case-insensitively, so a `{ "Password_Hash": ... }` key would
// otherwise survive a case-sensitive delete and still hit the real
// column (codex review).
const denied = new Set(IMMUTABLE_EVENT_COLUMNS.map((c) => c.toLowerCase()));
for (const key of Object.keys(updates)) {
if (denied.has(key.toLowerCase())) delete updates[key];
}
const customerColumnsAvailable = await hasCustomerContactColumns();
if (Object.prototype.hasOwnProperty.call(updates, 'host_name') || Object.prototype.hasOwnProperty.call(updates, 'host_email')) {
@@ -1395,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,
@@ -1497,10 +1551,15 @@ module.exports = (router) => {
}
}
// Update event
await db('events')
.where('id', id)
.update(updates);
// Update event. Skip the write when the denylist (or masked secrets)
// left nothing to change — Knex rejects .update({}) with an error,
// which would surface as a 500 for an otherwise-valid no-op request
// (e.g. a body of only protected fields). (codex review.)
if (Object.keys(updates).length > 0) {
await db('events')
.where('id', id)
.update(updates);
}
// Customer-account assignments (#354). Same skip semantics as POST:
// ignore when the customer portal flag is off so stale tabs don't
+39 -8
View File
@@ -1,4 +1,5 @@
const express = require('express');
const { neutralizeSpreadsheetFormula } = require('../utils/spreadsheetSafe');
const router = express.Router();
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
@@ -164,6 +165,24 @@ router.get('/events/:eventId/feedback',
}
);
// Ownership guard for by-feedback-id routes (GHSA-2qc2 / GHSA-32h4). These
// take a :feedbackId (not :eventId), so requireEventOwnership can't apply —
// resolve the feedback's event and enforce the same rule (super_admin sees
// all; others need to own the event, or it's ownerless/legacy). Returns
// false and sends a 404 (not 403 — don't leak which feedback ids exist)
// when the caller may not act on it.
async function assertOwnsFeedback(req, res, feedbackId) {
if (req.admin.roleName === 'super_admin') return true;
const fb = await db('photo_feedback').where('id', feedbackId).first('event_id');
if (!fb) { res.status(404).json({ error: 'Feedback not found' }); return false; }
const event = await db('events').where('id', fb.event_id).first('created_by');
if (event && event.created_by && event.created_by !== req.admin.id) {
res.status(404).json({ error: 'Feedback not found' });
return false;
}
return true;
}
// Moderate feedback (approve/hide/reject)
router.put('/feedback/:feedbackId/:action',
adminAuth,
@@ -171,11 +190,12 @@ router.put('/feedback/:feedbackId/:action',
async (req, res) => {
try {
const { feedbackId, action } = req.params;
if (!['approve', 'hide', 'reject'].includes(action)) {
return res.status(400).json({ error: 'Invalid action' });
}
if (!(await assertOwnsFeedback(req, res, feedbackId))) return;
await feedbackService.moderateFeedback(feedbackId, action, req.admin.id);
res.json({ success: true });
@@ -193,7 +213,8 @@ router.delete('/feedback/:feedbackId',
async (req, res) => {
try {
const { feedbackId } = req.params;
if (!(await assertOwnsFeedback(req, res, feedbackId))) return;
await feedbackService.deleteFeedback(feedbackId, req.admin.id);
res.json({ success: true });
@@ -347,7 +368,15 @@ router.get('/feedback/pending-moderation',
requirePermission('events.view'),
async (req, res) => {
try {
const pending = await feedbackService.getPendingModeration();
// Scope to the caller's owned events unless super_admin (GHSA-3335).
let ownedEventIds = null;
if (req.admin.roleName !== 'super_admin') {
const rows = await db('events')
.where((q) => q.whereNull('created_by').orWhere('created_by', req.admin.id))
.select('id');
ownedEventIds = rows.map((r) => r.id);
}
const pending = await feedbackService.getPendingModeration(null, ownedEventIds);
res.json(pending);
} catch (error) {
logger.error('Error getting pending moderation:', error);
@@ -450,11 +479,13 @@ function convertToCSV(data) {
const value = row[header];
if (value === null || value === undefined) return '';
if (typeof value === 'boolean') return value ? 'yes' : 'no';
if (typeof value === 'string'
&& (value.includes(',') || value.includes('"') || value.includes('\n') || value.includes('\r'))) {
return `"${value.replace(/"/g, '""')}"`;
// Formula-neutralize user-controlled cells (guest_name/comment_text)
// before quoting — quoting alone doesn't stop `=cmd()` (GHSA-3cw3).
const neutralized = neutralizeSpreadsheetFormula(value);
if (neutralized.includes(',') || neutralized.includes('"') || neutralized.includes('\n') || neutralized.includes('\r')) {
return `"${neutralized.replace(/"/g, '""')}"`;
}
return value;
return neutralized;
}).join(',');
});
+6 -1
View File
@@ -39,8 +39,13 @@ function serializeGuest(row) {
};
}
const { neutralizeSpreadsheetFormula } = require('../utils/spreadsheetSafe');
function escapeCsvCell(value) {
const str = value == null ? '' : String(value);
// Neutralize spreadsheet formulas FIRST (a `=cmd()` guest name executes on
// open — RFC-4180 quoting doesn't stop it), then quote-wrap (GHSA-wc99 /
// GHSA-f4fp).
const str = neutralizeSpreadsheetFormula(value);
if (/[,"\n\r]/.test(str)) {
return `"${str.replace(/"/g, '""')}"`;
}
+11 -2
View File
@@ -502,6 +502,15 @@ router.get('/export', adminAuth, requirePermission('settings.view'), async (req,
/**
* Helper function to convert data to CSV
*/
const { neutralizeSpreadsheetFormula } = require('../utils/spreadsheetSafe');
function csvCell(value) {
// Formula-neutralize, then RFC-4180 quote (the previous join('') did
// neither — GHSA-37p4).
const s = neutralizeSpreadsheetFormula(value);
return /[,"\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
}
function convertToCSV(data) {
// Simplified CSV conversion for security logs
const headers = ['timestamp', 'event_type', 'client_ip', 'details'];
@@ -511,8 +520,8 @@ function convertToCSV(data) {
log.client_ip,
JSON.stringify(log.details || {})
]);
return [headers.join(','), ...rows.map(row => row.join(','))].join('\n');
return [headers.join(','), ...rows.map(row => row.map(csvCell).join(','))].join('\n');
}
module.exports = router;
+88 -5
View File
@@ -315,9 +315,10 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), r
const crypto = require('crypto');
const uploadId = crypto.randomBytes(16).toString('hex');
// Counter base — same approximation as before. Strict uniqueness is
// already enforced by the filename template + DB unique index, so a
// small race here just retries a counter on conflict (rare).
// Counter base — a per-request approximation (concurrent upload
// requests can compute the same base; there is NO unique index on
// photos.filename). Uniqueness of the final path comes from the
// random suffix inside generatePhotoFilename (#931).
const existingCount = await db('photos')
.where({ event_id: eventId, type: photoType })
.count('id as count')
@@ -756,6 +757,16 @@ router.patch('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.e
.where({ id: photoId, event_id: eventId })
.update(updateData);
// A visibility or category change alters which photos belong in the
// guest download bundle — drop the cached ZIP so it rebuilds fresh,
// otherwise a hide→unhide cycle can leave the stale cache omitting
// photos added in between (codex review).
if (updateData.visibility !== undefined
|| Object.prototype.hasOwnProperty.call(updateData, 'category_id')
|| Object.prototype.hasOwnProperty.call(updateData, 'type')) {
downloadZipService.invalidate(parseInt(eventId, 10));
}
// Fetch and return the updated photo
const updatedPhoto = await db('photos')
.where({ id: photoId, event_id: eventId })
@@ -904,6 +915,14 @@ router.post('/:eventId/photos/bulk-update', adminAuth, requirePermission('photos
.where('event_id', eventId)
.update(updateData);
// Visibility/category changes alter the guest download bundle — drop the
// cached ZIP so it rebuilds fresh (codex review).
if (updateData.visibility !== undefined
|| Object.prototype.hasOwnProperty.call(updateData, 'category_id')
|| Object.prototype.hasOwnProperty.call(updateData, 'type')) {
downloadZipService.invalidate(parseInt(eventId, 10));
}
res.json({ message: `${photoIds.length} photos updated successfully` });
} catch (error) {
errorResponse(res, error, 500, 'Failed to update photos');
@@ -1091,7 +1110,13 @@ router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), requ
average_rating: photo.average_rating || 0,
comment_count: commentMap[photo.id] || 0,
like_count: photo.like_count || 0,
favorite_count: photo.favorite_count || 0
favorite_count: photo.favorite_count || 0,
// Engagement counters (#895 follow-up): the grid reads these, but
// this explicit mapper never included them — so the Engagement
// column showed 0 regardless of what the DB counted. This, not
// stale data, was why per-image downloads always displayed 0.
view_count: photo.view_count || 0,
download_count: photo.download_count || 0
}))
});
} catch (error) {
@@ -1126,7 +1151,65 @@ router.get('/:eventId/photo/:photoId', adminAuth, requirePermission('photos.view
const event = await db('events').where('id', eventId).first();
const storageKey = resolvePhotoStorageKey(event, photo);
res.setHeader('Content-Type', `image/${path.extname(photo.filename).slice(1)}`);
// Content-Type resolution (#908 + external review). Invariant: the
// header is ALWAYS image/* or video/*.
// - photos.mime_type is never echoed verbatim unless it is a video/
// type: the chunked-upload path stores the client-sent MIME
// unvalidated, so a stored text/html served inline under the app
// origin would be a same-origin XSS gift.
// - Images ignore the stored value entirely — migration 039
// backfilled image/jpeg onto every legacy row (PNGs included), so
// the extension is the more trustworthy signal; normalized via the
// shared map (image/jpg → image/jpeg), jpeg fallback when unknown.
// - Videos prefer a stored video/ type, then the extension map
// (.mov → video/quicktime, .webm → video/webm, …), then video/mp4.
// The old ext-derived image/<ext> (image/mp4) is what made the
// admin player's blob unplayable (#908).
const { EXTENSION_TO_MIME } = require('../services/uploadSettings');
const ext = path.extname(photo.filename).slice(1).toLowerCase();
// Own-property lookup (review): a client-controlled filename ending in
// .constructor / .__proto__ / .toString would otherwise return an
// inherited Object.prototype member, and the extMime.startsWith below
// would throw — a permanent 500 for that photo instead of the fallback.
const extMime = Object.prototype.hasOwnProperty.call(EXTENSION_TO_MIME, ext)
? EXTENSION_TO_MIME[ext]
: null;
// Full-token validation, not just a prefix check: the stored value is
// client-controlled, and header-invalid characters (video/mp4\r\nX: y)
// would make setHeader throw — a permanent 500 for that photo. Bare
// 'video/' is equally invalid; both fall back to the extension map.
const storedVideoMime = photo.mime_type && /^video\/[\w.+-]+$/.test(photo.mime_type)
? photo.mime_type
: null;
// Honor a stored image MIME for any header-safe RASTER type (#908
// review): the S3 auto-importer accepts arbitrary image/* from
// mime-types and stores it (avif/bmp/tiff/heic/apng/ico/jxl/…), and a
// hand-listed allowlist kept missing formats. Allow image/<token> but
// NEVER the scriptable svg / *+xml family (image/svg+xml executes
// inline). The strict token + anchors also block header injection
// (image/x\r\nY:). Migration 039's blanket image/jpeg backfill on
// legacy rows is why the mapped extension still wins ahead of this.
const storedImageMime =
photo.mime_type &&
/^image\/[\w.+-]+$/.test(photo.mime_type) &&
!/^image\/svg|xml/i.test(photo.mime_type)
? photo.mime_type
: null;
const isVideo = photo.media_type === 'video' ||
Boolean(storedVideoMime) ||
Boolean(extMime && extMime.startsWith('video/'));
// Never interpolate the raw extension on the image side: it would
// synthesize image/svg+xml (scriptable inline) or header-invalid values
// from client-controlled chunked-upload filenames. Precedence is
// mapped-extension (also corrects the 039 legacy-jpeg backfill on PNGs)
// -> safe stored raster MIME (auto-imported avif/bmp/tiff) -> image/jpeg.
// A stored type outside the allowlist degrades to image/jpeg; browsers
// sniff image bytes in <img>/blob contexts, so a mislabel is harmless
// where an injected type is not.
const contentType = isVideo
? storedVideoMime || (extMime && extMime.startsWith('video/') ? extMime : null) || 'video/mp4'
: (extMime && extMime.startsWith('image/') ? extMime : null) || storedImageMime || 'image/jpeg';
res.setHeader('Content-Type', contentType);
res.setHeader('Cache-Control', 'private, max-age=3600');
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
+92 -14
View File
@@ -15,8 +15,32 @@ 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();
// A deal that spans both quotes and contracts cascades a project link across
// BOTH tables (projectService.linkDealToProject). So attaching one document
// must also require manage permission on the OTHER domain the cascade will
// touch — otherwise quotes.manage alone could re-point a linked contract, and
// vice versa (GHSA-v4vw / codex review). No-op when the deal touches only the
// one domain, or on older instances without the deal_uuid column.
async function assertCascadePermitted(req, docTable, docId, otherTable, otherPerm) {
let doc;
try {
doc = await db(docTable).where({ id: docId }).first('deal_uuid');
} catch { return; }
if (!doc || !doc.deal_uuid) return;
let linked;
try {
linked = await db(otherTable).where({ deal_uuid: doc.deal_uuid }).first('id');
} catch { return; }
if (!linked) return;
if (!(await userHasAnyPermission(req.admin.id, [otherPerm]))) {
throw new ForbiddenError(`This deal also links a ${otherTable.replace(/s$/, '')}; the ${otherPerm} permission is required`);
}
}
router.use(adminAuth);
// Projects is feature-flagged like bills/quotes — when off, the whole cockpit
@@ -42,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 });
}));
@@ -65,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' });
@@ -75,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 }),
@@ -96,50 +126,98 @@ 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');
}),
);
// Attach a quote to the project (quotes carry no event_id — migration 121).
// Requires quotes.manage in addition to events.edit — attaching a quote
// mutates a separately-permissioned document domain (GHSA-v4vw).
router.post('/:id/quotes',
requirePermission('events.edit'),
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 result = await projectService.assignQuote(parseInt(req.params.id, 10), parseInt(req.body.quoteId, 10));
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, req.admin);
return successResponse(res, result, 200, 'Quote attached to project');
}),
);
// Attach a contract to the project.
// Attach a contract to the project. Requires contracts.manage in addition
// to events.edit (GHSA-v4vw).
router.post('/:id/contracts',
requirePermission('events.edit'),
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 result = await projectService.assignContract(parseInt(req.params.id, 10), parseInt(req.body.contractId, 10));
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, 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);
@@ -152,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 };
+5 -5
View File
@@ -20,7 +20,7 @@ const { body, query, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { validateExternalUrl } = require('../utils/networkValidation');
const { validateExternalUrlAsync } = require('../utils/networkValidation');
const webhookService = require('../services/webhookService');
const logger = require('../utils/logger');
@@ -78,9 +78,9 @@ router.post(
requirePermission('settings.edit'),
[
body('name').isString().trim().isLength({ min: 1, max: 100 }),
body('url').isString().isLength({ max: 2048 }).custom((url) => {
body('url').isString().isLength({ max: 2048 }).custom(async (url) => {
if (ALLOW_PRIVATE_URLS) return true;
const check = validateExternalUrl(url);
const check = await validateExternalUrlAsync(url);
if (!check.valid) throw new Error(check.error);
return true;
}),
@@ -160,9 +160,9 @@ router.put(
requirePermission('settings.edit'),
[
body('name').optional().isString().trim().isLength({ min: 1, max: 100 }),
body('url').optional().isString().isLength({ max: 2048 }).custom((url) => {
body('url').optional().isString().isLength({ max: 2048 }).custom(async (url) => {
if (ALLOW_PRIVATE_URLS) return true;
const check = validateExternalUrl(url);
const check = await validateExternalUrlAsync(url);
if (!check.valid) throw new Error(check.error);
return true;
}),
+69 -25
View File
@@ -30,6 +30,7 @@ const { handleAsync, errorResponse } = require('../utils/routeHelpers');
const { NotFoundError } = require('../utils/errors');
const { ensureThumbnail, ensureHeroImage, ensurePreviewImage, withLocalCopy } = require('../services/imageProcessor');
const downloadZipService = require('../services/downloadZipService');
const { applyPhotoVisibilityFilter, canSeeHiddenPhotos } = require('../utils/photoVisibility');
const {
getUseOriginalFilenames,
pickRawDownloadName,
@@ -83,9 +84,20 @@ router.get('/resolve/:identifier', handleAsync(async (req, res) => {
}
const { event, matchType, shareToken } = result;
const linkVariants = await buildShareLinkVariants({ slug: event.slug, shareToken });
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
// The share_token is a bearer secret. Only return it (and the share
// links/URLs that embed it) when the caller already proved they hold it —
// i.e. they resolved via the token or the full share link. A bare *slug*
// lookup (slugs appear in gallery URLs and are guessable) must NOT hand
// back the secret, or an anonymous caller could turn a known slug into
// share-link access to a no-password gallery (GHSA-rh8r).
const callerHasToken = matchType !== 'slug';
if (!callerHasToken) {
return res.json({ slug: event.slug, matchType, requires_password: requiresPassword });
}
const linkVariants = await buildShareLinkVariants({ slug: event.slug, shareToken });
res.json({
slug: event.slug,
token: shareToken,
@@ -784,6 +796,10 @@ router.patch('/:slug/photos/:photoId/visibility', verifyGalleryAccess, async (re
.where({ id: photoId, event_id: req.event.id })
.update({ visibility });
// A client hiding/showing a photo changes the guest download bundle —
// drop the cached ZIP so it rebuilds fresh (codex review).
downloadZipService.invalidate(req.event.id);
res.json({ message: 'Photo visibility updated', visibility });
} catch (error) {
errorResponse(res, error, 500, 'Failed to update photo visibility');
@@ -812,6 +828,10 @@ router.patch('/:slug/photos/visibility/bulk', verifyGalleryAccess, async (req, r
.where('event_id', req.event.id)
.update({ visibility });
// Client bulk hide/show alters the guest download bundle — invalidate
// the cached ZIP (codex review).
downloadZipService.invalidate(req.event.id);
res.json({ message: `${count} photos updated`, visibility });
} catch (error) {
errorResponse(res, error, 500, 'Failed to update photo visibility');
@@ -956,8 +976,21 @@ router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, async
return res.status(403).json({ error: 'Downloads are disabled for this gallery' });
}
// Try to serve pre-generated zip (instant download with Content-Length)
const zipInfo = await downloadZipService.getZipInfo(req.event.id);
// Try to serve pre-generated zip (instant download with Content-Length).
// Guests may use the prebuilt cache ONLY when the event has no hidden
// photos: a cache built before a photo was hidden — or before this
// visibility-aware builder shipped — could otherwise still leak it, and
// getZipInfo only checks the DB pointer + file stat, not freshness. When
// hidden photos exist, guests fall through to the visibility-filtered
// stream below. PIN-clients always stream a full archive.
const isClient = canSeeHiddenPhotos(req.accessLevel);
const eventHasHidden = await db('photos')
.where({ event_id: req.event.id, visibility: 'hidden' })
.first()
.then(Boolean);
const zipInfo = (isClient || eventHasHidden)
? null
: await downloadZipService.getZipInfo(req.event.id);
if (zipInfo) {
const storage = getStorage();
@@ -1006,23 +1039,31 @@ router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, async
return;
}
// Fallback: on-the-fly streaming (existing behavior)
// Also trigger background zip generation for next time
downloadZipService.generateZip(req.event.id).catch(err =>
logger.warn('Background zip generation failed', { eventId: req.event.id, error: err.message })
);
// Fallback: on-the-fly streaming (existing behavior). Only pre-build the
// guest cache when it will actually be served next time — a guest
// download of an event with no hidden photos. Client bypasses and
// hidden-photo events always stream, so rebuilding the guest archive on
// those requests is wasted I/O (codex review).
if (!isClient && !eventHasHidden) {
downloadZipService.generateZip(req.event.id).catch(err =>
logger.warn('Background zip generation failed', { eventId: req.event.id, error: err.message })
);
}
// Fetch photos — exclude photos in categories that disabled downloads (#640).
// Uncategorised photos are always included; categories without the column
// (pre-migration-135) fall through the LEFT JOIN's null and are included.
const photos = await db('photos')
.leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
.where('photos.event_id', req.event.id)
.where(function () {
this.whereNull('photos.category_id')
.orWhere('photo_categories.allow_downloads', true)
.orWhereNull('photo_categories.allow_downloads');
})
const photos = await applyPhotoVisibilityFilter(
db('photos')
.leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
.where('photos.event_id', req.event.id)
.where(function () {
this.whereNull('photos.category_id')
.orWhere('photo_categories.allow_downloads', true)
.orWhereNull('photo_categories.allow_downloads');
}),
req.accessLevel
)
.select('photos.*')
.orderBy('photos.type', 'asc')
.orderBy('photos.uploaded_at', 'desc');
@@ -1172,15 +1213,18 @@ router.post('/:slug/download-selected', verifyGalleryAccess, denySlideshowToken,
// Fetch photos — exclude photos in categories that disabled downloads (#640).
// Same LEFT JOIN pattern as the download-all endpoint.
const photos = await db('photos')
.leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
.where('photos.event_id', req.event.id)
.whereIn('photos.id', photoIds)
.where(function () {
this.whereNull('photos.category_id')
.orWhere('photo_categories.allow_downloads', true)
.orWhereNull('photo_categories.allow_downloads');
})
const photos = await applyPhotoVisibilityFilter(
db('photos')
.leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
.where('photos.event_id', req.event.id)
.whereIn('photos.id', photoIds)
.where(function () {
this.whereNull('photos.category_id')
.orWhere('photo_categories.allow_downloads', true)
.orWhereNull('photo_categories.allow_downloads');
}),
req.accessLevel
)
.select('photos.*')
.orderBy('photos.uploaded_at', 'desc');
+53 -14
View File
@@ -7,6 +7,7 @@ const secureImageService = require('../services/secureImageService');
const { getStorage } = require('../services/storage');
const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('../services/photoResolver');
const { withLocalCopy } = require('../services/imageProcessor');
const { isPhotoHiddenFromViewer, canSeeHiddenPhotos } = require('../utils/photoVisibility');
const crypto = require('crypto');
const logger = require('../utils/logger');
const { timingSafeEqualStr } = require('../utils/timingSafe');
@@ -16,10 +17,14 @@ const router = express.Router();
/**
* Generate a signed URL token for image access
*/
function generateImageToken(photoId, expiresIn = 3600) {
function generateImageToken(photoId, expiresIn = 3600, clientBypass = false) {
const secret = process.env.JWT_SECRET;
const expires = Date.now() + (expiresIn * 1000);
const data = `${photoId}:${expires}`;
// Third segment: whether the minter was a PIN-client, letting the serve
// route still deliver a photo hidden AFTER minting (TOCTOU) — a guest's
// token carries 0, so it stops working the moment the photo is hidden.
// Old two-segment tokens verify unchanged and read the flag as no-bypass.
const data = `${photoId}:${expires}:${clientBypass ? 1 : 0}`;
const signature = crypto.createHmac('sha256', secret).update(data).digest('hex');
return `${Buffer.from(data).toString('base64')}.${signature}`;
}
@@ -32,20 +37,23 @@ function verifyImageToken(token) {
const secret = process.env.JWT_SECRET;
const [data, signature] = token.split('.');
const decoded = Buffer.from(data, 'base64').toString();
const [photoId, expires] = decoded.split(':');
const [photoId, expires, clientFlag] = decoded.split(':');
// Verify signature (constant-time — avoids leaking the HMAC byte-by-byte)
const expectedSignature = crypto.createHmac('sha256', secret).update(decoded).digest('hex');
if (!timingSafeEqualStr(signature, expectedSignature)) {
return null;
}
// Check expiration
if (Date.now() > parseInt(expires)) {
return null;
}
return { photoId: parseInt(photoId), expires: parseInt(expires) };
return {
photoId: parseInt(photoId),
expires: parseInt(expires),
clientBypass: clientFlag === '1',
};
} catch (error) {
return null;
}
@@ -79,6 +87,12 @@ router.get('/:slug/photo/:photoId/view', verifyGalleryAccess, async (req, res) =
return res.status(404).json({ error: 'Photo not found' });
}
// Block guest access to hidden/client-only photos (parity with the
// gallery single-photo routes).
if (isPhotoHiddenFromViewer(photo, req.accessLevel)) {
return res.status(403).json({ error: 'Photo not available' });
}
// Check for suspicious activity
const isSuspicious = await secureImageService.detectSuspiciousActivity(clientFingerprint, photoId);
if (isSuspicious) {
@@ -191,15 +205,23 @@ router.post('/:slug/photo/:photoId/generate-secure-token', verifyGalleryAccess,
return res.status(404).json({ error: 'Photo not found' });
}
// Don't mint a secure-image capability for a hidden/client-only photo
// when the caller isn't a client — the serve route is token-only.
if (isPhotoHiddenFromViewer(photo, req.accessLevel)) {
return res.status(403).json({ error: 'Photo not available' });
}
// Create client fingerprint
const clientFingerprint = secureImageService.createClientFingerprint(req);
// Generate secure token
// Generate secure token. clientBypass lets a client's token keep serving
// a photo hidden after minting; a guest's stops at the serve route.
const token = secureImageService.generateSecureToken(photoId, req.sessionID || 'anonymous', {
expiresIn,
maxUses: protectionLevel === 'maximum' ? 1 : 3,
clientFingerprint,
protectionLevel
protectionLevel,
clientBypass: canSeeHiddenPhotos(req.accessLevel)
});
res.json({
@@ -233,9 +255,19 @@ router.post('/:slug/photo/:photoId/generate-url', verifyGalleryAccess, async (re
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
// Generate signed token
const token = generateImageToken(photoId);
// Refuse to mint a signed URL for a hidden/client-only photo when the
// caller isn't a client. The signed-serve route below is token-only
// (no gallery auth), so the access decision has to happen here at mint
// time — mirroring how the reveal-bypass flag is baked into the token.
if (isPhotoHiddenFromViewer(photo, req.accessLevel)) {
return res.status(403).json({ error: 'Photo not available' });
}
// Generate signed token. The client-bypass flag lets a PIN-client's
// token keep serving a photo hidden after minting; a guest's token
// (clientBypass=0) stops the moment the photo is hidden.
const token = generateImageToken(photoId, 3600, canSeeHiddenPhotos(req.accessLevel));
const signedUrl = `/api/images/${req.params.slug}/photo/${photoId}/signed/${token}`;
res.json({
@@ -283,7 +315,14 @@ router.get('/:slug/photo/:photoId/signed/:token', async (req, res) => {
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
// Recheck visibility at serve time (TOCTOU): a photo hidden AFTER the
// URL was minted must stop serving, unless the token was minted by a
// client (clientBypass) — mirroring the reveal-mode check above.
if (photo.visibility === 'hidden' && !tokenData.clientBypass) {
return res.status(403).json({ error: 'Photo not available' });
}
// Get watermark settings
const watermarkSettings = await watermarkService.getWatermarkSettings();
+71 -1
View File
@@ -13,6 +13,7 @@ const {
pickRawDownloadName,
} = require('../services/downloadFilenameService');
const { buildContentDisposition } = require('../utils/filenameSanitizer');
const { isPhotoHiddenFromViewer, canSeeHiddenPhotos } = require('../utils/photoVisibility');
const router = express.Router();
@@ -40,6 +41,12 @@ router.post('/:slug/generate-token', async (req, res, next) => {
return res.status(404).json({ error: 'Photo not found' });
}
// Don't mint a secure-image capability for a hidden/client-only photo
// when the caller isn't a client (the token is reusable up to 3×).
if (isPhotoHiddenFromViewer(photo, req.accessLevel)) {
return res.status(403).json({ error: 'Photo not available' });
}
// Create client fingerprint
const clientFingerprint = secureImageService.createClientFingerprint(req);
@@ -51,7 +58,10 @@ router.post('/:slug/generate-token', async (req, res, next) => {
expiresIn: protectionLevel === 'maximum' ? 180 : 300, // 3-5 minutes
maxUses: accessType === 'download' ? 1 : 3,
clientFingerprint,
protectionLevel
protectionLevel,
// TOCTOU: a client's token keeps serving a photo hidden after minting;
// a guest's stops the moment it's hidden (checked at the serve route).
clientBypass: canSeeHiddenPhotos(req.accessLevel)
};
const token = secureImageService.generateSecureToken(
@@ -137,6 +147,34 @@ router.get('/:slug/secure/:photoId/:token',
return res.status(404).json({ error: 'Gallery not found' });
}
// Bind the token to the gallery + photo it was minted for
// (GHSA-g94x-8vv8-3c9f). This route serves via <img src> with the
// token in the URL, so it can't require verifyGalleryAccess like the
// download sibling does. Instead enforce the scope already inside the
// token: it is minted for one photoId (and photos belong to exactly
// one gallery), and its sessionId records the minting gallery's id.
// Without this, a token minted on any PUBLIC gallery reads every other
// gallery's photos with no password.
const tokenPhotoId = Number(tokenValidation.data?.photoId);
if (!Number.isInteger(tokenPhotoId) || tokenPhotoId !== Number(photoId)) {
await secureImageService.logImageAccess(
photoId, event.id, req.clientInfo, 'photo_mismatch'
);
return res.status(403).json({ error: 'Token not valid for this photo' });
}
// Defense in depth: the sessionId embeds the gallery the token was
// minted for (`gallery_public_<id>_...` / `gallery_<id>_...`). Reject a
// token whose gallery is parseable and differs from this one.
const sessionEventId = Number(
(String(tokenValidation.data?.sessionId || '').match(/^gallery_(?:public_)?(\d+)_/) || [])[1]
);
if (Number.isInteger(sessionEventId) && sessionEventId !== Number(event.id)) {
await secureImageService.logImageAccess(
photoId, event.id, req.clientInfo, 'gallery_mismatch'
);
return res.status(403).json({ error: 'Token not valid for this gallery' });
}
// Verify photo exists and belongs to event
const photo = await db('photos')
.where({ id: photoId, event_id: event.id })
@@ -146,6 +184,13 @@ router.get('/:slug/secure/:photoId/:token',
return res.status(404).json({ error: 'Photo not found' });
}
// Recheck visibility at serve time (TOCTOU): a photo hidden AFTER the
// token was minted must stop serving, unless the token was minted by a
// client (clientBypass) — mirroring the reveal-mode check above.
if (photo.visibility === 'hidden' && !tokenValidation.data?.clientBypass) {
return res.status(403).json({ error: 'Photo not available' });
}
// Resolve photo through storage backend (managed) or fall back to local
// path (external reference mode). secureImageService needs a local file,
// so we materialize a tmp copy via withLocalCopy in S3 mode.
@@ -293,6 +338,14 @@ router.get('/:slug/secure-download/:photoId/:token',
return res.status(403).json({ error: 'Invalid or expired token' });
}
// Bind the token to the photo it was minted for (GHSA-crxv) — the
// /secure serve route does this, but secure-download did not, so a
// token minted for photo A could download photo B (incl. a hidden one).
const tokenPhotoId = Number(tokenValidation.data?.photoId);
if (!Number.isInteger(tokenPhotoId) || tokenPhotoId !== Number(photoId)) {
return res.status(403).json({ error: 'Token not valid for this photo' });
}
// Verify photo exists
const photo = await db('photos')
.where({ id: photoId, event_id: req.event.id })
@@ -302,6 +355,23 @@ router.get('/:slug/secure-download/:photoId/:token',
return res.status(404).json({ error: 'Photo not found' });
}
// Block guest access to hidden/client-only photos.
if (isPhotoHiddenFromViewer(photo, req.accessLevel)) {
return res.status(403).json({ error: 'Photo not available' });
}
// Per-category download opt-out (#640) — the regular single-photo
// download enforces this too; the secure path skipped it. SQLite
// returns the boolean as numeric 0, so check both forms.
if (photo.category_id) {
const cat = await db('photo_categories')
.where('id', photo.category_id)
.first('allow_downloads');
if (cat && (cat.allow_downloads === false || cat.allow_downloads === 0)) {
return res.status(403).json({ error: 'Downloads are disabled for this category' });
}
}
// Resolve photo through storage backend (managed) or local disk (external).
const storageKey = resolvePhotoStorageKey(req.event, photo);
@@ -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' });
+6 -4
View File
@@ -1,4 +1,5 @@
const archiver = require('archiver');
const { neutralizeSpreadsheetFormula } = require('../utils/spreadsheetSafe');
const fs = require('fs');
const fsp = require('fs').promises;
const path = require('path');
@@ -261,12 +262,13 @@ function convertToCSV(data) {
const csvRows = data.map(row => {
return headers.map(header => {
const value = row[header];
// Escape quotes and wrap in quotes if contains comma
if (typeof value === 'string' && (value.includes(',') || value.includes('"'))) {
// Formula-neutralize before quoting (guest_name/comment_text are
// user-controlled); the old check didn't even escape \n/\r (GHSA-q82f).
const value = neutralizeSpreadsheetFormula(row[header]);
if (value.includes(',') || value.includes('"') || value.includes('\n') || value.includes('\r')) {
return `"${value.replace(/"/g, '""')}"`;
}
return value || '';
return value;
}).join(',');
});
+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');
}
/**
+8
View File
@@ -845,6 +845,14 @@ function parseRsyncStats(output) {
async function performRsyncBackup(config, files) {
const { spawnAsync } = require('../utils/safeExec');
// SSRF: the /test-connection route validates the host, but a scheduled or
// manual /run reaches here directly with the stored host. Resolve-and-vet
// it right before ssh/rsync does its own DNS at connect time, so a host
// that resolves to an internal address can't be reached (GHSA-4jh8).
const { isHostAllowed } = require('../utils/networkValidation');
if (!(await isHostAllowed(config.backup_rsync_host))) {
throw new Error('rsync host resolves to a private or internal network address');
}
// Anchored excludes for the de-selected What-to-Backup paths; rsync
// otherwise transfers the whole storage root regardless of the walker's
// file list (which only feeds manifests and file state).
+6 -2
View File
@@ -281,8 +281,12 @@ async function cleanupExpiredUploads() {
return expiredIds.length;
}
// Run cleanup every hour
setInterval(cleanupExpiredUploads, 60 * 60 * 1000);
// Run cleanup every hour. unref so this module-level housekeeping timer
// never holds the process open on its own — in production the HTTP
// listener keeps the loop alive, and in Jest this exact handle kept the
// runner from exiting for every suite that requires adminPhotos (#908;
// it is why adminPhotos.reference sits on the CI ignore list).
setInterval(cleanupExpiredUploads, 60 * 60 * 1000).unref();
module.exports = {
initializeUpload,
+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.
@@ -112,8 +112,15 @@ class DownloadZipService {
const event = await db('events').where({ id: eventId }).first();
if (!event) return { success: false, error: 'Event not found' };
// The prebuilt zip is served to ordinary gallery guests (the
// download-all fast path), so it must exclude hidden/client-only
// photos — NULL visibility counts as visible (pre-migration rows).
// PIN-clients bypass this cache and stream a full archive instead.
const photos = await db('photos')
.where({ event_id: eventId })
.where(function () {
this.where('visibility', 'visible').orWhereNull('visibility');
})
.select('*')
.orderBy('type', 'asc')
.orderBy('uploaded_at', 'desc');
+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);
}
+6 -2
View File
@@ -400,7 +400,7 @@ class FeedbackService {
/**
* Get feedback requiring moderation
*/
async getPendingModeration(eventId = null) {
async getPendingModeration(eventId = null, ownedEventIds = null) {
try {
let query = db('photo_feedback')
.join('photos', 'photo_feedback.photo_id', 'photos.id')
@@ -408,9 +408,13 @@ class FeedbackService {
.where('photo_feedback.is_approved', false)
.where('photo_feedback.is_hidden', false)
.where('photo_feedback.feedback_type', 'comment');
if (eventId) {
query = query.where('photo_feedback.event_id', eventId);
} else if (Array.isArray(ownedEventIds)) {
// Scope to the caller's owned events (GHSA-3335) — an empty set
// matches nothing, so a restricted admin sees only their own.
query = query.whereIn('photo_feedback.event_id', ownedEventIds.length ? ownedEventIds : [-1]);
}
const pending = await query
+5 -1
View File
@@ -6,6 +6,7 @@
const archiver = require('archiver');
const { PassThrough } = require('stream');
const { XmpGenerator } = require('./xmpGenerator');
const { neutralizeSpreadsheetFormula } = require('../utils/spreadsheetSafe');
const { db } = require('../database/db');
const path = require('path');
const fs = require('fs').promises;
@@ -162,7 +163,10 @@ class PhotoExportService {
const csvContent = [
headers.join(','),
...rows.map(row => row.map(cell => `"${String(cell).replace(/"/g, '""')}"`).join(','))
// Formula-neutralize each cell before quoting — filenames/categories
// are user-controlled, and quoting alone doesn't stop `=cmd()`
// execution (GHSA-5364).
...rows.map(row => row.map(cell => `"${neutralizeSpreadsheetFormula(cell).replace(/"/g, '""')}"`).join(','))
].join('\n');
return {
+5 -3
View File
@@ -326,9 +326,11 @@ async function queueFilesForProcessing(files, options = {}) {
if (fileList.length === 0) return { uploadId, photos: queued, errors };
// Counter base — same approximation the upload route used pre-async.
// Strict uniqueness is still enforced by the filename template; on a
// collision the worker would just fail one photo.
// Counter base — a per-request approximation (concurrent calls can
// compute the same base). Uniqueness of the final path comes from the
// random suffix inside generatePhotoFilename (#931) — before that
// suffix, a counter collision silently overwrote the first photo's
// bytes at its already-recorded path.
const existingCount = await db('photos')
.where({ event_id: eventId, type: photoType })
.count('id as count')
+117 -12
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,8 +262,12 @@ 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) {
async function linkDealToProject(dealUuid, projectId, conn = db, actor = null) {
if (!dealUuid || !projectId) return;
// Collect ALL the deal's customers across its quote/contract/invoice lineage
@@ -273,6 +310,31 @@ 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 route
// guard (requireProjectOwnership) only vets `projectId`; 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 +356,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 +379,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 +454,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 +550,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 }
};
+7 -4
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
@@ -1047,7 +1047,9 @@ async function sendQuote(id, adminId) {
});
try {
await logActivity('quote_sent', { quoteId: id, token }, null, `admin:${adminId}`);
// Do NOT log the raw bearer token — it grants quote actions and the
// activity log is readable later (GHSA-prch). The quoteId is the audit key.
await logActivity('quote_sent', { quoteId: id }, null, `admin:${adminId}`);
} catch (_) {}
// Fire the quote.sent workflow trigger (best-effort; emit is fail-closed when
@@ -1272,7 +1274,8 @@ async function recordResponse({ token, action, ip, tosAccepted }) {
});
try {
await logActivity(`quote_${newStatus}`, { quoteId: quote.id, token: tokenRow.token }, null, 'customer:public');
// Raw bearer token must not reach the activity log (GHSA-prch).
await logActivity(`quote_${newStatus}`, { quoteId: quote.id }, null, 'customer:public');
} catch (_) {}
// Defer the workflow emit until the 15-min toggle window locks — so accepting
+74 -9
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');
@@ -12,6 +13,15 @@ const backupManifest = require('./backupManifest');
const S3StorageAdapter = require('./storage/s3Storage');
const { queueEmail } = require('./emailProcessor');
const { formatBoolean } = require('../utils/dbCompat');
// A manifest is attacker-influenceable (hand-crafted backup). Reject any
// entry path that would resolve OUTSIDE its intended base directory
// (traversal / absolute path) before any fs write. The target may not exist
// yet, so resolve rather than realpath (GHSA-fm58).
function pathEscapes(baseDir, candidate) {
const rel = path.relative(path.resolve(baseDir), path.resolve(candidate));
return !rel || rel === '..' || rel.startsWith('..' + path.sep) || path.isAbsolute(rel);
}
const { formatBytes } = require('../utils/formatBytes');
const os = require('os');
@@ -510,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
@@ -771,7 +789,14 @@ class RestoreService {
for (const file of filesToDownload) {
const s3Key = path.posix.join(prefix, file.path);
const localFilePath = path.join(localPath, file.path);
// Containment guard (GHSA-fm58): reject a manifest path that would
// write outside the download staging dir.
if (pathEscapes(localPath, localFilePath)) {
this.log('error', `Refusing unsafe manifest path on download: ${file.path}`);
continue;
}
await fs.mkdir(path.dirname(localFilePath), { recursive: true });
try {
@@ -1204,6 +1229,14 @@ END $$;`
const sourcePath = path.join(backupPath, file.path);
const targetPath = path.join(storagePath, file.path);
// Containment guard (GHSA-fm58): a crafted manifest path like
// `../../etc/cron.d/x` would otherwise escape the storage root and
// overwrite arbitrary files. Skip any entry that escapes.
if (pathEscapes(backupPath, sourcePath) || pathEscapes(storagePath, targetPath)) {
errors.push(`Refusing unsafe manifest path: ${file.path}`);
continue;
}
// Check if source file exists
try {
await fs.access(sourcePath);
@@ -1375,6 +1408,15 @@ END $$;`
for (const file of filesToVerify) {
const filePath = path.join(storagePath, file.path);
// Same containment guard as performFilesRestore: a traversal
// manifest entry (e.g. `../../etc/passwd`) was skipped during the
// restore, so it must not be fs.access'd/hashed here either —
// otherwise an existing outside file makes the skipped entry look
// "verified" (and we'd read an arbitrary file off disk).
if (pathEscapes(storagePath, filePath)) {
verification.errors.push(`Refusing unsafe manifest path on verification: ${file.path}`);
continue;
}
try {
await fs.access(filePath);
@@ -1479,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);
}
/**
+6 -1
View File
@@ -21,7 +21,11 @@ class SecureImageService {
expiresIn = 300, // 5 minutes default
maxUses = 1,
clientFingerprint = '',
protectionLevel = 'standard'
protectionLevel = 'standard',
// Whether the minter was a PIN-client — lets the serve route keep
// delivering a photo hidden AFTER minting (TOCTOU). A guest's token
// carries false, so it stops the moment the photo is hidden.
clientBypass = false
} = options;
const tokenData = {
@@ -32,6 +36,7 @@ class SecureImageService {
maxUses,
usedCount: 0,
protectionLevel,
clientBypass,
createdAt: Date.now()
};
+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 };
+10 -1
View File
@@ -159,7 +159,16 @@ const resolveShareIdentifier = async (identifier) => {
return { event, matchType: 'link', shareToken: getEventShareToken(event) };
}
event = await baseQuery.clone().where('share_link', 'like', `%/${trimmed}`).first();
// GHSA-rh8r hardening: `trimmed` is attacker-controlled, so escape LIKE
// wildcards (`%`, `_`, and the escape char itself) before embedding it.
// Otherwise an anonymous `/resolve/________…________` (32 underscores)
// matches ANY share_link via single-char wildcards, resolves as
// matchType 'link_partial', and the /resolve route hands back the
// gallery's bearer token — reopening the very hole the token-withholding
// fix closed. Explicit ESCAPE clause because SQLite has no default LIKE
// escape character (Postgres defaults to backslash, but we set it for both).
const likeTail = `%/${trimmed.replace(/[\\%_]/g, (c) => `\\${c}`)}`;
event = await baseQuery.clone().whereRaw('share_link LIKE ? ESCAPE \'\\\'', [likeTail]).first();
if (event) {
return { event, matchType: 'link_partial', shareToken: getEventShareToken(event) };
}
+34 -2
View File
@@ -6,6 +6,11 @@ const crypto = require('crypto');
const logger = require('../../utils/logger');
// Staging files older than this are considered orphaned by a crash between
// copy and rename, and are reclaimed during list() walks. Generous enough
// that no legitimate in-flight copy (even multi-GB on slow NFS) hits it.
const STAGING_RECLAIM_AGE_MS = 60 * 60 * 1000;
/**
* Filesystem-backed implementation of the StorageBackend interface.
* All keys are relative to `root` (typically process.env.STORAGE_PATH).
@@ -67,8 +72,18 @@ class LocalFsStorage {
async putFromFile(relPath, localPath, _options = {}) {
const abs = this._resolve(relPath);
await fsp.mkdir(path.dirname(abs), { recursive: true });
// copyFile is atomic from the destination's perspective on POSIX.
await fsp.copyFile(localPath, abs);
// copyFile truncates and rewrites the destination in place, so a
// concurrent reader (thumbnail/watermark generation, photo serving)
// can observe partial or foreign bytes mid-copy (#931). Copy to a
// sibling tmp file and rename, like put() above — rename IS atomic.
const tmp = `${abs}.tmp.${process.pid}.${crypto.randomBytes(4).toString('hex')}`;
try {
await fsp.copyFile(localPath, tmp);
await fsp.rename(tmp, abs);
} catch (err) {
await fsp.unlink(tmp).catch(() => {});
throw err;
}
}
async get(relPath) {
@@ -126,6 +141,23 @@ class LocalFsStorage {
throw err;
}
for (const ent of dirents) {
// Hide in-flight staging files (put/putFromFile write `<key>.tmp.<pid>.<hex>`
// siblings before the atomic rename). Without this filter a
// concurrent archive/backup listing could stream a partial tmp
// entry or fail when the rename wins the race (#931). Stale ones
// (a crash between copy and rename orphans them) are reclaimed
// here — hiding without reclaiming would let interrupted uploads
// accumulate invisible files until the volume fills.
if (/\.tmp\.\d+\.[0-9a-f]+$/.test(ent.name)) {
const childAbs = path.join(dir, ent.name);
try {
const st = await fsp.stat(childAbs);
if (Date.now() - st.mtimeMs > STAGING_RECLAIM_AGE_MS) {
await fsp.unlink(childAbs).catch(() => {});
}
} catch { /* vanished (rename/cleanup won the race) — fine */ }
continue;
}
const childAbs = path.join(dir, ent.name);
const childRel = relBase ? `${relBase}/${ent.name}` : ent.name;
if (ent.isDirectory()) {
@@ -0,0 +1,130 @@
/**
* #931 LocalFsStorage.putFromFile must be atomic. The old implementation
* used fs.copyFile straight onto the destination, which truncates and
* rewrites in place: a concurrent reader (thumbnail/watermark generation,
* photo serving) could observe partial or foreign bytes mid-copy. The fix
* copies to a sibling tmp file and renames, like put() always did.
*/
const fs = require('fs');
const fsp = require('fs/promises');
const os = require('os');
const path = require('path');
const LocalFsStorage = require('../LocalFsStorage');
describe('LocalFsStorage.putFromFile', () => {
let root;
let srcDir;
let storage;
beforeEach(async () => {
root = await fsp.mkdtemp(path.join(os.tmpdir(), 'picpeak-lfs-root-'));
srcDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'picpeak-lfs-src-'));
storage = new LocalFsStorage({ root });
});
afterEach(async () => {
await fsp.rm(root, { recursive: true, force: true });
await fsp.rm(srcDir, { recursive: true, force: true });
});
it('writes the source bytes to the destination key', async () => {
const src = path.join(srcDir, 'a.jpg');
await fsp.writeFile(src, Buffer.from('photo-a-bytes'));
await storage.putFromFile('events/active/ev/a.jpg', src);
const out = await fsp.readFile(path.join(root, 'events/active/ev/a.jpg'));
expect(out.toString()).toBe('photo-a-bytes');
});
it('leaves no tmp files behind after a successful write', async () => {
const src = path.join(srcDir, 'a.jpg');
await fsp.writeFile(src, Buffer.from('photo-a-bytes'));
await storage.putFromFile('events/active/ev/a.jpg', src);
const entries = await fsp.readdir(path.join(root, 'events/active/ev'));
expect(entries).toEqual(['a.jpg']);
});
it('leaves no tmp files behind when the source is missing', async () => {
await expect(
storage.putFromFile('events/active/ev/missing.jpg', path.join(srcDir, 'nope.jpg'))
).rejects.toThrow();
const entries = await fsp.readdir(path.join(root, 'events/active/ev')).catch(() => []);
expect(entries.filter((e) => e.includes('.tmp.'))).toEqual([]);
});
it('hides in-flight staging files from list()', async () => {
const src = path.join(srcDir, 'a.jpg');
await fsp.writeFile(src, Buffer.from('photo-a-bytes'));
await storage.putFromFile('events/active/ev/a.jpg', src);
// Simulate a concurrent writer's staging file: archiveEvent lists
// this exact prefix and must never see (stream/delete) it.
await fsp.writeFile(
path.join(root, 'events/active/ev/b.jpg.tmp.12345.deadbeef'),
Buffer.from('partial')
);
const keys = (await storage.list('events/active/ev')).map((e) => e.key ?? e);
expect(JSON.stringify(keys)).toContain('a.jpg');
expect(JSON.stringify(keys)).not.toContain('.tmp.');
});
it('reclaims stale orphaned staging files during list()', async () => {
const dir = path.join(root, 'events/active/ev');
await fsp.mkdir(dir, { recursive: true });
const fresh = path.join(dir, 'f.jpg.tmp.111.aaaaaaaa');
const stale = path.join(dir, 's.jpg.tmp.222.bbbbbbbb');
await fsp.writeFile(fresh, Buffer.from('in-flight'));
await fsp.writeFile(stale, Buffer.from('orphaned'));
// Age the "stale" one past the reclaim threshold (1h).
const old = new Date(Date.now() - 2 * 60 * 60 * 1000);
await fsp.utimes(stale, old, old);
await storage.list('events/active/ev');
// Fresh in-flight staging survives (a live copy may still rename it);
// the crash orphan is gone.
await expect(fsp.stat(fresh)).resolves.toBeDefined();
await expect(fsp.stat(stale)).rejects.toThrow();
});
it('never exposes a partially written destination (tmp+rename atomicity)', async () => {
// A large-ish payload so the copy is not a single instantaneous block.
const big = Buffer.alloc(8 * 1024 * 1024, 0xab);
const src = path.join(srcDir, 'big.bin');
await fsp.writeFile(src, big);
const key = 'events/active/ev/big.bin';
const dest = path.join(root, key);
// Poll the destination while the copy runs: it must either not exist
// yet or already have the full size — never an in-between truncated
// state (which is exactly what in-place copyFile produced).
const observed = [];
const poller = (async () => {
for (let i = 0; i < 200; i++) {
try {
const st = fs.statSync(dest);
observed.push(st.size);
} catch {
// not there yet — fine
}
await new Promise((r) => setImmediate(r));
}
})();
await storage.putFromFile(key, src);
await poller;
for (const size of observed) {
expect(size).toBe(big.length);
}
const out = await fsp.stat(dest);
expect(out.size).toBe(big.length);
});
});
+13
View File
@@ -131,6 +131,19 @@ class S3StorageAdapter extends stream.EventEmitter {
*/
async testConnection() {
try {
// Resolve-and-vet the custom endpoint before the network round-trip
// (the constructor's literal check can't catch a public-looking
// hostname that resolves to an internal IP). Prod-only, matching the
// constructor gate — dev points at localhost MinIO deliberately.
if (process.env.NODE_ENV === 'production' && this.config.endpoint) {
const { isHostAllowed } = require('../../utils/networkValidation');
const { hostname } = new URL(
/^https?:\/\//.test(this.config.endpoint) ? this.config.endpoint : `https://${this.config.endpoint}`
);
if (!(await isHostAllowed(hostname))) {
throw new Error('S3 endpoint resolves to a private or internal network address');
}
}
await this.s3Client.send(new HeadBucketCommand({ Bucket: this.bucket }));
logger.info(`Successfully connected to S3 bucket: ${this.bucket}`);
return true;
@@ -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) {
@@ -8,6 +8,7 @@
* - Tracking regeneration progress
*/
const pLimit = require('p-limit');
const { db } = require('../database/db');
const watermarkService = require('./watermarkService');
const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('./photoResolver');
@@ -22,6 +23,13 @@ class WatermarkGeneratorService {
this.batchSize = 10;
// Concurrent processing limit
this.concurrentLimit = 2;
// ONE process-wide limiter for every sharp pipeline this service
// spawns (#931). Per-invocation limiters would stack: overlapping
// regenerateAll/generateForEvent calls each brought their own cap,
// and the fire-and-forget generateForPhoto side-effect (one per
// uploaded photo) had no cap at all — a 363-photo bulk upload could
// decode 363 full-resolution images concurrently (#628 OOM class).
this.limit = pLimit(this.concurrentLimit);
}
/**
@@ -62,11 +70,21 @@ class WatermarkGeneratorService {
// (external reference mode). watermarkService needs a local file path.
const event = { slug: photo.slug, source_mode: photo.source_mode, external_path: photo.external_path };
const storageKey = resolvePhotoStorageKey(event, photo);
const result = storageKey
? await withLocalCopy(storageKey, (lp) =>
watermarkService.generateAndSaveWatermark(photo, lp, settings)
)
: await watermarkService.generateAndSaveWatermark(photo, resolvePhotoFilePath(event, photo), settings);
const result = await this.limit(async () => {
// Revalidate inside the limited slot: a long queue (bulk upload)
// can hold this job for minutes, during which an admin may disable
// watermarking — running with the captured settings would recreate
// files AFTER clearAllWatermarks() wiped them (#931 round 3).
const fresh = await watermarkService.getWatermarkSettings();
if (!fresh || !fresh.enabled) {
return { success: false, watermarkPath: null, error: 'Watermarking is disabled' };
}
return storageKey
? withLocalCopy(storageKey, (lp) =>
watermarkService.generateAndSaveWatermark(photo, lp, fresh)
)
: watermarkService.generateAndSaveWatermark(photo, resolvePhotoFilePath(event, photo), fresh);
});
if (result.success) {
// Update database with watermark path
@@ -122,7 +140,11 @@ class WatermarkGeneratorService {
return { ...results, errors: ['Watermarking is disabled'] };
}
// Process in batches
// Process in batches. processPhotoWatermark routes every sharp
// pipeline through the shared instance limiter — a bare Promise.all
// over the batch ran all 10 at once, decoding 10 full-resolution
// images simultaneously (#931; same OOM class as #628 in the
// thumbnail path).
for (let i = 0; i < photos.length; i += this.batchSize) {
const batch = photos.slice(i, i + this.batchSize);
@@ -168,11 +190,21 @@ class WatermarkGeneratorService {
try {
const event = { slug: photo.slug, source_mode: photo.source_mode, external_path: photo.external_path };
const storageKey = resolvePhotoStorageKey(event, photo);
const result = storageKey
? await withLocalCopy(storageKey, (lp) =>
watermarkService.generateAndSaveWatermark(photo, lp, settings)
)
: await watermarkService.generateAndSaveWatermark(photo, resolvePhotoFilePath(event, photo), settings);
const result = await this.limit(async () => {
// Same revalidation as generateForPhoto: batch jobs queue for a
// long time, and a disable mid-run must not recreate files after
// clearAllWatermarks(). The batch's `settings` snapshot is still
// used for rendering; only the enabled gate is rechecked.
const fresh = await watermarkService.getWatermarkSettings();
if (!fresh || !fresh.enabled) {
return { success: false, watermarkPath: null, error: 'Watermarking is disabled' };
}
return storageKey
? withLocalCopy(storageKey, (lp) =>
watermarkService.generateAndSaveWatermark(photo, lp, settings)
)
: watermarkService.generateAndSaveWatermark(photo, resolvePhotoFilePath(event, photo), settings);
});
if (result.success) {
await db('photos')
@@ -235,7 +267,8 @@ class WatermarkGeneratorService {
logger.info(`Starting watermark regeneration for ${photos.length} photos`);
// Process in batches
// Process in batches, capped at concurrentLimit parallel sharp
// pipelines via the shared instance limiter (see generateForEvent).
for (let i = 0; i < photos.length; i += this.batchSize) {
// Check if job was cancelled
if (!this.activeJobs.has(jobId)) {
+47 -5
View File
@@ -2,7 +2,7 @@ const axios = require('axios');
const { db } = require('../database/db');
const logger = require('../utils/logger');
const { signPayload, renderTemplate } = require('./webhookService');
const { validateExternalUrl } = require('../utils/networkValidation');
const { validateExternalUrlAsync } = require('../utils/networkValidation');
const POLL_INTERVAL_MS = parseInt(process.env.WEBHOOK_DELIVERY_INTERVAL_MS || '5000', 10);
const CONCURRENCY = parseInt(process.env.WEBHOOK_DELIVERY_CONCURRENCY || '5', 10);
@@ -91,12 +91,24 @@ async function deliverOne(row) {
return;
}
// Re-validate URL per delivery — DNS-rebinding mitigation. Admin can opt
// out via WEBHOOK_ALLOW_PRIVATE_URLS=true for local-receiver dev runs.
// Re-validate URL per delivery — DNS-rebinding mitigation. Resolves the
// host and vets every A/AAAA record (a public-looking name that now
// resolves to an internal IP is rejected). Admin can opt out via
// WEBHOOK_ALLOW_PRIVATE_URLS=true for local-receiver dev runs.
if (!allowPrivateUrls) {
const urlCheck = validateExternalUrl(webhook.url);
const urlCheck = await validateExternalUrlAsync(webhook.url);
if (!urlCheck.valid) {
await markFailedFinal(row, `URL rejected: ${urlCheck.error}`);
// A transient lookup failure ('unresolved' — EAI_AGAIN, resolver
// briefly down) must NOT connect: falling through to axios would let
// an attacker SERVFAIL this preflight and answer axios's own lookup
// with a private/metadata IP, defeating the guard. Schedule the
// normal retry/backoff instead — no request is made. A confirmed
// policy rejection (resolves-to-private / malformed) is permanent.
if (urlCheck.reason === 'unresolved') {
await scheduleTransientRetry(row, webhook, 'URL host did not resolve — retrying');
} else {
await markFailedFinal(row, `URL rejected: ${urlCheck.error}`);
}
return;
}
}
@@ -217,6 +229,36 @@ async function markFailedFinal(row, reason) {
await db('webhooks').where({ id: row.webhook_id }).update({ last_failure_at: new Date() });
}
// Schedule the normal retry/backoff for a transient failure that must not
// make a network request (e.g. the SSRF preflight lookup failed). Mirrors
// the failure branch of the main delivery path: retry until MAX_ATTEMPTS,
// then give up. No response fields — nothing was sent.
async function scheduleTransientRetry(row, webhook, errorMsg) {
const newAttempt = row.attempt_count + 1;
if (newAttempt >= MAX_ATTEMPTS) {
await db('webhook_deliveries')
.where({ id: row.id })
.update({
status: 'failed',
last_error: errorMsg,
attempt_count: newAttempt,
completed_at: new Date(),
next_retry_at: null,
});
} else {
const backoff = BACKOFF_MS[Math.min(newAttempt - 1, BACKOFF_MS.length - 1)];
await db('webhook_deliveries')
.where({ id: row.id })
.update({
status: 'pending',
last_error: errorMsg,
attempt_count: newAttempt,
next_retry_at: new Date(Date.now() + backoff),
});
}
await db('webhooks').where({ id: webhook.id }).update({ last_failure_at: new Date() });
}
function stringifyBody(data) {
if (data == null) return null;
if (typeof data === 'string') return data;
@@ -89,23 +89,25 @@ describe('sanitizeFilename — edge cases', () => {
});
describe('generatePhotoFilename — composed name uses the NFD pipeline', () => {
it('round-trips Ägypten + individual → Agypten_individual_0050.jpg (#607)', () => {
// The trailing _[0-9a-f]{12} is the anti-collision suffix (#931) that
// keeps concurrent uploads from assigning the same final storage path.
it('round-trips Ägypten + individual → Agypten_individual_0050 (#607)', () => {
expect(generatePhotoFilename('Ägypten', 'individual', 50, '.jpg'))
.toBe('Agypten_individual_0050.jpg');
.toMatch(/^Agypten_individual_0050_[0-9a-f]{12}\.jpg$/);
});
it('handles missing category by defaulting to "uncategorized"', () => {
expect(generatePhotoFilename('Wedding', null, 1, '.jpg'))
.toBe('Wedding_uncategorized_0001.jpg');
.toMatch(/^Wedding_uncategorized_0001_[0-9a-f]{12}\.jpg$/);
});
it('zero-pads the counter to 4 digits', () => {
expect(generatePhotoFilename('e', 'c', 7, '.png')).toBe('e_c_0007.png');
expect(generatePhotoFilename('e', 'c', 1234, '.png')).toBe('e_c_1234.png');
// 5+ digit counters intentionally overflow the pad — pinned because
// the unique index in the photos table doesn't care about pad width,
// only string uniqueness.
expect(generatePhotoFilename('e', 'c', 99999, '.png')).toBe('e_c_99999.png');
expect(generatePhotoFilename('e', 'c', 7, '.png')).toMatch(/^e_c_0007_[0-9a-f]{12}\.png$/);
expect(generatePhotoFilename('e', 'c', 1234, '.png')).toMatch(/^e_c_1234_[0-9a-f]{12}\.png$/);
// 5+ digit counters intentionally overflow the pad — pad width never
// mattered for uniqueness (there is no unique index on filenames);
// the random suffix is what guarantees it.
expect(generatePhotoFilename('e', 'c', 99999, '.png')).toMatch(/^e_c_99999_[0-9a-f]{12}\.png$/);
});
});
+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 };
+13 -2
View File
@@ -1,4 +1,5 @@
const path = require('path');
const crypto = require('crypto');
/**
* Sanitize a string to be used as a filename component
@@ -59,8 +60,18 @@ function generatePhotoFilename(eventName, categoryName, counter, extension) {
const sanitizedEvent = sanitizeFilename(eventName, 30);
const sanitizedCategory = sanitizeFilename(categoryName || 'uncategorized', 20);
const paddedCounter = String(counter).padStart(4, '0');
return `${sanitizedEvent}_${sanitizedCategory}_${paddedCounter}${extension}`;
// Random suffix (#931): the counter base is `count(*)+1` computed per
// upload request, so two concurrent bulk-upload requests can assign the
// same counter to different photos. Since files are written to their
// final path before any row exists (and photos has no unique index on
// filename — one can't be added without a dedupe migration on installs
// that already carry historical duplicates), a collision silently
// overwrites the first photo's bytes at its recorded path — cross-photo
// contamination. 48 bits keep the collision odds negligible even for
// pathological concurrency (two simultaneous 2000-photo uploads: ~7e-12).
const suffix = crypto.randomBytes(6).toString('hex');
return `${sanitizedEvent}_${sanitizedCategory}_${paddedCounter}_${suffix}${extension}`;
}
/**
+68 -1
View File
@@ -1,5 +1,6 @@
const { URL } = require('url');
const net = require('net');
const dns = require('dns').promises;
/**
* Check if a hostname or IP resolves to a private/internal network address.
@@ -162,6 +163,11 @@ function isPrivateIPv6(ip) {
/**
* Validate a URL string, rejecting private/internal targets.
*
* NOTE: literal-only. For a hostname (not an IP), this checks the string but
* NOT what it resolves to `evil.example` with an A record of 10.0.0.5
* passes. Prefer isHostAllowed / validateExternalUrlAsync at any call site
* that then actually connects; kept for synchronous callers and fast checks.
* @param {string} urlString - URL to validate
* @returns {{ valid: boolean, error?: string }}
*/
@@ -177,4 +183,65 @@ function validateExternalUrl(urlString) {
}
}
module.exports = { isPrivateIP, validateExternalUrl };
/**
* Resolve a hostname and reject if it (or ANY of its A/AAAA records) points
* at a private/internal address. Closes the SSRF hole where a public-looking
* hostname resolves to an internal IP or the cloud metadata endpoint the
* literal isPrivateIP check alone can't see that. Fails closed on resolution
* failure. IP literals are decided by isPrivateIP without a lookup.
*
* Residual: a determined attacker who controls DNS can still rebind between
* this check and the client's own resolution (TOCTOU). Fully closing that
* needs pinning the connection to the vetted IP, which the underlying
* clients (nodemailer/imap/ssh/aws-sdk) don't cleanly support; these actions
* are admin-only, so resolve-and-vet is the proportionate mitigation.
*
* @param {string} hostname
* @returns {Promise<boolean>} true when safe to connect
*/
async function classifyHost(hostname) {
if (!hostname || typeof hostname !== 'string') return 'invalid';
// Literal check first: IP literals, blocked names, .internal/.local/.localhost.
if (isPrivateIP(hostname)) return 'private';
// An IP literal is fully decided above — no name to resolve.
const bare = hostname.replace(/^\[|\]$/g, '');
if (net.isIP(bare)) return 'ok';
let addresses;
try {
addresses = await dns.lookup(hostname, { all: true });
} catch {
return 'unresolved'; // transient/NXDOMAIN — caller decides retry vs reject
}
if (!addresses.length) return 'unresolved';
return addresses.every((a) => !isPrivateIP(a.address)) ? 'ok' : 'private';
}
async function isHostAllowed(hostname) {
// Fail-closed boolean for save/test call sites: anything not clearly 'ok'
// (including a transient lookup failure) is rejected.
return (await classifyHost(hostname)) === 'ok';
}
/**
* Async, DNS-resolving counterpart to validateExternalUrl. Returns a `reason`
* so callers with retry semantics (e.g. the webhook worker) can distinguish a
* policy rejection ('private'/'invalid') from a transient lookup failure
* ('unresolved') that should be retried rather than permanently failed.
* @param {string} urlString
* @returns {Promise<{ valid: boolean, error?: string, reason: string }>}
*/
async function validateExternalUrlAsync(urlString) {
let parsed;
try {
parsed = new URL(urlString);
} catch {
return { valid: false, error: 'Invalid URL format', reason: 'invalid' };
}
const reason = await classifyHost(parsed.hostname);
if (reason !== 'ok') {
return { valid: false, error: 'URL points to a private or internal network address', reason };
}
return { valid: true, reason: 'ok' };
}
module.exports = { isPrivateIP, validateExternalUrl, isHostAllowed, validateExternalUrlAsync, classifyHost };
+46
View File
@@ -0,0 +1,46 @@
/**
* Shared hidden-photo access control.
*
* PicPeak photos carry a `visibility` column: 'visible' (or NULL, for
* pre-migration rows) is shown to everyone; 'hidden' is client-only. A
* gallery viewer's `req.accessLevel` is 'client' for a PIN-client login and
* something else ('guest'/'slideshow'/) for an ordinary guest.
*
* The main photo-list query and the single-photo download/view routes each
* enforced this inline, but several bulk/secure paths (download-all,
* download-selected, protected-image view, signed-URL mint, secure-token
* mint, secure-download) shipped without it letting ordinary guests reach
* hidden/client-only photos. These helpers centralise the rule so every
* sink applies exactly the same predicate.
*/
// PIN-clients see hidden photos; everyone else does not.
function canSeeHiddenPhotos(accessLevel) {
return accessLevel === 'client';
}
/**
* Append the guest visibility filter to a knex `photos` query. No-op for
* clients. NULL visibility is treated as visible (pre-migration default).
* The query must reference the table as `photos` (all call sites do).
*/
function applyPhotoVisibilityFilter(query, accessLevel) {
if (canSeeHiddenPhotos(accessLevel)) return query;
return query.where(function () {
this.where('photos.visibility', 'visible').orWhereNull('photos.visibility');
});
}
/**
* Single-photo predicate: true when this photo must be blocked for a viewer
* at the given access level. Mirrors the inline guards in gallery.js.
*/
function isPhotoHiddenFromViewer(photo, accessLevel) {
return !!photo && photo.visibility === 'hidden' && !canSeeHiddenPhotos(accessLevel);
}
module.exports = {
canSeeHiddenPhotos,
applyPhotoVisibilityFilter,
isPhotoHiddenFromViewer,
};

Some files were not shown because too many files have changed in this diff Show More